Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdbc19cf0d | ||
|
|
adc7a2a05b | ||
|
|
29554ebdbd | ||
|
|
434643596e | ||
|
|
313ab724b8 | ||
|
|
ee7d7f7cac | ||
|
|
0dd18c4fd4 | ||
|
|
5869c741d7 | ||
|
|
5b6e461706 | ||
|
|
53adc74a1c | ||
|
|
9a7cc86a75 | ||
|
|
6cba932146 | ||
|
|
ba829be8e9 | ||
|
|
9f58917c39 | ||
|
|
a375fbe37f | ||
|
|
38ca23dc3e | ||
|
|
8fe8edb341 | ||
|
|
143fff6414 | ||
|
|
c216df8021 | ||
|
|
f7bb84f9b8 | ||
|
|
cbff4deef2 | ||
|
|
85ecae7147 | ||
|
|
8a79675497 | ||
|
|
6951bc1700 | ||
|
|
e6f6bd5c3a | ||
|
|
b3ec09fbef | ||
|
|
765573163d | ||
|
|
1505adb154 | ||
|
|
ade0be97f6 | ||
|
|
96db086ecb | ||
|
|
6610abbd72 | ||
|
|
489c2208c5 | ||
|
|
3db843a6f9 | ||
|
|
79e7d4df9c | ||
|
|
2e3cb134d1 | ||
|
|
aca4d045b2 | ||
|
|
a0afbc90f5 | ||
|
|
57429f3a75 | ||
|
|
01a8a59349 | ||
|
|
0150ca3c32 | ||
|
|
33b9278085 | ||
|
|
9196af12f8 | ||
|
|
f6e2ab63dd | ||
|
|
f1c1a72f44 | ||
|
|
1e7b387b43 | ||
|
|
135ba4d26d | ||
|
|
8c1623256b | ||
|
|
b246106876 | ||
|
|
d5a7464e15 | ||
|
|
061cd22748 | ||
|
|
55da04a3f7 | ||
|
|
2a6359e247 | ||
|
|
3afba919c4 | ||
|
|
07ceef1294 | ||
|
|
50c7bb68bb | ||
|
|
3ffbb11552 | ||
|
|
669a6616d8 | ||
|
|
65bce2c52d | ||
|
|
cd37771c52 | ||
|
|
df59207add | ||
|
|
2d15b7cc03 | ||
|
|
d3bd25d544 | ||
|
|
bf592886c6 | ||
|
|
fedbd903ff | ||
|
|
65ce1bede8 | ||
|
|
67e47fc47f | ||
|
|
0db53bfd9c | ||
|
|
e68eef8865 | ||
|
|
77282893f3 | ||
|
|
6642d5fa22 | ||
|
|
b2973dc6ad | ||
|
|
b69c4d3e4f | ||
|
|
1c9ed4bd65 | ||
|
|
de21c56543 | ||
|
|
7a1032a3bd | ||
|
|
63d6d1bbca | ||
|
|
98a4212e46 | ||
|
|
510bef02b6 | ||
|
|
9438e17f88 | ||
|
|
61ef95a35e | ||
|
|
0f5dbd12bf | ||
|
|
6ac5ab7b5c |
+1
-1
@@ -37,4 +37,4 @@ VITE_MICROSOFT_CLIENT_SECRET_ID=dummy_secret_id
|
||||
VITE_MICROSOFT_CLIENT_SECRET=dummy_secret
|
||||
VITE_MICROSOFT_TENANT_ID=common
|
||||
|
||||
VITE_SEARCH_URL=http://localhost:8888
|
||||
VITE_SEARCH_URL=https://search.kevlarai.com
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Classroom Copilot App - Environment Variables Template
|
||||
# Copy this file to .env and fill in the values
|
||||
|
||||
# =============================================================================
|
||||
# Frontend Configuration
|
||||
# =============================================================================
|
||||
VITE_APP_NAME=Classroom Copilot
|
||||
VITE_DEV=false
|
||||
VITE_FRONTEND_SITE_URL=https://app.classroomcopilot.ai
|
||||
VITE_PORT_FRONTEND=3000
|
||||
VITE_PORT_FRONTEND_HMR=24678
|
||||
VITE_APP_HMR_URL=http://localhost:24678
|
||||
|
||||
# =============================================================================
|
||||
# API Configuration
|
||||
# =============================================================================
|
||||
VITE_API_URL=https://api.classroomcopilot.ai
|
||||
VITE_API_BASE=https://api.classroomcopilot.ai
|
||||
|
||||
# =============================================================================
|
||||
# Supabase Configuration
|
||||
# =============================================================================
|
||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
|
||||
|
||||
# =============================================================================
|
||||
# TLSync (TLDraw Sync) Configuration — shared secret is server-side only (API TLSYNC_SECRET)
|
||||
# =============================================================================
|
||||
VITE_TLSYNC_URL=https://app.classroomcopilot.ai/tldraw
|
||||
|
||||
# =============================================================================
|
||||
# WhisperLive (Transcription) Configuration
|
||||
# =============================================================================
|
||||
VITE_WHISPERLIVE_URL=wss://whisperlive.classroomcopilot.ai/ws
|
||||
|
||||
# =============================================================================
|
||||
# Search Configuration
|
||||
# =============================================================================
|
||||
VITE_SEARCH_URL=https://search.kevlarai.com
|
||||
|
||||
# =============================================================================
|
||||
# Super Admin Configuration
|
||||
# =============================================================================
|
||||
VITE_SUPER_ADMIN_EMAIL=[email protected]
|
||||
|
||||
# =============================================================================
|
||||
# Port Configuration (for docker-compose)
|
||||
# =============================================================================
|
||||
PORT_FRONTEND=3000
|
||||
PORT_FRONTEND_HMR=24678
|
||||
PORT_API=8000
|
||||
PORT_SUPABASE=8000
|
||||
@@ -0,0 +1,39 @@
|
||||
name: app-ci-deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-build-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:run
|
||||
- run: npm run build
|
||||
|
||||
- name: Configure SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy app
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key "${{ secrets.DEPLOY_USER }}@${{ secrets.APP_DEPLOY_HOST }}" '
|
||||
set -euo pipefail
|
||||
cd /home/kcar/app
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
docker network inspect kevlarai-network >/dev/null 2>&1 || docker network create kevlarai-network
|
||||
docker compose -p app-prod -f docker-compose.yml up -d --build
|
||||
docker compose -p app-prod -f docker-compose.yml ps
|
||||
curl -fsSI http://127.0.0.1:3000 >/dev/null
|
||||
'
|
||||
+49
-7
@@ -1,10 +1,52 @@
|
||||
node_modules
|
||||
# Dependencies
|
||||
node_modules/
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
/dist
|
||||
/build
|
||||
*.local
|
||||
|
||||
# Environment files (never commit secrets)
|
||||
.env
|
||||
# Build output
|
||||
dist/
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.bak
|
||||
|
||||
# Lock files - clean install
|
||||
package-lock.json
|
||||
*.bak
|
||||
*.bak
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# Misc
|
||||
.cache/
|
||||
.tmp/
|
||||
*.tmp
|
||||
|
||||
# Local environment variants
|
||||
.env.dev
|
||||
.env.prod
|
||||
|
||||
# Playwright test artifacts
|
||||
test-results/
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { WrappedApp, App } from '../src/App';
|
||||
|
||||
describe('App', () => {
|
||||
it('Renders hello world', () => {
|
||||
// ARRANGE
|
||||
render(<WrappedApp />);
|
||||
// ACT
|
||||
// EXPECT
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
level: 1,
|
||||
}
|
||||
)).toHaveTextContent('Hello World')
|
||||
});
|
||||
it('Renders not found if invalid path', () => {
|
||||
// ARRANGE
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/this-route-does-not-exist']}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
// ACT
|
||||
// EXPECT
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
level: 1,
|
||||
}
|
||||
)).toHaveTextContent('Not Found')
|
||||
});
|
||||
});
|
||||
+7
-2
@@ -1,12 +1,17 @@
|
||||
FROM node:20 AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
# TODO: Remove this or review embedded variables
|
||||
COPY .env .env
|
||||
|
||||
# First generate package-lock.json if it doesn't exist, then do clean install
|
||||
RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# Vite bakes VITE_* values at build time, so compose must choose the env file
|
||||
# during image build, not only at container runtime.
|
||||
ARG ENV_FILE=.env
|
||||
COPY ${ENV_FILE} .env
|
||||
|
||||
# Run build with production mode
|
||||
RUN npm run build -- --mode production
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
frontend-test:
|
||||
image: node:20-bookworm
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
- app-test-node-modules:/app/node_modules
|
||||
command: sh -lc "npm ci && npm run test:run"
|
||||
profiles:
|
||||
- test
|
||||
|
||||
frontend-dev:
|
||||
container_name: cc-app-dev
|
||||
image: cc-app-dev:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
ENV_FILE: .env.dev
|
||||
env_file:
|
||||
- .env.dev
|
||||
ports:
|
||||
- "13000:3000"
|
||||
networks:
|
||||
- kevlarai-network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
app-test-node-modules:
|
||||
|
||||
networks:
|
||||
kevlarai-network:
|
||||
external: true
|
||||
name: kevlarai-network
|
||||
+8
-6
@@ -1,19 +1,21 @@
|
||||
services:
|
||||
classroom-copilot:
|
||||
container_name: classroom-copilot
|
||||
frontend:
|
||||
container_name: cc-prod
|
||||
image: cc-app-prod:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
ENV_FILE: .env
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- ./:/app
|
||||
- "3000:3000"
|
||||
networks:
|
||||
- kevlarai-network
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
kevlarai-network:
|
||||
external: true
|
||||
name: kevlarai-network
|
||||
driver: bridge
|
||||
|
||||
Generated
+14397
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -5,7 +5,9 @@
|
||||
"start": "vite --host",
|
||||
"build": "vite build",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
"build-storybook": "storybook build",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
@@ -82,9 +84,8 @@
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"react-refresh": "^0.14.2",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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,6 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'postcss-import': {},
|
||||
},
|
||||
|
||||
+58
-14
@@ -1,25 +1,69 @@
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { useTLDraw } from './contexts/TLDrawContext';
|
||||
import { theme } from './services/themeService';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { TLDrawProvider } from './contexts/TLDrawContext';
|
||||
import { UserProvider } from './contexts/UserContext';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import React from 'react';
|
||||
|
||||
const App = React.memo(() => (
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<AuthProvider>
|
||||
<UserProvider>
|
||||
<TLDrawProvider>
|
||||
<AppRoutes />
|
||||
</TLDrawProvider>
|
||||
</UserProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
));
|
||||
/**
|
||||
* Syncs tldraw's color scheme to data-color-mode on <html>.
|
||||
* Must live inside TLDrawProvider so useTLDraw() reads live preferences.
|
||||
* This activates the [data-color-mode="dark"] overrides in cc-design-system.css.
|
||||
* tldraw itself uses tl-theme__dark class on .tl-container — we mirror that
|
||||
* to the document root so --cc-* token overrides apply globally.
|
||||
*/
|
||||
const DarkModeSync: React.FC = () => {
|
||||
const { tldrawPreferences } = useTLDraw();
|
||||
const prefersDarkMode =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
useEffect(() => {
|
||||
const scheme = tldrawPreferences?.colorScheme ?? 'system';
|
||||
const isDark = scheme === 'dark' || (scheme === 'system' && prefersDarkMode);
|
||||
document.documentElement.setAttribute('data-color-mode', isDark ? 'dark' : 'light');
|
||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const App = React.memo(() => {
|
||||
const prefersDarkMode =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
const appTheme = useMemo(() => {
|
||||
// App is outside TLDrawProvider so tldrawPreferences is always null here.
|
||||
// Theme defaults to OS preference; DarkModeSync (inside TLDrawProvider) handles
|
||||
// updating data-color-mode once tldraw preferences are loaded.
|
||||
const mode = prefersDarkMode ? 'dark' : 'light';
|
||||
return createTheme({ palette: { mode } });
|
||||
}, [prefersDarkMode]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<AuthProvider>
|
||||
<UserProvider>
|
||||
<TLDrawProvider>
|
||||
<DarkModeSync />
|
||||
<ThemeProvider theme={appTheme}>
|
||||
<CssBaseline />
|
||||
<AppRoutes />
|
||||
</ThemeProvider>
|
||||
</TLDrawProvider>
|
||||
</UserProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
App.displayName = import.meta.env.VITE_APP_NAME;
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import AppRoutes from './AppRoutes';
|
||||
|
||||
const mockUseAuth = vi.fn();
|
||||
|
||||
vi.mock('./contexts/AuthContext', () => ({
|
||||
useAuth: () => mockUseAuth(),
|
||||
}));
|
||||
|
||||
vi.mock('./contexts/UserContext', () => ({
|
||||
useUser: () => ({ isInitialized: true }),
|
||||
}));
|
||||
|
||||
vi.mock('./debugConfig', () => ({
|
||||
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('./pages/Layout', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock('./pages/auth/PlatformAdminPage', () => ({ default: () => <div>Platform Admin Page</div> }));
|
||||
vi.mock('./pages/auth/adminPage', () => ({ default: () => <div>Legacy Admin</div> }));
|
||||
vi.mock('./pages/auth/loginPage', () => ({ default: () => <div>Login Page</div> }));
|
||||
vi.mock('./pages/auth/signupPage', () => ({ default: () => <div>Signup Page</div> }));
|
||||
vi.mock('./pages/user/dashboardPage', () => ({ default: () => <div>Dashboard Page</div> }));
|
||||
vi.mock('./pages/user/NotFound', () => ({ default: () => <div>Private Not Found</div> }));
|
||||
vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found</div> }));
|
||||
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
|
||||
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
|
||||
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
|
||||
vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div> }));
|
||||
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
|
||||
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
|
||||
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
|
||||
vi.mock('./pages/tldraw/devPage', () => ({ default: () => <div>Dev</div> }));
|
||||
vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => <div>Teacher Planner</div> }));
|
||||
vi.mock('./pages/morphicPage', () => ({ default: () => <div>Morphic</div> }));
|
||||
vi.mock('./pages/tldraw/ShareHandler', () => ({ default: () => <div>Share</div> }));
|
||||
vi.mock('./pages/searxngPage', () => ({ default: () => <div>Search</div> }));
|
||||
vi.mock('./pages/dev/SimpleUploadTest', () => ({ default: () => <div>Upload Test</div> }));
|
||||
vi.mock('./pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence', () => ({
|
||||
CCDocumentIntelligence: () => <div>Doc Intelligence</div>,
|
||||
}));
|
||||
vi.mock('./pages/timetable', () => ({
|
||||
TimetablePage: () => <div>Timetable</div>,
|
||||
TimetableListPage: () => <div>Timetable List</div>,
|
||||
ClassesPage: () => <div>Classes</div>,
|
||||
LessonPage: () => <div>Lesson</div>,
|
||||
TaughtLessonsPage: () => <div>Taught Lessons</div>,
|
||||
MyClassesPage: () => <div>My Classes</div>,
|
||||
EnrollmentRequestsPage: () => <div>Enrollment Requests</div>,
|
||||
StaffManagerPage: () => <div>Staff Manager</div>,
|
||||
StudentManagerPage: () => <div>Student Manager</div>,
|
||||
SchoolSettingsPage: () => <div>School Settings</div>,
|
||||
ClassDetailPage: () => <div>Class Detail</div>,
|
||||
StudentLessonsPage: () => <div>Student Lessons</div>,
|
||||
LessonPlansPage: () => <div>Lesson Plans</div>,
|
||||
LessonPlanDetailPage: () => <div>Lesson Plan Detail</div>,
|
||||
}));
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function authState(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
user: null,
|
||||
user_role: null,
|
||||
accessToken: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('/admin route authorization', () => {
|
||||
beforeEach(() => {
|
||||
mockUseAuth.mockReset();
|
||||
});
|
||||
|
||||
it('redirects anonymous users away from /admin', () => {
|
||||
mockUseAuth.mockReturnValue(authState({ user: null, user_role: null }));
|
||||
|
||||
renderAt('/admin');
|
||||
|
||||
expect(screen.queryByText('Platform Admin Page')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects authenticated non-admin users away from /admin', () => {
|
||||
mockUseAuth.mockReturnValue(authState({
|
||||
user: { id: 'user-1', email: '[email protected]', user_type: 'email_teacher' },
|
||||
user_role: 'email_teacher',
|
||||
accessToken: 'token',
|
||||
}));
|
||||
|
||||
renderAt('/admin');
|
||||
|
||||
expect(screen.queryByText('Platform Admin Page')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Dashboard Page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows super_admin users to access /admin', () => {
|
||||
mockUseAuth.mockReturnValue(authState({
|
||||
user: { id: 'admin-1', email: '[email protected]', user_type: 'super_admin' },
|
||||
user_role: 'super_admin',
|
||||
accessToken: 'token',
|
||||
}));
|
||||
|
||||
renderAt('/admin');
|
||||
|
||||
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+62
-11
@@ -7,11 +7,13 @@ import LoginPage from './pages/auth/loginPage';
|
||||
import SignupPage from './pages/auth/signupPage';
|
||||
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
||||
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
||||
import { CCExamMarker } from './pages/tldraw/CCExamMarker/CCExamMarker';
|
||||
import { ExamDashboardPage } from './pages/exam';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import CalendarPage from './pages/user/calendarPage';
|
||||
import SettingsPage from './pages/user/settingsPage';
|
||||
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
|
||||
import AdminDashboard from './pages/auth/adminPage';
|
||||
import PlatformAdminPage from './pages/auth/PlatformAdminPage';
|
||||
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
|
||||
import DevPage from './pages/tldraw/devPage';
|
||||
import TeacherPlanner from './pages/react-flow/teacherPlanner';
|
||||
@@ -29,15 +31,22 @@ import DashboardPage from './pages/user/dashboardPage';
|
||||
// Timetable Module Pages
|
||||
import {
|
||||
TimetablePage,
|
||||
TimetableListPage,
|
||||
ClassesPage,
|
||||
LessonPage,
|
||||
TaughtLessonsPage,
|
||||
MyClassesPage,
|
||||
EnrollmentRequestsPage,
|
||||
StaffManagerPage,
|
||||
StudentManagerPage,
|
||||
SchoolSettingsPage,
|
||||
ClassDetailPage,
|
||||
StudentLessonsPage,
|
||||
LessonPlansPage,
|
||||
LessonPlanDetailPage,
|
||||
} from './pages/timetable';
|
||||
|
||||
const FullContextRoutes: React.FC = () => {
|
||||
// Only block on Supabase profile being ready — Neo4j contexts initialize in the background.
|
||||
// Individual pages handle their own Neo4j loading states.
|
||||
const { isInitialized: isUserInitialized } = useUser();
|
||||
|
||||
if (!isUserInitialized) {
|
||||
@@ -59,9 +68,36 @@ const FullContextRoutes: React.FC = () => {
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
const RequireAuth: React.FC = () => {
|
||||
const { user, isAuthResolving } = useAuth();
|
||||
|
||||
if (isAuthResolving) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh'
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
const AppRoutes: React.FC = () => {
|
||||
const { user, loading: isAuthLoading } = useAuth();
|
||||
const { user, user_role, loading: isAuthLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
const platformAdminRoles = ['super_admin', 'cc_admin', 'cc_developer'];
|
||||
const isPlatformAdmin = !!user && platformAdminRoles.includes(user_role ?? '');
|
||||
|
||||
// Debug log for routing
|
||||
logger.debug('routing', '🔄 Rendering routes', {
|
||||
@@ -114,24 +150,39 @@ const AppRoutes: React.FC = () => {
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
user?.user_type === 'admin' ? <AdminDashboard /> : <NotFound />
|
||||
!user ? (
|
||||
<Navigate to="/login" replace state={{ from: location }} />
|
||||
) : isPlatformAdmin ? (
|
||||
<PlatformAdminPage />
|
||||
) : (
|
||||
<Navigate to="/dashboard" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Authentication only routes - only render if all contexts are initialized */}
|
||||
{user && (
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<FullContextRoutes />}>
|
||||
{/* Timetable Module Routes */}
|
||||
<Route path="/timetable" element={<TimetablePage />} />
|
||||
<Route path="/timetable" element={<TimetableListPage />} />
|
||||
<Route path="/timetable/:timetableId" element={<TimetablePage />} />
|
||||
<Route path="/classes" element={<ClassesPage />} />
|
||||
<Route path="/my-classes" element={<MyClassesPage />} />
|
||||
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
||||
<Route path="/student-lessons" element={<StudentLessonsPage />} />
|
||||
<Route path="/lesson-plans" element={<LessonPlansPage />} />
|
||||
<Route path="/lesson-plans/:planId" element={<LessonPlanDetailPage />} />
|
||||
<Route path="/enrollment-requests" element={<EnrollmentRequestsPage />} />
|
||||
<Route path="/lessons/:lessonId" element={<LessonPage />} />
|
||||
<Route path="/my-lessons" element={<TaughtLessonsPage />} />
|
||||
<Route path="/staff-manager" element={<StaffManagerPage />} />
|
||||
<Route path="/student-manager" element={<StudentManagerPage />} />
|
||||
<Route path="/school-settings" element={<SchoolSettingsPage />} />
|
||||
|
||||
{/* Existing Routes */}
|
||||
<Route path="/search" element={<SearxngPage />} />
|
||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||
<Route path="/exam-marker" element={<CCExamMarker />} />
|
||||
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
||||
<Route path="/morphic" element={<MorphicPage />} />
|
||||
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
||||
@@ -142,10 +193,10 @@ const AppRoutes: React.FC = () => {
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
)}
|
||||
</Route>
|
||||
|
||||
{/* Fallback route - use different NotFound pages based on auth state */}
|
||||
<Route path="*" element={user ? <NotFound /> : <NotFoundPublic />} />
|
||||
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
|
||||
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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(() => {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/// <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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/// <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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/// <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);
|
||||
});
|
||||
});
|
||||
+4
-3
@@ -1,11 +1,12 @@
|
||||
import axios from 'axios';
|
||||
import { API_BASE as CONFIG_API_BASE } from './config/apiConfig';
|
||||
import { logger } from './debugConfig';
|
||||
|
||||
// Use development backend URL if no custom URL is provided
|
||||
const baseURL = import.meta.env.VITE_API_BASE || 'http://localhost:8080';
|
||||
const baseURL = CONFIG_API_BASE;
|
||||
|
||||
if (!import.meta.env.VITE_API_BASE) {
|
||||
logger.warn('axios', '⚠️ VITE_API_BASE not set, defaulting to http://localhost:8080');
|
||||
if (!import.meta.env.VITE_API_BASE && !import.meta.env.VITE_API_URL) {
|
||||
logger.warn('axios', '⚠️ VITE_API_BASE not set, using relative /api fallback');
|
||||
}
|
||||
|
||||
const instance = axios.create({
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<h1 style={{ color: '#dc2626', marginBottom: '1rem' }}>Something went wrong</h1>
|
||||
<p style={{ color: '#6b7280', marginBottom: '1.5rem' }}>
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
window.location.reload();
|
||||
}}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
Reload Application
|
||||
</button>
|
||||
{import.meta.env.DEV && (
|
||||
<details style={{ marginTop: '2rem', textAlign: 'left', maxWidth: '600px' }}>
|
||||
<summary style={{ cursor: 'pointer', color: '#6b7280' }}>Error Details</summary>
|
||||
<pre style={{
|
||||
backgroundColor: '#f3f4f6',
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
overflow: 'auto',
|
||||
fontSize: '0.875rem',
|
||||
}}>
|
||||
{this.state.error?.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Centralized API configuration for Classroom Copilot.
|
||||
*
|
||||
* Resolves the API base URL from environment variables with proper fallbacks.
|
||||
* - In dev: VITE_API_BASE should always be set (e.g., http://192.168.0.64:18000)
|
||||
* - In production: VITE_API_BASE should be set to the production API URL
|
||||
* - Fallback: "/api" (relative path, works when API is served from same origin)
|
||||
*
|
||||
* IMPORTANT: Never hardcode localhost or 127.0.0.1 as fallbacks - they break
|
||||
* remote dev access and do not work in containerized environments.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Primary API base URL. Use this for all API calls.
|
||||
* Resolved from VITE_API_BASE (preferred) or VITE_API_URL (legacy alias).
|
||||
*/
|
||||
export const API_BASE: string =
|
||||
import.meta.env.VITE_API_BASE ||
|
||||
import.meta.env.VITE_API_URL ||
|
||||
"/api";
|
||||
|
||||
/**
|
||||
* TLSync worker URL for tldraw multiplayer.
|
||||
* Resolved from VITE_TLSYNC_URL (preferred) or derived from VITE_FRONTEND_SITE_URL.
|
||||
*/
|
||||
export const TLSYNC_URL: string =
|
||||
import.meta.env.VITE_TLSYNC_URL ||
|
||||
(import.meta.env.VITE_FRONTEND_SITE_URL?.startsWith("http")
|
||||
? `${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`
|
||||
: `https://${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`);
|
||||
|
||||
/**
|
||||
* WhisperLive WebSocket URL for live transcription.
|
||||
* Resolved from VITE_WHISPERLIVE_URL. Must be set in env.
|
||||
*/
|
||||
export const WHISPERLIVE_URL: string | undefined =
|
||||
import.meta.env.VITE_WHISPERLIVE_URL;
|
||||
|
||||
/**
|
||||
* Search proxy URL (searxng).
|
||||
* Resolved from VITE_SEARCH_URL.
|
||||
*/
|
||||
export const SEARCH_URL: string =
|
||||
import.meta.env.VITE_SEARCH_URL || "/searxng-api/";
|
||||
@@ -5,6 +5,8 @@ import { CCUser, CCUserMetadata, authService } from '../services/auth/authServic
|
||||
import { logger } from '../debugConfig';
|
||||
import { supabase } from '../supabaseClient';
|
||||
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
||||
import { fetchBootstrapData, BootstrapResponse, invalidateBootstrapCache } from '../services/bootstrap/bootstrapService';
|
||||
|
||||
|
||||
export interface AuthContextType {
|
||||
user: CCUser | null;
|
||||
@@ -15,6 +17,8 @@ export interface AuthContextType {
|
||||
signIn: (email: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
bootstrapData: BootstrapResponse | null;
|
||||
isAuthResolving: boolean;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextType>({
|
||||
@@ -25,7 +29,9 @@ export const AuthContext = createContext<AuthContextType>({
|
||||
error: null,
|
||||
signIn: async () => {},
|
||||
signOut: async () => {},
|
||||
clearError: () => {}
|
||||
clearError: () => {},
|
||||
bootstrapData: null,
|
||||
isAuthResolving: false
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
@@ -35,8 +41,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
|
||||
const [isAuthResolving, setIsAuthResolving] = useState(false);
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE as string;
|
||||
|
||||
const persistSession = useCallback((session: Session | null) => {
|
||||
if (session) {
|
||||
@@ -71,15 +78,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return { user: resolvedUser, role: resolvedRole };
|
||||
}, []);
|
||||
|
||||
const triggerUserInit = useCallback((token: string) => {
|
||||
fetch(`${apiBase}/user/init`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => logger.debug('auth-context', '✅ User init', data))
|
||||
.catch(err => logger.warn('auth-context', '⚠️ User init failed', { err }));
|
||||
}, [apiBase]);
|
||||
const loadBootstrap = useCallback(async (token: string | null | undefined) => {
|
||||
if (!token) {
|
||||
setBootstrapData(null);
|
||||
return null;
|
||||
}
|
||||
const bootstrap = await fetchBootstrapData(token);
|
||||
setBootstrapData(bootstrap);
|
||||
return bootstrap;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
@@ -88,13 +95,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (event === 'SIGNED_IN') {
|
||||
persistSession(session ?? null);
|
||||
setIsAuthResolving(!!session?.user);
|
||||
if (session?.user) {
|
||||
try {
|
||||
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||
setUser(resolvedUser);
|
||||
setUserRole(role);
|
||||
setAccessToken(session.access_token ?? null);
|
||||
triggerUserInit(session.access_token);
|
||||
await loadBootstrap(session.access_token);
|
||||
} catch (buildError) {
|
||||
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
|
||||
setUser(null);
|
||||
@@ -108,17 +116,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setAccessToken(null);
|
||||
}
|
||||
setLoading(false);
|
||||
setIsAuthResolving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
|
||||
persistSession(session ?? null);
|
||||
setIsAuthResolving(!!session?.user);
|
||||
if (session?.user) {
|
||||
try {
|
||||
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||
setUser(resolvedUser);
|
||||
setUserRole(role);
|
||||
setAccessToken(session.access_token ?? null);
|
||||
await loadBootstrap(session.access_token);
|
||||
} catch (buildError) {
|
||||
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
|
||||
setUser(null);
|
||||
@@ -132,21 +143,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setAccessToken(null);
|
||||
}
|
||||
setLoading(false);
|
||||
setIsAuthResolving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === 'SIGNED_OUT') {
|
||||
persistSession(null);
|
||||
setIsAuthResolving(false);
|
||||
setUser(null);
|
||||
setUserRole(null);
|
||||
setAccessToken(null);
|
||||
setBootstrapData(null);
|
||||
invalidateBootstrapCache();
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [buildUserFromSupabase, persistSession, triggerUserInit]);
|
||||
}, [buildUserFromSupabase, persistSession, loadBootstrap]);
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
try {
|
||||
@@ -167,6 +182,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setUser(resolvedUser);
|
||||
setUserRole(role);
|
||||
setAccessToken(data.session?.access_token ?? null);
|
||||
await loadBootstrap(data.session?.access_token ?? null);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('auth-context', '❌ Sign in failed', { error });
|
||||
@@ -205,7 +221,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
error,
|
||||
signIn,
|
||||
signOut,
|
||||
clearError
|
||||
clearError,
|
||||
bootstrapData,
|
||||
isAuthResolving
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -23,6 +23,7 @@ export type LogCategory =
|
||||
| 'neo-institute-context'
|
||||
| 'auth-service'
|
||||
| 'graph-service'
|
||||
| 'graph-sidebar'
|
||||
| 'registration-service'
|
||||
| 'provisioning-service'
|
||||
| 'snapshot-service'
|
||||
@@ -111,6 +112,7 @@ export type LogCategory =
|
||||
| 'user-context'
|
||||
| 'neo-user-context'
|
||||
| 'neo-institute-context'
|
||||
| 'cc-live-transcription'
|
||||
| 'search-service';
|
||||
|
||||
interface LogConfig {
|
||||
|
||||
+4
-5
@@ -1,7 +1,6 @@
|
||||
/* src/index.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/* Tailwind removed — utility classes provided by tailwind-compat.css shim */
|
||||
@import './tailwind-compat.css';
|
||||
|
||||
/* Base styles */
|
||||
html,
|
||||
@@ -152,10 +151,10 @@ body {
|
||||
|
||||
.login-role-header {
|
||||
font-weight: 600 !important;
|
||||
color: #1976d2 !important;
|
||||
color: var(--cc-action-primary) !important;
|
||||
margin-bottom: 24px !important;
|
||||
padding-bottom: 8px !important;
|
||||
border-bottom: 2px solid #1976d2 !important;
|
||||
border-bottom: 2px solid var(--cc-action-primary) !important;
|
||||
width: fit-content !important;
|
||||
}
|
||||
|
||||
|
||||
+75
-115
@@ -7,6 +7,7 @@ import {
|
||||
Typography,
|
||||
IconButton,
|
||||
Box,
|
||||
Chip,
|
||||
useTheme,
|
||||
Menu,
|
||||
MenuItem,
|
||||
@@ -19,9 +20,6 @@ import Login from '@mui/icons-material/Login';
|
||||
import Logout from '@mui/icons-material/Logout';
|
||||
import Teacher from '@mui/icons-material/School';
|
||||
import Student from '@mui/icons-material/Person';
|
||||
import TLDrawDev from '@mui/icons-material/Dashboard';
|
||||
import DevTools from '@mui/icons-material/Build';
|
||||
import Multiplayer from '@mui/icons-material/Groups';
|
||||
import Calendar from '@mui/icons-material/CalendarToday';
|
||||
import TeacherPlanner from '@mui/icons-material/Assignment';
|
||||
import ExamMarker from '@mui/icons-material/AssignmentTurnedIn';
|
||||
@@ -33,32 +31,32 @@ import Schedule from '@mui/icons-material/Schedule';
|
||||
import Class from '@mui/icons-material/Class';
|
||||
import Book from '@mui/icons-material/Book';
|
||||
import Enrollment from '@mui/icons-material/HowToReg';
|
||||
import Lessons from '@mui/icons-material/EventNote';
|
||||
import LessonPlans from '@mui/icons-material/LibraryBooks';
|
||||
import People from '@mui/icons-material/People';
|
||||
import SchoolSettings from '@mui/icons-material/Tune';
|
||||
import { HEADER_HEIGHT } from './Layout';
|
||||
import { logger } from '../debugConfig';
|
||||
import { GraphNavigator } from '../components/navigation/GraphNavigator';
|
||||
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, signOut } = useAuth();
|
||||
const { user, signOut, bootstrapData } = useAuth();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(!!user);
|
||||
const isAdmin = user?.email === import.meta.env.VITE_SUPER_ADMIN_EMAIL;
|
||||
const showGraphNavigation = location.pathname === '/single-player';
|
||||
const isPlatformAdmin = bootstrapData?.permissions?.platform_admin ?? false;
|
||||
const schoolRole = bootstrapData?.active_institute?.membership_role ?? null;
|
||||
const isSchoolAdmin = schoolRole === 'school_admin' || schoolRole === 'department_head';
|
||||
|
||||
// Update authentication state whenever user changes
|
||||
useEffect(() => {
|
||||
const newAuthState = !!user;
|
||||
setIsAuthenticated(newAuthState);
|
||||
logger.debug('user-context', '🔄 User state changed in header', {
|
||||
hasUser: newAuthState,
|
||||
userId: user?.id,
|
||||
userEmail: user?.email,
|
||||
userState: newAuthState ? 'logged-in' : 'logged-out',
|
||||
isAdmin
|
||||
});
|
||||
}, [user, isAdmin]);
|
||||
setIsAuthenticated(!!user);
|
||||
}, [user]);
|
||||
|
||||
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
@@ -129,6 +127,15 @@ const Header: React.FC = () => {
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{isAuthenticated && bootstrapData?.onboarding?.next_step && bootstrapData.onboarding.next_step !== 'ready' && (
|
||||
<Chip
|
||||
label={bootstrapData.onboarding.next_step.replace(/_/g, ' ')}
|
||||
color="warning"
|
||||
size="small"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
sx={{ cursor: 'pointer', display: { xs: 'none', sm: 'flex' } }}
|
||||
/>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
@@ -192,9 +199,9 @@ const Header: React.FC = () => {
|
||||
</MenuItem>,
|
||||
<Divider key="home-divider" />,
|
||||
|
||||
// Timetable Section
|
||||
// My Work Section
|
||||
<Typography
|
||||
key="timetable-header"
|
||||
key="work-header"
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
px: 2,
|
||||
@@ -206,26 +213,8 @@ const Header: React.FC = () => {
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
>
|
||||
Timetable & Classes
|
||||
My Work
|
||||
</Typography>,
|
||||
<MenuItem key="timetable" onClick={() => handleNavigation('/timetable')}>
|
||||
<ListItemIcon>
|
||||
<Schedule />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="My Timetable"
|
||||
secondary="View your schedule"
|
||||
/>
|
||||
</MenuItem>,
|
||||
<MenuItem key="classes" onClick={() => handleNavigation('/classes')}>
|
||||
<ListItemIcon>
|
||||
<Class />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Browse Classes"
|
||||
secondary="Find and enroll in classes"
|
||||
/>
|
||||
</MenuItem>,
|
||||
<MenuItem key="my-classes" onClick={() => handleNavigation('/my-classes')}>
|
||||
<ListItemIcon>
|
||||
<Book />
|
||||
@@ -235,84 +224,59 @@ const Header: React.FC = () => {
|
||||
secondary="Manage your classes"
|
||||
/>
|
||||
</MenuItem>,
|
||||
<MenuItem key="enrollment-requests" onClick={() => handleNavigation('/enrollment-requests')}>
|
||||
<MenuItem key="my-lessons" onClick={() => handleNavigation('/my-lessons')}>
|
||||
<ListItemIcon>
|
||||
<Enrollment />
|
||||
<Lessons />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Enrollment Requests"
|
||||
secondary="Review pending enrollments"
|
||||
<ListItemText
|
||||
primary="My Lessons"
|
||||
secondary="Weekly lesson view"
|
||||
/>
|
||||
</MenuItem>,
|
||||
<Divider key="timetable-divider" />,
|
||||
<MenuItem key="lesson-plans" onClick={() => handleNavigation('/lesson-plans')}>
|
||||
<ListItemIcon>
|
||||
<LessonPlans />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Lesson Plans"
|
||||
secondary="Plan and manage lessons"
|
||||
/>
|
||||
</MenuItem>,
|
||||
<Divider key="work-divider" />,
|
||||
|
||||
// Features Section
|
||||
<Typography
|
||||
key="features-header"
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
color: theme.palette.text.secondary,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
>
|
||||
Features
|
||||
</Typography>,
|
||||
<MenuItem key="calendar" onClick={() => handleNavigation('/calendar')}>
|
||||
<ListItemIcon>
|
||||
<Calendar />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Calendar" />
|
||||
</MenuItem>,
|
||||
<MenuItem key="teacher-planner" onClick={() => handleNavigation('/teacher-planner')}>
|
||||
<ListItemIcon>
|
||||
<TeacherPlanner />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Teacher Planner" />
|
||||
</MenuItem>,
|
||||
<MenuItem key="exam-marker" onClick={() => handleNavigation('/exam-marker')}>
|
||||
<ListItemIcon>
|
||||
<ExamMarker />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Exam Marker" />
|
||||
</MenuItem>,
|
||||
<Divider key="features-divider" />,
|
||||
// School Admin Section
|
||||
...(isSchoolAdmin ? [
|
||||
<Typography
|
||||
key="school-admin-header"
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
px: 2, py: 1,
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
School Admin
|
||||
</Typography>,
|
||||
<MenuItem key="school-settings" onClick={() => handleNavigation('/school-settings')}>
|
||||
<ListItemIcon><SchoolSettings /></ListItemIcon>
|
||||
<ListItemText primary="School Settings" secondary="Calendar, overview" />
|
||||
</MenuItem>,
|
||||
<MenuItem key="staff-manager" onClick={() => handleNavigation('/staff-manager')}>
|
||||
<ListItemIcon><People /></ListItemIcon>
|
||||
<ListItemText primary="Staff Manager" secondary="Invite & manage teachers" />
|
||||
</MenuItem>,
|
||||
<MenuItem key="student-manager" onClick={() => handleNavigation('/student-manager')}>
|
||||
<ListItemIcon><Teacher /></ListItemIcon>
|
||||
<ListItemText primary="Student Manager" secondary="Invite & manage students" />
|
||||
</MenuItem>,
|
||||
<Divider key="school-admin-divider" />,
|
||||
] : []),
|
||||
|
||||
// Utilities Section
|
||||
<Typography
|
||||
key="utilities-header"
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
color: theme.palette.text.secondary,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
>
|
||||
Utilities
|
||||
</Typography>,
|
||||
<MenuItem key="settings" onClick={() => handleNavigation('/settings')}>
|
||||
<ListItemIcon>
|
||||
<Settings />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Settings" />
|
||||
</MenuItem>,
|
||||
<MenuItem key="search" onClick={() => handleNavigation('/search')}>
|
||||
<ListItemIcon>
|
||||
<Search />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Search" />
|
||||
</MenuItem>,
|
||||
|
||||
// Admin Section
|
||||
...(isAdmin ? [
|
||||
// Platform Admin Section
|
||||
...(isPlatformAdmin ? [
|
||||
<Divider key="admin-divider" />,
|
||||
<Typography
|
||||
key="admin-header"
|
||||
@@ -330,15 +294,11 @@ const Header: React.FC = () => {
|
||||
Administration
|
||||
</Typography>,
|
||||
<MenuItem key="admin" onClick={() => handleNavigation('/admin')}>
|
||||
<ListItemIcon>
|
||||
<Admin />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Admin Dashboard" />
|
||||
<ListItemIcon><Admin /></ListItemIcon>
|
||||
<ListItemText primary="Platform Admin" secondary="Schools & system stats" />
|
||||
</MenuItem>
|
||||
] : []),
|
||||
|
||||
// Authentication Section
|
||||
<Divider key="auth-divider" />,
|
||||
<MenuItem key="signout" onClick={handleSignOut}>
|
||||
<ListItemIcon>
|
||||
<Logout />
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Button, CircularProgress, Alert, Chip,
|
||||
Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Card, CardContent, Grid, IconButton, Tooltip,
|
||||
} from '@mui/material';
|
||||
import { Refresh, School, People, EventNote, HourglassEmpty } from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
interface SchoolEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
urn: string | null;
|
||||
website: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
neo4j_uuid_string: string | null;
|
||||
staff_count: number;
|
||||
student_count: number;
|
||||
has_calendar: boolean;
|
||||
pending_invitations: number;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
schools: number;
|
||||
profiles: number;
|
||||
taught_lessons: number;
|
||||
pending_invitations: number;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon: Icon }: { label: string; value: number; icon: React.ElementType }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Icon sx={{ color: 'primary.main', fontSize: 28 }} />
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, lineHeight: 1 }}>{value}</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const PlatformAdminPage: React.FC = () => {
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [schools, setSchools] = useState<SchoolEntry[]>([]);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [schoolsRes, statsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/admin/schools`, { headers }).then(r => r.json()),
|
||||
fetch(`${API_BASE}/admin/stats`, { headers }).then(r => r.json()),
|
||||
]);
|
||||
if (schoolsRes.status === 'ok') setSchools(schoolsRes.schools || []);
|
||||
else setError(schoolsRes.detail || 'Failed to load schools');
|
||||
if (statsRes.status === 'ok') setStats(statsRes);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1100, mx: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Platform Admin</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>Classroom Copilot — system overview</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Tooltip title="Refresh">
|
||||
<IconButton size="small" onClick={loadData} disabled={loading}><Refresh fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
<Button size="small" variant="outlined" onClick={() => navigate('/dashboard')}>← Dashboard</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
|
||||
{stats && (
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={6} sm={3}><StatCard label="Schools" value={stats.schools} icon={School} /></Grid>
|
||||
<Grid item xs={6} sm={3}><StatCard label="Profiles" value={stats.profiles} icon={People} /></Grid>
|
||||
<Grid item xs={6} sm={3}><StatCard label="Taught Lessons" value={stats.taught_lessons} icon={EventNote} /></Grid>
|
||||
<Grid item xs={6} sm={3}><StatCard label="Pending Invites" value={stats.pending_invitations} icon={HourglassEmpty} /></Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
|
||||
) : schools.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No schools registered.</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>School</TableCell>
|
||||
<TableCell>URN</TableCell>
|
||||
<TableCell>Staff</TableCell>
|
||||
<TableCell>Students</TableCell>
|
||||
<TableCell>Calendar</TableCell>
|
||||
<TableCell>Invites</TableCell>
|
||||
<TableCell>Neo4j</TableCell>
|
||||
<TableCell>Registered</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{schools.map(s => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell sx={{ fontWeight: 500, fontSize: '0.85rem' }}>{s.name}</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.75rem', color: 'text.secondary' }}>
|
||||
{s.urn || '—'}
|
||||
</TableCell>
|
||||
<TableCell>{s.staff_count}</TableCell>
|
||||
<TableCell>{s.student_count}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={s.has_calendar ? 'Yes' : 'No'}
|
||||
size="small"
|
||||
color={s.has_calendar ? 'success' : 'default'}
|
||||
sx={{ fontSize: '0.65rem', height: 18 }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{s.pending_invitations > 0
|
||||
? <Chip label={s.pending_invitations} size="small" color="warning" sx={{ fontSize: '0.65rem', height: 18 }} />
|
||||
: <Typography variant="caption" sx={{ color: 'text.disabled' }}>—</Typography>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={s.neo4j_uuid_string ? 'Provisioned' : 'Pending'}
|
||||
size="small"
|
||||
color={s.neo4j_uuid_string ? 'success' : 'warning'}
|
||||
sx={{ fontSize: '0.65rem', height: 18 }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
|
||||
{new Date(s.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformAdminPage;
|
||||
@@ -6,34 +6,50 @@ import { EmailLoginForm } from './EmailLoginForm';
|
||||
import { EmailCredentials } from '../../services/auth/authService';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const getLoginErrorMessage = (error: unknown): string => {
|
||||
const status = (error as { status?: number } | null)?.status;
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
|
||||
if (code === 'email_not_confirmed' || status === 401 && code === 'invalid_grant') {
|
||||
return 'Please confirm your email before logging in';
|
||||
}
|
||||
|
||||
if (status === 401 || code === 'invalid_grant') {
|
||||
return 'Invalid email or password';
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : 'Login failed';
|
||||
};
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user, signIn } = useAuth();
|
||||
const { user, signIn, isAuthResolving } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
logger.debug('login-page', '🔍 Login page loaded', {
|
||||
hasUser: !!user
|
||||
hasUser: !!user,
|
||||
isAuthResolving
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
if (!isAuthResolving && user) {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}, [user, navigate]);
|
||||
}, [user, isAuthResolving, navigate]);
|
||||
|
||||
const handleLogin = async (credentials: EmailCredentials) => {
|
||||
try {
|
||||
setError(null);
|
||||
await signIn(credentials.email, credentials.password);
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
logger.error('login-page', '❌ Login failed', error);
|
||||
setError(error instanceof Error ? error.message : 'Login failed');
|
||||
const message = getLoginErrorMessage(error);
|
||||
setError(message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (user) {
|
||||
if (user && !isAuthResolving) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { EmailSignupForm } from './EmailSignupForm';
|
||||
import { EmailCredentials } from '../../services/auth/authService';
|
||||
import { RegistrationService } from '../../services/auth/registrationService';
|
||||
import { logger } from '../../debugConfig';
|
||||
import MicrosoftIcon from '@mui/icons-material/Microsoft';
|
||||
import MicrosoftIcon from '@mui/icons-material/Microsoft.js';
|
||||
|
||||
const SignupPage: React.FC = () => {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CloudUpload as UploadIcon,
|
||||
Upload,
|
||||
Folder as FolderIcon,
|
||||
FolderOpen as FolderOpenIcon,
|
||||
Description as FileIcon,
|
||||
@@ -39,8 +40,12 @@ import {
|
||||
PlayArrow as ProcessIcon,
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon
|
||||
Info as InfoIcon,
|
||||
InsertDriveFile,
|
||||
} from '@mui/icons-material';
|
||||
const FolderOpen = FolderOpenIcon;
|
||||
const Refresh = RefreshIcon;
|
||||
const Info = InfoIcon;
|
||||
import { supabase } from '../../supabaseClient';
|
||||
import {
|
||||
pickDirectory,
|
||||
@@ -123,7 +128,7 @@ const SimpleUploadTest: React.FC = () => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dirInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8080';
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||
|
||||
const apiFetch = useCallback(async (url: string, init?: { method?: string; body?: FormData | string; headers?: Record<string, string> }) => {
|
||||
const session = await supabase.auth.getSession();
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import ArchiveIcon from '@mui/icons-material/Archive';
|
||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { examRepository } from '../../services/exam/examRepository';
|
||||
import type { ExamTemplate } from '../../types/exam.types';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> = {
|
||||
draft: 'warning',
|
||||
ready: 'success',
|
||||
archived: 'default',
|
||||
};
|
||||
|
||||
const VERSION_SEPARATOR = ' · ';
|
||||
const VERSION_RE = /(?:^|\s)[vV](\d+(?:\.\d+)*)$/;
|
||||
|
||||
type DialogMode = 'create' | 'edit' | 'duplicate';
|
||||
|
||||
type TemplateDialogState = {
|
||||
mode: DialogMode;
|
||||
template?: ExamTemplate;
|
||||
} | null;
|
||||
|
||||
function splitTemplateTitle(title: string): { name: string; version: string } {
|
||||
const parts = title.split(VERSION_SEPARATOR);
|
||||
const possibleVersion = parts[parts.length - 1]?.trim() ?? '';
|
||||
if (parts.length > 1 && VERSION_RE.test(possibleVersion)) {
|
||||
return { name: parts.slice(0, -1).join(VERSION_SEPARATOR).trim(), version: possibleVersion };
|
||||
}
|
||||
return { name: title, version: 'v1' };
|
||||
}
|
||||
|
||||
function composeTemplateTitle(name: string, version: string): string {
|
||||
const cleanName = name.trim();
|
||||
const cleanVersion = version.trim();
|
||||
return cleanVersion ? `${cleanName}${VERSION_SEPARATOR}${cleanVersion}` : cleanName;
|
||||
}
|
||||
|
||||
function nextVersionLabel(version: string): string {
|
||||
const match = version.trim().match(VERSION_RE);
|
||||
if (!match) return 'v2';
|
||||
const segments = match[1].split('.');
|
||||
const last = Number(segments[segments.length - 1]);
|
||||
segments[segments.length - 1] = Number.isFinite(last) ? String(last + 1) : '2';
|
||||
return `v${segments.join('.')}`;
|
||||
}
|
||||
|
||||
function paperKey(t: ExamTemplate): string {
|
||||
return t.exam_id ?? t.source_file_id ?? t.exam_code ?? t.subject ?? 'custom-paper';
|
||||
}
|
||||
|
||||
function paperLabel(t: ExamTemplate): string {
|
||||
if (t.exam_code) return t.exam_code;
|
||||
if (t.subject) return t.subject;
|
||||
if (t.source_file_id) return 'Uploaded paper';
|
||||
return 'Custom paper';
|
||||
}
|
||||
|
||||
const ExamDashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { bootstrapData } = useAuth();
|
||||
const instituteId = bootstrapData?.active_institute?.id ?? undefined;
|
||||
|
||||
const [templates, setTemplates] = useState<ExamTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [dialog, setDialog] = useState<TemplateDialogState>(null);
|
||||
const [templateName, setTemplateName] = useState('');
|
||||
const [version, setVersion] = useState('v1');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setTemplates(await examRepository.listTemplates());
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.warn('cc-exam-marker', 'Failed to load templates', { message: msg });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load, instituteId]);
|
||||
|
||||
const groupedTemplates = useMemo(() => {
|
||||
const groups = new Map<string, { label: string; templates: ExamTemplate[] }>();
|
||||
templates.forEach((template) => {
|
||||
const key = paperKey(template);
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.templates.push(template);
|
||||
} else {
|
||||
groups.set(key, { label: paperLabel(template), templates: [template] });
|
||||
}
|
||||
});
|
||||
return Array.from(groups.entries()).map(([key, group]) => ({ key, ...group }));
|
||||
}, [templates]);
|
||||
|
||||
const openCreate = () => {
|
||||
setTemplateName('New template');
|
||||
setVersion('v1');
|
||||
setSubject('');
|
||||
setDialog({ mode: 'create' });
|
||||
};
|
||||
|
||||
const openEdit = (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
const parsed = splitTemplateTitle(template.title);
|
||||
setTemplateName(parsed.name);
|
||||
setVersion(parsed.version);
|
||||
setSubject(template.subject ?? '');
|
||||
setDialog({ mode: 'edit', template });
|
||||
};
|
||||
|
||||
const openDuplicate = (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
const parsed = splitTemplateTitle(template.title);
|
||||
setTemplateName(parsed.name);
|
||||
setVersion(nextVersionLabel(parsed.version));
|
||||
setSubject(template.subject ?? '');
|
||||
setDialog({ mode: 'duplicate', template });
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
if (!saving) setDialog(null);
|
||||
};
|
||||
|
||||
const handleSaveDialog = async () => {
|
||||
if (!dialog || !templateName.trim()) return;
|
||||
const title = composeTemplateTitle(templateName, version);
|
||||
setSaving(true);
|
||||
try {
|
||||
if (dialog.mode === 'create') {
|
||||
const created = await examRepository.createTemplate({
|
||||
title,
|
||||
subject: subject.trim() || undefined,
|
||||
institute_id: instituteId,
|
||||
});
|
||||
setDialog(null);
|
||||
navigate(`/exam-marker/${created.id}/setup`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dialog.template) return;
|
||||
|
||||
if (dialog.mode === 'duplicate') {
|
||||
const created = await examRepository.duplicateTemplate(dialog.template.id, title);
|
||||
setTemplates((prev) => [created, ...prev]);
|
||||
setDialog(null);
|
||||
navigate(`/exam-marker/${created.id}/setup`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await examRepository.updateTemplateMeta(dialog.template.id, {
|
||||
title,
|
||||
subject: subject.trim() || null,
|
||||
});
|
||||
setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||
setDialog(null);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Template action failed', { message: msg, mode: dialog.mode });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
const parsed = splitTemplateTitle(template.title);
|
||||
if (!window.confirm(`Archive ${parsed.name} ${parsed.version}? This hides it from the dashboard but keeps the work recoverable.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await examRepository.archiveTemplate(template.id);
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== template.id));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const dialogTitle = dialog?.mode === 'edit'
|
||||
? 'Rename template / edit version'
|
||||
: dialog?.mode === 'duplicate'
|
||||
? 'Duplicate as new version'
|
||||
: 'New exam template';
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<Stack spacing={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h3" component="h1" gutterBottom>
|
||||
Exam Marker
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 620 }}>
|
||||
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||
New template
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : templates.length === 0 ? (
|
||||
<Paper variant="outlined" sx={{ p: 6, textAlign: 'center' }}>
|
||||
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
||||
<Typography variant="h6" gutterBottom>No exam templates yet</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Create your first named template to start mapping an exam paper.
|
||||
</Typography>
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
|
||||
New template
|
||||
</Button>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
{groupedTemplates.map((group) => (
|
||||
<Box key={group.key}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="h6">{group.label}</Typography>
|
||||
<Chip size="small" label={`${group.templates.length} template${group.templates.length === 1 ? '' : 's'}`} variant="outlined" />
|
||||
</Stack>
|
||||
<Grid container spacing={3}>
|
||||
{group.templates.map((t) => {
|
||||
const parsed = splitTemplateTitle(t.title);
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 3,
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
transition: 'box-shadow 120ms',
|
||||
'&:hover': { boxShadow: 6 },
|
||||
}}
|
||||
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="h6" sx={{ pr: 1 }}>{parsed.name}</Typography>
|
||||
<Chip size="small" label={parsed.version} color="info" variant="outlined" sx={{ mt: 0.5 }} />
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Tooltip title="Rename / edit version">
|
||||
<IconButton size="small" onClick={(e) => openEdit(t, e)} aria-label="rename template">
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Duplicate as new version">
|
||||
<IconButton size="small" onClick={(e) => openDuplicate(t, e)} aria-label="duplicate template">
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Archive">
|
||||
<IconButton size="small" onClick={(e) => handleArchive(t, e)} aria-label="archive template">
|
||||
<ArchiveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Box>
|
||||
{t.subject && (
|
||||
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
|
||||
)}
|
||||
{t.exam_code && (
|
||||
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
|
||||
)}
|
||||
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Updated {new Date(t.updated_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Dialog open={Boolean(dialog)} onClose={closeDialog} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
label="Template name"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
fullWidth
|
||||
autoFocus
|
||||
required
|
||||
helperText="User-facing name. Several templates can share the same paper."
|
||||
/>
|
||||
<TextField
|
||||
label="Version"
|
||||
value={version}
|
||||
onChange={(e) => setVersion(e.target.value)}
|
||||
fullWidth
|
||||
helperText="Stored in the template title until the API grows a dedicated version column."
|
||||
/>
|
||||
<TextField
|
||||
label="Paper / subject label"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
fullWidth
|
||||
disabled={dialog?.mode === 'duplicate'}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeDialog} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSaveDialog} disabled={saving || !templateName.trim()}>
|
||||
{saving ? 'Saving…' : dialog?.mode === 'duplicate' ? 'Create version' : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamDashboardPage;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||
@@ -1,324 +1,428 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { AccessTime, Add, ArrowBack, CalendarToday, Delete, Edit, MenuBook, People } from '@mui/icons-material';
|
||||
import useTimetableStore from '../../stores/timetableStore';
|
||||
import { useUser } from '../../contexts/UserContext';
|
||||
import Modal from '../../components/common/Modal';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Button, CircularProgress, Alert, Chip, Divider,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
|
||||
Autocomplete, IconButton, Tooltip, Tabs, Tab, Avatar,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School,
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Profile { id: string; full_name: string; display_name?: string; email: string; }
|
||||
interface ClassTeacher { teacher_id: string; is_primary: boolean; can_edit: boolean; profile: Profile; }
|
||||
interface ClassStudent { student_id: string; status: string; enrolled_at: string; profile: Profile; }
|
||||
interface EnrollmentRequest { id: string; student_id: string; status: string; created_at: string; profile: Profile; }
|
||||
|
||||
interface ClassDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
class_code?: string;
|
||||
subject?: string;
|
||||
year_group?: string;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
teachers: ClassTeacher[];
|
||||
students: ClassStudent[];
|
||||
enrollment_requests: EnrollmentRequest[];
|
||||
student_count: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function initials(name: string) {
|
||||
return name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
}
|
||||
|
||||
// ─── Add Student Dialog ───────────────────────────────────────────────────────
|
||||
|
||||
interface AddStudentDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: (studentId: string) => Promise<void>;
|
||||
accessToken: string;
|
||||
existingIds: Set<string>;
|
||||
}
|
||||
|
||||
function AddStudentDialog({ open, onClose, onAdd, accessToken, existingIds }: AddStudentDialogProps) {
|
||||
const [allStudents, setAllStudents] = useState<Profile[]>([]);
|
||||
const [selected, setSelected] = useState<Profile | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
fetch(`${API_BASE}/classes/school/students`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => setAllStudents((d.students || []).filter((s: Profile) => !existingIds.has(s.id))))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, accessToken, existingIds]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!selected) return;
|
||||
setSaving(true);
|
||||
await onAdd(selected.id);
|
||||
setSaving(false);
|
||||
setSelected(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Add Student to Class</DialogTitle>
|
||||
<DialogContent sx={{ pt: 2 }}>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : (
|
||||
<Autocomplete
|
||||
options={allStudents}
|
||||
getOptionLabel={o => `${o.full_name} (${o.email})`}
|
||||
value={selected}
|
||||
onChange={(_, v) => setSelected(v)}
|
||||
renderInput={params => (
|
||||
<TextField {...params} label="Search students" size="small" autoFocus />
|
||||
)}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
variant="contained"
|
||||
disabled={!selected || saving}
|
||||
startIcon={saving ? <CircularProgress size={16} /> : <PersonAdd />}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ClassDetailPage: React.FC = () => {
|
||||
const { classId } = useParams<{ classId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { profile } = useUser();
|
||||
const {
|
||||
currentClass,
|
||||
timetables,
|
||||
enrolledStudents,
|
||||
classTeachers,
|
||||
classDetailLoading,
|
||||
classDetailError,
|
||||
fetchClassDetail,
|
||||
deleteClass,
|
||||
clearCurrentClass,
|
||||
} = useTimetableStore();
|
||||
const { classId } = useParams<{ classId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { accessToken, user, bootstrapData } = useAuth();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'timetables' | 'students' | 'teachers'>('timetables');
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [cls, setCls] = useState<ClassDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState(0);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (classId) {
|
||||
fetchClassDetail(classId);
|
||||
}
|
||||
return () => {
|
||||
clearCurrentClass();
|
||||
const load = useCallback(async () => {
|
||||
if (!accessToken || !classId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}).then(r => r.json());
|
||||
if (clsRes.id) setCls(clsRes);
|
||||
else setError(clsRes.detail || 'Class not found');
|
||||
const role = bootstrapData?.active_institute?.membership_role || '';
|
||||
setIsAdmin(role === 'school_admin' || role === 'department_head');
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken, classId, bootstrapData]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const apiPost = async (path: string, body?: object) => {
|
||||
const r = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return r.json();
|
||||
};
|
||||
}, [classId, fetchClassDetail, clearCurrentClass]);
|
||||
|
||||
const handleDeleteClass = async () => {
|
||||
if (!classId) return;
|
||||
await deleteClass(classId);
|
||||
navigate('/timetable/classes');
|
||||
};
|
||||
const apiDelete = async (path: string) => {
|
||||
await fetch(`${API_BASE}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
};
|
||||
|
||||
const isOwner = currentClass?.created_by === profile?.id;
|
||||
const isTeacher = classTeachers.some(t => t.teacher_id === profile?.id && t.is_primary);
|
||||
const apiPatch = async (path: string, body: object) => {
|
||||
const r = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return r.json();
|
||||
};
|
||||
|
||||
const handleAddStudent = async (studentId: string) => {
|
||||
setActionError(null);
|
||||
const res = await apiPost(`/classes/${classId}/students`, { student_id: studentId });
|
||||
if (res.status === 'ok') load();
|
||||
else setActionError(res.detail || 'Failed to add student');
|
||||
};
|
||||
|
||||
const handleRemoveStudent = async (studentId: string) => {
|
||||
setActionError(null);
|
||||
await apiDelete(`/classes/${classId}/students/${studentId}`);
|
||||
load();
|
||||
};
|
||||
|
||||
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
|
||||
setActionError(null);
|
||||
const res = await apiPatch(`/classes/${classId}/enrollment-requests/${requestId}`, { action });
|
||||
if (res.status === 'ok') load();
|
||||
else setActionError(res.detail || 'Action failed');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !cls) {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert severity="error">{error || 'Class not found'}</Alert>
|
||||
<Button sx={{ mt: 2 }} startIcon={<ArrowBack />} onClick={() => navigate('/classes')}>
|
||||
Back to Classes
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const existingStudentIds = new Set(cls.students.map(s => s.student_id));
|
||||
const pendingCount = cls.enrollment_requests.length;
|
||||
|
||||
if (classDetailLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (classDetailError || !currentClass) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-red-800 mb-2">Error Loading Class</h2>
|
||||
<p className="text-red-600">{classDetailError || 'Class not found'}</p>
|
||||
<Link to="/timetable/classes" className="text-blue-600 hover:underline mt-4 inline-block">
|
||||
Back to Classes
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/timetable/classes"
|
||||
className="inline-flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<ArrowBack size={18} />
|
||||
Back to Classes
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-gray-900">{currentClass.name}</h1>
|
||||
<span className="px-3 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded-full">
|
||||
{currentClass.subject}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-500">
|
||||
{currentClass.school_year} • {currentClass.academic_term}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(isOwner || isTeacher) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/timetable/classes/${classId}/edit`}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Edit size={18} />
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors"
|
||||
>
|
||||
<Delete size={18} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-50 rounded-lg">
|
||||
<CalendarToday className="text-blue-600" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Timetables</p>
|
||||
<p className="text-xl font-semibold text-gray-900">{currentClass.timetable_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-50 rounded-lg">
|
||||
<People className="text-green-600" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Students</p>
|
||||
<p className="text-xl font-semibold text-gray-900">{currentClass.student_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-50 rounded-lg">
|
||||
<MenuBook className="text-purple-600" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Teachers</p>
|
||||
<p className="text-xl font-semibold text-gray-900">{classTeachers.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<div className="flex gap-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('timetables')}
|
||||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'timetables'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Timetables
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('students')}
|
||||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'students'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Students ({enrolledStudents.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('teachers')}
|
||||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'teachers'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Teachers ({classTeachers.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
{activeTab === 'timetables' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Timetables</h2>
|
||||
{(isOwner || isTeacher) && (
|
||||
<Link
|
||||
to={`/timetable/classes/${classId}/timetables/new`}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Add size={18} />
|
||||
Add Timetable
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{timetables.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<AccessTime size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-2">No timetables yet</p>
|
||||
<p>Create a timetable to start scheduling lessons</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{timetables.map((timetable) => (
|
||||
<Link
|
||||
key={timetable.id}
|
||||
to={`/timetable/timetables/${timetable.id}`}
|
||||
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{timetable.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{timetable.lesson_count} lessons
|
||||
{timetable.is_recurring && ' • Recurring'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowBack className="rotate-180 text-gray-400" size={18} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'students' && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Enrolled Students</h2>
|
||||
{enrolledStudents.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<People size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-2">No students enrolled</p>
|
||||
<p>Students can request enrollment or be added by teachers</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{enrolledStudents.map((student) => (
|
||||
<div
|
||||
key={student.user_id}
|
||||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-gray-600 font-medium">
|
||||
{student.full_name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{student.full_name}</p>
|
||||
<p className="text-sm text-gray-500">{student.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'teachers' && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Teachers</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{classTeachers.map((teacher) => (
|
||||
<div
|
||||
key={teacher.teacher_id}
|
||||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 font-medium">
|
||||
{teacher.full_name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{teacher.full_name}</p>
|
||||
<p className="text-sm text-gray-500">{teacher.email}</p>
|
||||
</div>
|
||||
{teacher.is_primary && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs font-medium rounded-full">
|
||||
Primary
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<Modal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
title="Delete Class"
|
||||
>
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 mb-6">
|
||||
Are you sure you want to delete "{currentClass.name}"? This action cannot be undone and will remove all timetables, lessons, and whiteboards associated with this class.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
|
||||
{/* Header */}
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/classes')}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteClass}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Delete Class
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
Back to Classes
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" fontWeight={700}>{cls.name}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
{cls.class_code && <Chip label={cls.class_code} size="small" />}
|
||||
{cls.subject && <Chip label={cls.subject} size="small" variant="outlined" />}
|
||||
{cls.year_group && <Chip label={`Y${cls.year_group}`} size="small" variant="outlined" />}
|
||||
<Chip
|
||||
label={cls.is_active ? 'Active' : 'Inactive'}
|
||||
size="small"
|
||||
color={cls.is_active ? 'success' : 'default'}
|
||||
/>
|
||||
</Box>
|
||||
{cls.description && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{cls.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<School sx={{ color: 'primary.main', fontSize: 40, opacity: 0.3 }} />
|
||||
</Box>
|
||||
|
||||
{actionError && (
|
||||
<Alert severity="error" onClose={() => setActionError(null)} sx={{ mb: 2 }}>
|
||||
{actionError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
<Tab label={`Students (${cls.student_count})`} />
|
||||
<Tab label={`Requests${pendingCount > 0 ? ` (${pendingCount})` : ''}`} />
|
||||
<Tab label={`Teachers (${cls.teachers.length})`} />
|
||||
</Tabs>
|
||||
|
||||
{/* Students tab */}
|
||||
{tab === 0 && (
|
||||
<Box>
|
||||
{isAdmin && (
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<PersonAdd />}
|
||||
onClick={() => setAddOpen(true)}
|
||||
>
|
||||
Add Student
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{cls.students.length === 0 ? (
|
||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||
No students enrolled yet
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{cls.students.map(s => (
|
||||
<Box
|
||||
key={s.student_id}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.5,
|
||||
p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'primary.light' }}>
|
||||
{initials(s.profile?.full_name || '?')}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{s.profile?.full_name || s.student_id}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{s.profile?.email}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isAdmin && (
|
||||
<Tooltip title="Remove student">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleRemoveStudent(s.student_id)}
|
||||
>
|
||||
<PersonRemove fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Enrollment requests tab */}
|
||||
{tab === 1 && (
|
||||
<Box>
|
||||
{cls.enrollment_requests.length === 0 ? (
|
||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||
No pending enrollment requests
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{cls.enrollment_requests.map(req => (
|
||||
<Box
|
||||
key={req.id}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.5,
|
||||
p: 1.5, border: '1px solid', borderColor: 'warning.light',
|
||||
borderRadius: 1, bgcolor: 'warning.50',
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'warning.light' }}>
|
||||
{initials(req.profile?.full_name || '?')}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{req.profile?.full_name || req.student_id}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{req.profile?.email} · requested{' '}
|
||||
{new Date(req.created_at).toLocaleDateString('en-GB')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isAdmin && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Tooltip title="Approve">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="success"
|
||||
onClick={() => handleEnrollmentResponse(req.id, 'approve')}
|
||||
>
|
||||
<CheckCircle fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Reject">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleEnrollmentResponse(req.id, 'reject')}
|
||||
>
|
||||
<Cancel fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Teachers tab */}
|
||||
{tab === 2 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{cls.teachers.length === 0 ? (
|
||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||
No teachers assigned
|
||||
</Typography>
|
||||
) : (
|
||||
cls.teachers.map(t => (
|
||||
<Box
|
||||
key={t.teacher_id}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.5,
|
||||
p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'secondary.light' }}>
|
||||
{initials(t.profile?.full_name || '?')}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{t.profile?.full_name || t.teacher_id}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t.profile?.email}
|
||||
</Typography>
|
||||
</Box>
|
||||
{t.is_primary && (
|
||||
<Chip label="Primary" size="small" color="primary" />
|
||||
)}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<AddStudentDialog
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onAdd={handleAddStudent}
|
||||
accessToken={accessToken || ''}
|
||||
existingIds={existingStudentIds}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClassDetailPage;
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Button, CircularProgress, Alert, Chip, TextField,
|
||||
IconButton, Tooltip, Divider, Paper, Select, MenuItem, FormControl,
|
||||
InputLabel, Switch, FormControlLabel, Dialog, DialogTitle,
|
||||
DialogContent, DialogActions,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack, Add, Delete, AutoAwesome, DragIndicator, Save,
|
||||
LibraryBooks, LinkOff,
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Objective {
|
||||
id: string;
|
||||
text: string;
|
||||
bloom_level?: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface Activity {
|
||||
id: string;
|
||||
section: string;
|
||||
title: string;
|
||||
description: string;
|
||||
duration_minutes?: number;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface LessonPlan {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string | null;
|
||||
year_group: string | null;
|
||||
duration_minutes: number | null;
|
||||
context_notes: string | null;
|
||||
objectives: Objective[];
|
||||
activities: Activity[];
|
||||
is_public: boolean;
|
||||
creator_id: string;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const BLOOM_LEVELS = ['remember', 'understand', 'apply', 'analyse', 'evaluate', 'create'];
|
||||
const SECTION_SUGGESTIONS = ['Starter', 'Main', 'Plenary', 'Extension', 'Assessment', 'Discussion'];
|
||||
const BLOOM_COLORS: Record<string, string> = {
|
||||
remember: '#90caf9', understand: '#a5d6a7', apply: '#fff176',
|
||||
analyse: '#ffcc80', evaluate: '#ef9a9a', create: '#ce93d8',
|
||||
};
|
||||
|
||||
// ─── AI Suggest button ────────────────────────────────────────────────────────
|
||||
|
||||
function SuggestButton({ onClick, loading }: { onClick: () => void; loading: boolean }) {
|
||||
return (
|
||||
<Tooltip title="AI suggest">
|
||||
<span>
|
||||
<IconButton size="small" onClick={onClick} disabled={loading} color="primary" sx={{ p: 0.5 }}>
|
||||
{loading ? <CircularProgress size={16} /> : <AutoAwesome fontSize="small" />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
const LessonPlanDetailPage: React.FC = () => {
|
||||
const { planId } = useParams<{ planId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const [plan, setPlan] = useState<LessonPlan | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [suggestingField, setSuggestingField] = useState<string | null>(null);
|
||||
|
||||
// Local editable state (mirrors plan)
|
||||
const [title, setTitle] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [yearGroup, setYearGroup] = useState('');
|
||||
const [duration, setDuration] = useState('');
|
||||
const [contextNotes, setContextNotes] = useState('');
|
||||
const [isPublic, setIsPublic] = useState(false);
|
||||
const [objectives, setObjectives] = useState<Objective[]>([]);
|
||||
const [activities, setActivities] = useState<Activity[]>([]);
|
||||
|
||||
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!accessToken || !planId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/lessons/plans/${planId}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.id) {
|
||||
setPlan(data);
|
||||
setTitle(data.title || '');
|
||||
setSubject(data.subject || '');
|
||||
setYearGroup(data.year_group || '');
|
||||
setDuration(data.duration_minutes?.toString() || '');
|
||||
setContextNotes(data.context_notes || '');
|
||||
setIsPublic(data.is_public || false);
|
||||
setObjectives(data.objectives || []);
|
||||
setActivities(data.activities || []);
|
||||
} else {
|
||||
setError(data.detail || 'Plan not found');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken, planId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const save = useCallback(async (
|
||||
overrides?: Partial<{ title: string; subject: string; year_group: string; duration_minutes: number | null; context_notes: string; is_public: boolean; objectives: Objective[]; activities: Activity[] }>
|
||||
) => {
|
||||
if (!accessToken || !planId) return;
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
try {
|
||||
await fetch(`${API_BASE}/lessons/plans/${planId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
subject: subject || null,
|
||||
year_group: yearGroup || null,
|
||||
duration_minutes: duration ? parseInt(duration, 10) : null,
|
||||
context_notes: contextNotes || null,
|
||||
is_public: isPublic,
|
||||
objectives,
|
||||
activities,
|
||||
...overrides,
|
||||
}),
|
||||
});
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [accessToken, planId, title, subject, yearGroup, duration, contextNotes, isPublic, objectives, activities]);
|
||||
|
||||
const scheduleAutoSave = useCallback(() => {
|
||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||
autoSaveTimer.current = setTimeout(() => save(), 1500);
|
||||
}, [save]);
|
||||
|
||||
// ── Objectives ────────────────────────────────────────────────────────────
|
||||
|
||||
const addObjective = () => {
|
||||
const next: Objective = { id: crypto.randomUUID(), text: '', bloom_level: undefined, order: objectives.length };
|
||||
setObjectives(prev => [...prev, next]);
|
||||
};
|
||||
|
||||
const updateObjective = (id: string, patch: Partial<Objective>) => {
|
||||
setObjectives(prev => prev.map(o => o.id === id ? { ...o, ...patch } : o));
|
||||
scheduleAutoSave();
|
||||
};
|
||||
|
||||
const removeObjective = (id: string) => {
|
||||
setObjectives(prev => prev.filter(o => o.id !== id).map((o, i) => ({ ...o, order: i })));
|
||||
scheduleAutoSave();
|
||||
};
|
||||
|
||||
// ── Activities ────────────────────────────────────────────────────────────
|
||||
|
||||
const addActivity = () => {
|
||||
const next: Activity = {
|
||||
id: crypto.randomUUID(), section: 'Main', title: '', description: '', order: activities.length,
|
||||
};
|
||||
setActivities(prev => [...prev, next]);
|
||||
};
|
||||
|
||||
const updateActivity = (id: string, patch: Partial<Activity>) => {
|
||||
setActivities(prev => prev.map(a => a.id === id ? { ...a, ...patch } : a));
|
||||
scheduleAutoSave();
|
||||
};
|
||||
|
||||
const removeActivity = (id: string) => {
|
||||
setActivities(prev => prev.filter(a => a.id !== id).map((a, i) => ({ ...a, order: i })));
|
||||
scheduleAutoSave();
|
||||
};
|
||||
|
||||
// ── AI Suggest ────────────────────────────────────────────────────────────
|
||||
|
||||
const suggest = async (field: string, targetId?: string) => {
|
||||
if (!accessToken || !planId) return;
|
||||
const key = targetId ? `${field}_${targetId}` : field;
|
||||
setSuggestingField(key);
|
||||
try {
|
||||
const body: Record<string, any> = {
|
||||
field,
|
||||
context: contextNotes,
|
||||
objective_texts: objectives.map(o => o.text).filter(Boolean),
|
||||
};
|
||||
if (field === 'activity_description' && targetId) {
|
||||
const act = activities.find(a => a.id === targetId);
|
||||
if (act) body.activity_section = act.section;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/lessons/plans/${planId}/suggest`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.suggestion) {
|
||||
if (field === 'objectives') {
|
||||
addObjective();
|
||||
setObjectives(prev => {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = { ...updated[updated.length - 1], text: data.suggestion };
|
||||
return updated;
|
||||
});
|
||||
} else if (field === 'activity_description' && targetId) {
|
||||
updateActivity(targetId, { description: data.suggestion });
|
||||
} else if (field === 'title') {
|
||||
setTitle(data.suggestion);
|
||||
scheduleAutoSave();
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError('AI suggest failed: ' + e.message);
|
||||
} finally {
|
||||
setSuggestingField(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !plan) {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
<Button sx={{ mt: 2 }} startIcon={<ArrowBack />} onClick={() => navigate('/lesson-plans')}>
|
||||
Back to Plans
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 860, mx: 'auto' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Button size="small" startIcon={<ArrowBack />} onClick={() => navigate('/lesson-plans')}>
|
||||
Lesson Plans
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{saved && <Typography variant="caption" color="success.main">Saved</Typography>}
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={saving ? <CircularProgress size={14} /> : <Save />}
|
||||
onClick={() => save()}
|
||||
disabled={saving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>{error}</Alert>
|
||||
)}
|
||||
|
||||
{/* Title row */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2.5 }}>
|
||||
<TextField
|
||||
value={title}
|
||||
onChange={e => { setTitle(e.target.value); scheduleAutoSave(); }}
|
||||
variant="standard"
|
||||
fullWidth
|
||||
inputProps={{ style: { fontSize: '1.5rem', fontWeight: 700 } }}
|
||||
placeholder="Lesson title…"
|
||||
/>
|
||||
<SuggestButton
|
||||
onClick={() => suggest('title')}
|
||||
loading={suggestingField === 'title'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Metadata row */}
|
||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', mb: 3, alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="Subject"
|
||||
value={subject}
|
||||
onChange={e => { setSubject(e.target.value); scheduleAutoSave(); }}
|
||||
size="small"
|
||||
sx={{ width: 200 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Year group"
|
||||
value={yearGroup}
|
||||
onChange={e => { setYearGroup(e.target.value); scheduleAutoSave(); }}
|
||||
size="small"
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Duration (min)"
|
||||
value={duration}
|
||||
onChange={e => { setDuration(e.target.value); scheduleAutoSave(); }}
|
||||
size="small"
|
||||
type="number"
|
||||
inputProps={{ min: 1, max: 300 }}
|
||||
sx={{ width: 140 }}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={isPublic}
|
||||
onChange={e => { setIsPublic(e.target.checked); scheduleAutoSave(); }}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="caption">Public</Typography>}
|
||||
sx={{ ml: 0.5 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Context notes */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 0.75 }}>
|
||||
Context / notes
|
||||
</Typography>
|
||||
<TextField
|
||||
value={contextNotes}
|
||||
onChange={e => { setContextNotes(e.target.value); scheduleAutoSave(); }}
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Class context, prior knowledge, SEN considerations, links to curriculum…"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
{/* Objectives */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.25 }}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
Learning objectives
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<SuggestButton
|
||||
onClick={() => suggest('objectives')}
|
||||
loading={suggestingField === 'objectives'}
|
||||
/>
|
||||
<Tooltip title="Add objective">
|
||||
<IconButton size="small" onClick={addObjective}>
|
||||
<Add fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{objectives.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1.5, textAlign: 'center' }}>
|
||||
No objectives yet — add one or use ✨ to suggest.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{objectives.map((obj, idx) => (
|
||||
<Paper key={obj.id} variant="outlined" sx={{ p: 1.25 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<DragIndicator sx={{ color: 'text.disabled', mt: 0.5, flexShrink: 0 }} fontSize="small" />
|
||||
<TextField
|
||||
value={obj.text}
|
||||
onChange={e => updateObjective(obj.id, { text: e.target.value })}
|
||||
fullWidth
|
||||
size="small"
|
||||
multiline
|
||||
placeholder={`Objective ${idx + 1}…`}
|
||||
variant="standard"
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 120, flexShrink: 0 }}>
|
||||
<Select
|
||||
value={obj.bloom_level || ''}
|
||||
onChange={e => updateObjective(obj.id, { bloom_level: e.target.value || undefined })}
|
||||
displayEmpty
|
||||
variant="standard"
|
||||
renderValue={v => v ? (
|
||||
<Chip
|
||||
label={v as string}
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: '0.65rem', bgcolor: BLOOM_COLORS[v as string] || 'grey.200' }}
|
||||
/>
|
||||
) : <Typography variant="caption" color="text.disabled">Bloom level</Typography>}
|
||||
>
|
||||
<MenuItem value=""><em>None</em></MenuItem>
|
||||
{BLOOM_LEVELS.map(l => (
|
||||
<MenuItem key={l} value={l}>
|
||||
<Chip
|
||||
label={l}
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: '0.65rem', bgcolor: BLOOM_COLORS[l] || 'grey.200' }}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Tooltip title="Remove">
|
||||
<IconButton size="small" color="error" onClick={() => removeObjective(obj.id)} sx={{ flexShrink: 0, p: 0.25 }}>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
{/* Activities */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.25 }}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
Activities
|
||||
</Typography>
|
||||
<Tooltip title="Add activity">
|
||||
<IconButton size="small" onClick={addActivity}>
|
||||
<Add fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1.5, textAlign: 'center' }}>
|
||||
No activities yet — add one to structure your lesson.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{activities.map((act, idx) => (
|
||||
<Paper key={act.id} variant="outlined" sx={{ p: 1.5 }}>
|
||||
{/* Activity header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<DragIndicator sx={{ color: 'text.disabled', flexShrink: 0 }} fontSize="small" />
|
||||
|
||||
{/* Section selector */}
|
||||
<TextField
|
||||
value={act.section}
|
||||
onChange={e => updateActivity(act.id, { section: e.target.value })}
|
||||
size="small"
|
||||
label="Section"
|
||||
sx={{ width: 140, flexShrink: 0 }}
|
||||
select={false}
|
||||
inputProps={{ list: `section-suggestions-${act.id}` }}
|
||||
placeholder="Starter / Main…"
|
||||
/>
|
||||
<datalist id={`section-suggestions-${act.id}`}>
|
||||
{SECTION_SUGGESTIONS.map(s => <option key={s} value={s} />)}
|
||||
</datalist>
|
||||
|
||||
<TextField
|
||||
value={act.title}
|
||||
onChange={e => updateActivity(act.id, { title: e.target.value })}
|
||||
size="small"
|
||||
label="Activity title"
|
||||
fullWidth
|
||||
placeholder={`Activity ${idx + 1}…`}
|
||||
/>
|
||||
<TextField
|
||||
value={act.duration_minutes?.toString() || ''}
|
||||
onChange={e => updateActivity(act.id, { duration_minutes: e.target.value ? parseInt(e.target.value, 10) : undefined })}
|
||||
size="small"
|
||||
label="Min"
|
||||
type="number"
|
||||
inputProps={{ min: 1 }}
|
||||
sx={{ width: 72, flexShrink: 0 }}
|
||||
/>
|
||||
<Tooltip title="Remove">
|
||||
<IconButton size="small" color="error" onClick={() => removeActivity(act.id)} sx={{ flexShrink: 0, p: 0.25 }}>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, pl: 3.5 }}>
|
||||
<TextField
|
||||
value={act.description}
|
||||
onChange={e => updateActivity(act.id, { description: e.target.value })}
|
||||
size="small"
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
placeholder="Describe what the teacher and pupils do…"
|
||||
/>
|
||||
<SuggestButton
|
||||
onClick={() => suggest('activity_description', act.id)}
|
||||
loading={suggestingField === `activity_description_${act.id}`}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LessonPlanDetailPage;
|
||||
@@ -0,0 +1,328 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Button, CircularProgress, Alert, Chip, Card,
|
||||
CardContent, CardActions, IconButton, Tooltip, TextField,
|
||||
InputAdornment, MenuItem, Select, FormControl, InputLabel,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
} from '@mui/material';
|
||||
import { Add, Search, LibraryBooks, Edit, Delete, FilterList } from '@mui/icons-material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PlanSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string | null;
|
||||
year_group: string | null;
|
||||
duration_minutes: number | null;
|
||||
objectives: { id: string; text: string; bloom_level?: string }[];
|
||||
activities: { id: string; section: string; title: string }[];
|
||||
is_public: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
creator_name?: string;
|
||||
}
|
||||
|
||||
// ─── Create plan dialog ───────────────────────────────────────────────────────
|
||||
|
||||
interface CreateDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (data: { title: string; subject?: string; year_group?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
function CreatePlanDialog({ open, onClose, onCreate }: CreateDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [yearGroup, setYearGroup] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const reset = () => { setTitle(''); setSubject(''); setYearGroup(''); };
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
await onCreate({ title: title.trim(), subject: subject || undefined, year_group: yearGroup || undefined });
|
||||
setSaving(false);
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => { reset(); onClose(); };
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>New Lesson Plan</DialogTitle>
|
||||
<DialogContent sx={{ pt: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 0.5 }}>
|
||||
<TextField
|
||||
label="Title"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
autoFocus
|
||||
required
|
||||
placeholder="e.g. Introduction to Photosynthesis"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
<TextField
|
||||
label="Subject"
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="e.g. Biology"
|
||||
/>
|
||||
<TextField
|
||||
label="Year group"
|
||||
value={yearGroup}
|
||||
onChange={e => setYearGroup(e.target.value)}
|
||||
size="small"
|
||||
sx={{ width: 140 }}
|
||||
placeholder="e.g. 9"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={handleClose} disabled={saving}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
variant="contained"
|
||||
disabled={!title.trim() || saving}
|
||||
startIcon={saving ? <CircularProgress size={16} /> : <Add />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Plan card ────────────────────────────────────────────────────────────────
|
||||
|
||||
function PlanCard({ plan, onOpen, onDelete }: {
|
||||
plan: PlanSummary;
|
||||
onOpen: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const objCount = plan.objectives?.length ?? 0;
|
||||
const actCount = plan.activities?.length ?? 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
display: 'flex', flexDirection: 'column', cursor: 'pointer',
|
||||
'&:hover': { borderColor: 'primary.main', boxShadow: 1 },
|
||||
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
onClick={() => onOpen(plan.id)}
|
||||
>
|
||||
<CardContent sx={{ flex: 1, pb: 1 }}>
|
||||
<Typography variant="body1" fontWeight={700} sx={{ mb: 0.5, lineHeight: 1.3 }}>
|
||||
{plan.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mb: 1 }}>
|
||||
{plan.subject && (
|
||||
<Chip label={plan.subject} size="small" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||
)}
|
||||
{plan.year_group && (
|
||||
<Chip label={`Y${plan.year_group}`} size="small" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||
)}
|
||||
{plan.duration_minutes && (
|
||||
<Chip label={`${plan.duration_minutes}min`} size="small" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||
)}
|
||||
{plan.is_public && (
|
||||
<Chip label="Public" size="small" color="info" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{objCount} objective{objCount !== 1 ? 's' : ''} · {actCount} activit{actCount !== 1 ? 'ies' : 'y'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
<CardActions sx={{ pt: 0, px: 1.5, pb: 1.5, justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="Edit plan">
|
||||
<IconButton size="small" onClick={() => onOpen(plan.id)}>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete plan">
|
||||
<IconButton size="small" color="error" onClick={() => onDelete(plan.id)}>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
const LessonPlansPage: React.FC = () => {
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [plans, setPlans] = useState<PlanSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterSubject, setFilterSubject] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/lessons/plans`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) setPlans(data);
|
||||
else setError(data.detail || 'Failed to load plans');
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleCreate = async (body: { title: string; subject?: string; year_group?: string }) => {
|
||||
const res = await fetch(`${API_BASE}/lessons/plans`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.id) {
|
||||
navigate(`/lesson-plans/${data.id}`);
|
||||
} else {
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('Delete this lesson plan?')) return;
|
||||
await fetch(`${API_BASE}/lessons/plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
setPlans(prev => prev.filter(p => p.id !== id));
|
||||
};
|
||||
|
||||
const subjects = Array.from(new Set(plans.map(p => p.subject).filter(Boolean) as string[])).sort();
|
||||
|
||||
const filtered = plans.filter(p => {
|
||||
const matchSearch = !search || p.title.toLowerCase().includes(search.toLowerCase())
|
||||
|| (p.subject || '').toLowerCase().includes(search.toLowerCase());
|
||||
const matchSubject = !filterSubject || p.subject === filterSubject;
|
||||
return matchSearch && matchSubject;
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1100, mx: 'auto' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<LibraryBooks sx={{ color: 'primary.main', fontSize: 32 }} />
|
||||
<Box>
|
||||
<Typography variant="h5" fontWeight={700}>Lesson Plans</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{plans.length} plan{plans.length !== 1 ? 's' : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
New Plan
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Filters */}
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 3, alignItems: 'center' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search plans…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: <InputAdornment position="start"><Search fontSize="small" /></InputAdornment>,
|
||||
}}
|
||||
sx={{ width: 280 }}
|
||||
/>
|
||||
{subjects.length > 0 && (
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Subject</InputLabel>
|
||||
<Select
|
||||
label="Subject"
|
||||
value={filterSubject}
|
||||
onChange={e => setFilterSubject(e.target.value)}
|
||||
>
|
||||
<MenuItem value="">All subjects</MenuItem>
|
||||
{subjects.map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
{(search || filterSubject) && (
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<FilterList />}
|
||||
onClick={() => { setSearch(''); setFilterSubject(''); }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : filtered.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 8, color: 'text.secondary' }}>
|
||||
<LibraryBooks sx={{ fontSize: 56, opacity: 0.2, mb: 1 }} />
|
||||
<Typography variant="body1">
|
||||
{plans.length === 0 ? 'No lesson plans yet.' : 'No plans match your filters.'}
|
||||
</Typography>
|
||||
{plans.length === 0 && (
|
||||
<Button sx={{ mt: 2 }} variant="outlined" startIcon={<Add />} onClick={() => setCreateOpen(true)}>
|
||||
Create your first plan
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 2 }}>
|
||||
{filtered.map(plan => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
onOpen={id => navigate(`/lesson-plans/${id}`)}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<CreatePlanDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LessonPlansPage;
|
||||
@@ -3,19 +3,37 @@ import { Link } from 'react-router-dom';
|
||||
import { AccessTime, KeyboardArrowRight, MenuBook, People, School } from '@mui/icons-material';
|
||||
import useTimetableStore from '../../stores/timetableStore';
|
||||
import { useUser } from '../../contexts/UserContext';
|
||||
import { useNeoInstitute } from '../../contexts/NeoInstituteContext';
|
||||
import { CircularProgress } from '@mui/material';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const MyClassesSkeleton = () => (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<CircularProgress />
|
||||
</div>
|
||||
);
|
||||
|
||||
const MyClassesPage: React.FC = () => {
|
||||
const { profile } = useUser();
|
||||
const { selectedInstituteId } = useNeoInstitute();
|
||||
const {
|
||||
myClasses,
|
||||
myClassesLoading,
|
||||
myClassesError,
|
||||
classesLoading: myClassesLoading,
|
||||
classesError: myClassesError,
|
||||
fetchMyClasses,
|
||||
} = useTimetableStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyClasses();
|
||||
}, [fetchMyClasses]);
|
||||
fetchMyClasses().catch((error) =>
|
||||
logger.warn('my-classes', 'Fetch my classes failed', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
);
|
||||
}, [fetchMyClasses, selectedInstituteId]);
|
||||
|
||||
if (myClassesLoading) {
|
||||
return <MyClassesSkeleton />;
|
||||
}
|
||||
|
||||
const getRoleLabel = (role: string) => {
|
||||
switch (role) {
|
||||
@@ -36,14 +54,6 @@ const MyClassesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (myClassesLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (myClassesError) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
@@ -88,25 +98,25 @@ const MyClassesPage: React.FC = () => {
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{teachingClasses.map((classItem) => (
|
||||
<Link
|
||||
key={classItem.class_id}
|
||||
to={`/timetable/classes/${classItem.class_id}`}
|
||||
key={classItem.id}
|
||||
to={`/classes/${classItem.id}`}
|
||||
className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200"
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{classItem.class?.name}
|
||||
{classItem.name}
|
||||
</h3>
|
||||
{classItem.class?.code && (
|
||||
<p className="text-sm text-gray-500">{classItem.class.code}</p>
|
||||
{classItem.class_code && (
|
||||
<p className="text-sm text-gray-500">{classItem.class_code}</p>
|
||||
)}
|
||||
</div>
|
||||
<KeyboardArrowRight className="text-gray-400 flex-shrink-0 ml-2" size={20} />
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
|
||||
{classItem.class?.description || 'No description'}
|
||||
{classItem.description || 'No description'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -121,12 +131,12 @@ const MyClassesPage: React.FC = () => {
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center gap-4 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<People size={14} />
|
||||
<span>{classItem.class?.enrolled_count || 0} students</span>
|
||||
<span>{(classItem as any).enrolled_count || 0} students</span>
|
||||
</div>
|
||||
{classItem.class?.academic_year && (
|
||||
{classItem.academic_year && (
|
||||
<div className="flex items-center gap-1">
|
||||
<School size={14} />
|
||||
<span>{classItem.class.academic_year}</span>
|
||||
<span>{classItem.academic_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -151,25 +161,25 @@ const MyClassesPage: React.FC = () => {
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{enrolledClasses.map((classItem) => (
|
||||
<Link
|
||||
key={classItem.class_id}
|
||||
to={`/timetable/classes/${classItem.class_id}`}
|
||||
key={classItem.id}
|
||||
to={`/classes/${classItem.id}`}
|
||||
className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200"
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{classItem.class?.name}
|
||||
{classItem.name}
|
||||
</h3>
|
||||
{classItem.class?.code && (
|
||||
<p className="text-sm text-gray-500">{classItem.class.code}</p>
|
||||
{classItem.class_code && (
|
||||
<p className="text-sm text-gray-500">{classItem.class_code}</p>
|
||||
)}
|
||||
</div>
|
||||
<KeyboardArrowRight className="text-gray-400 flex-shrink-0 ml-2" size={20} />
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
|
||||
{classItem.class?.description || 'No description'}
|
||||
{classItem.description || 'No description'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -182,13 +192,13 @@ const MyClassesPage: React.FC = () => {
|
||||
<div className="flex items-center gap-1">
|
||||
<School size={14} />
|
||||
<span>
|
||||
{classItem.class?.teachers?.[0]?.first_name} {classItem.class?.teachers?.[0]?.last_name}
|
||||
{(classItem as any).primary_teacher_name} {}
|
||||
</span>
|
||||
</div>
|
||||
{classItem.class?.academic_year && (
|
||||
{classItem.academic_year && (
|
||||
<div className="flex items-center gap-1">
|
||||
<School size={14} />
|
||||
<span>{classItem.class.academic_year}</span>
|
||||
<span>{classItem.academic_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -208,7 +218,7 @@ const MyClassesPage: React.FC = () => {
|
||||
You are not enrolled in or teaching any classes yet.
|
||||
</p>
|
||||
<Link
|
||||
to="/timetable/classes"
|
||||
to="/classes"
|
||||
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200"
|
||||
>
|
||||
Browse Available Classes
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Button, CircularProgress, Alert, Chip,
|
||||
Table, TableHead, TableBody, TableRow, TableCell,
|
||||
Select, MenuItem, FormControl, Tooltip, Divider,
|
||||
Accordion, AccordionSummary, AccordionDetails, Grid,
|
||||
Card, CardContent, IconButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore, People, School, MenuBook, EventNote,
|
||||
HourglassEmpty, Refresh, Edit,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
const DAY_TYPE_OPTIONS = ['Academic', 'Holiday', 'Staff', 'OffTimetable'];
|
||||
const DAY_TYPE_COLORS: Record<string, 'default' | 'primary' | 'success' | 'warning' | 'error'> = {
|
||||
Academic: 'primary',
|
||||
Holiday: 'warning',
|
||||
Staff: 'success',
|
||||
OffTimetable: 'default',
|
||||
};
|
||||
|
||||
interface Term {
|
||||
id: string;
|
||||
term_name: string;
|
||||
term_number: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
academic_days: number;
|
||||
total_days: number;
|
||||
is_current: boolean;
|
||||
}
|
||||
|
||||
interface CalendarDay {
|
||||
id: string;
|
||||
date: string;
|
||||
day_of_week: string;
|
||||
day_type: string;
|
||||
week_cycle: string;
|
||||
week_number: number | null;
|
||||
academic_day_number: number | null;
|
||||
excluded_period_codes: string[];
|
||||
}
|
||||
|
||||
interface Overview {
|
||||
user_role: string;
|
||||
counts: {
|
||||
staff: number;
|
||||
students: number;
|
||||
classes: number;
|
||||
pending_invitations: number;
|
||||
};
|
||||
terms: Term[];
|
||||
has_calendar: boolean;
|
||||
}
|
||||
|
||||
// ─── Stat card ────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, icon: Icon, onClick }: {
|
||||
label: string; value: number | string; icon: React.ElementType; onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
sx={{ cursor: onClick ? 'pointer' : 'default', '&:hover': onClick ? { bgcolor: 'action.hover' } : {} }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Icon sx={{ color: 'primary.main', fontSize: 28 }} />
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, lineHeight: 1 }}>{value}</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Calendar days table ──────────────────────────────────────────────────────
|
||||
|
||||
function CalendarDaysTable({ termId, headers, isAdmin }: {
|
||||
termId: string; headers: Record<string, string>; isAdmin: boolean;
|
||||
}) {
|
||||
const [days, setDays] = useState<CalendarDay[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`${API_BASE}/school/calendar/days?term_id=${termId}`, { headers })
|
||||
.then(r => r.json())
|
||||
.then(data => setDays(data.days || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [termId]);
|
||||
|
||||
const handleTypeChange = async (dayId: string, newType: string) => {
|
||||
setSaving(dayId);
|
||||
try {
|
||||
await fetch(`${API_BASE}/school/calendar/days/${dayId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ day_type: newType }),
|
||||
});
|
||||
setDays(prev => prev.map(d => d.id === dayId ? { ...d, day_type: newType } : d));
|
||||
} catch { }
|
||||
setSaving(null);
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}><CircularProgress size={20} /></Box>;
|
||||
|
||||
return (
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Table size="small" sx={{ minWidth: 500 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Day</TableCell>
|
||||
<TableCell>Week</TableCell>
|
||||
<TableCell>Cycle</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{days.map(day => (
|
||||
<TableRow key={day.id} sx={{ opacity: day.day_type !== 'Academic' ? 0.7 : 1 }}>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.78rem' }}>{day.date}</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.78rem' }}>{day.day_of_week.slice(0, 3)}</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>{day.week_number ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
{day.week_cycle
|
||||
? <Chip label={`W${day.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
|
||||
: <Typography variant="caption" sx={{ color: 'text.disabled' }}>—</Typography>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isAdmin ? (
|
||||
<FormControl size="small" sx={{ minWidth: 110 }}>
|
||||
<Select
|
||||
value={day.day_type}
|
||||
onChange={e => handleTypeChange(day.id, e.target.value)}
|
||||
disabled={saving === day.id}
|
||||
sx={{ fontSize: '0.75rem', height: 24 }}
|
||||
>
|
||||
{DAY_TYPE_OPTIONS.map(opt => (
|
||||
<MenuItem key={opt} value={opt} sx={{ fontSize: '0.78rem' }}>{opt}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Chip
|
||||
label={day.day_type}
|
||||
size="small"
|
||||
color={DAY_TYPE_COLORS[day.day_type] ?? 'default'}
|
||||
sx={{ fontSize: '0.65rem', height: 18 }}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SchoolSettingsPage: React.FC = () => {
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [overview, setOverview] = useState<Overview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedTerm, setExpandedTerm] = useState<string | false>(false);
|
||||
|
||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/school/overview`, { headers });
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') setOverview(data);
|
||||
else setError(data.detail || 'Failed to load school overview');
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => { loadOverview(); }, [loadOverview]);
|
||||
|
||||
const isAdmin = overview?.user_role === 'school_admin' || overview?.user_role === 'department_head';
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}><CircularProgress /></Box>;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 960, mx: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>School Settings</Typography>
|
||||
{overview && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{overview.user_role}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Tooltip title="Refresh">
|
||||
<IconButton size="small" onClick={loadOverview}><Refresh fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
|
||||
{overview && (
|
||||
<>
|
||||
{/* Stat cards */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<StatCard label="Staff" value={overview.counts.staff} icon={People} onClick={() => navigate('/staff-manager')} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<StatCard label="Students" value={overview.counts.students} icon={School} onClick={() => navigate('/student-manager')} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<StatCard label="Classes" value={overview.counts.classes} icon={MenuBook} onClick={() => navigate('/classes')} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<StatCard label="Pending invites" value={overview.counts.pending_invitations} icon={HourglassEmpty} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
{/* Calendar */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>Academic Calendar</Typography>
|
||||
{!overview.has_calendar && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
No calendar configured
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{overview.terms.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
No academic calendar set up yet. Use the School Calendar Wizard in the navigation panel to set one up.
|
||||
</Alert>
|
||||
) : (
|
||||
overview.terms.map(term => (
|
||||
<Accordion
|
||||
key={term.id}
|
||||
expanded={expandedTerm === term.id}
|
||||
onChange={(_, open) => setExpandedTerm(open ? term.id : false)}
|
||||
sx={{ mb: 0.5 }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, width: '100%', pr: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, minWidth: 80 }}>
|
||||
{term.term_name}
|
||||
</Typography>
|
||||
{term.is_current && (
|
||||
<Chip label="Current" size="small" color="primary" sx={{ fontSize: '0.65rem', height: 18 }} />
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{term.start_date} → {term.end_date}
|
||||
</Typography>
|
||||
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
|
||||
<Chip label={`${term.academic_days} teaching days`} size="small" sx={{ fontSize: '0.65rem', height: 18 }} />
|
||||
{term.total_days > term.academic_days && (
|
||||
<Chip
|
||||
label={`${term.total_days - term.academic_days} non-teaching`}
|
||||
size="small"
|
||||
color="warning"
|
||||
sx={{ fontSize: '0.65rem', height: 18 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ p: 0 }}>
|
||||
<CalendarDaysTable termId={term.id} headers={headers} isAdmin={isAdmin} />
|
||||
{isAdmin && (
|
||||
<Typography variant="caption" sx={{ display: 'block', px: 2, py: 1, color: 'text.secondary' }}>
|
||||
Changing a day type automatically creates or removes its periods.
|
||||
</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Quick links */}
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Manage</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Button variant="outlined" size="small" startIcon={<People />} onClick={() => navigate('/staff-manager')}>
|
||||
Staff Manager
|
||||
</Button>
|
||||
<Button variant="outlined" size="small" startIcon={<School />} onClick={() => navigate('/student-manager')}>
|
||||
Student Manager
|
||||
</Button>
|
||||
<Button variant="outlined" size="small" startIcon={<MenuBook />} onClick={() => navigate('/classes')}>
|
||||
Classes
|
||||
</Button>
|
||||
<Button variant="outlined" size="small" startIcon={<EventNote />} onClick={() => navigate('/my-lessons')}>
|
||||
My Lessons
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchoolSettingsPage;
|
||||
@@ -0,0 +1,280 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, Button, CircularProgress, Alert, Chip,
|
||||
TextField, Select, MenuItem, FormControl, InputLabel,
|
||||
Table, TableHead, TableBody, TableRow, TableCell,
|
||||
IconButton, Tooltip, Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PersonAdd, Cancel, Send, Refresh,
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StaffMember {
|
||||
profile_id: string;
|
||||
email: string | null;
|
||||
username: string | null;
|
||||
display_name: string | null;
|
||||
role: string;
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
interface Invitation {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, 'default' | 'primary' | 'success' | 'warning'> = {
|
||||
school_admin: 'primary',
|
||||
department_head: 'warning',
|
||||
teacher: 'success',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
|
||||
pending: 'warning',
|
||||
accepted: 'success',
|
||||
expired: 'error',
|
||||
cancelled: 'default',
|
||||
};
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
const StaffManagerPage: React.FC = () => {
|
||||
const { accessToken } = useAuth();
|
||||
const [staff, setStaff] = useState<StaffMember[]>([]);
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
// Invite form
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState('teacher');
|
||||
const [inviting, setInviting] = useState(false);
|
||||
|
||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [staffRes, invRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/users/staff`, { headers }),
|
||||
fetch(`${API_BASE}/users/invitations?role=teacher&status=pending`, { headers })
|
||||
.then(r => r.json())
|
||||
.catch(() => ({ invitations: [] })),
|
||||
].map((p, i) => i === 0 ? (p as Promise<Response>).then(r => r.json()) : p));
|
||||
if (staffRes.status === 'ok') setStaff(staffRes.staff || []);
|
||||
else setError(staffRes.detail || 'Failed to load staff');
|
||||
setInvitations(invRes.invitations || []);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!inviteEmail.trim() || !accessToken) return;
|
||||
setInviting(true);
|
||||
setError(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/invite`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: inviteEmail.trim().toLowerCase(), role: inviteRole }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok' || data.status === 'already_pending') {
|
||||
setSuccessMsg(
|
||||
data.status === 'already_pending'
|
||||
? `Invitation already pending for ${inviteEmail}`
|
||||
: `Invitation sent to ${inviteEmail}`
|
||||
);
|
||||
setInviteEmail('');
|
||||
loadData();
|
||||
} else {
|
||||
setError(data.detail || data.message || 'Invite failed');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string, email: string) => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
await fetch(`${API_BASE}/users/invitations/${id}`, { method: 'DELETE', headers });
|
||||
setSuccessMsg(`Cancelled invitation for ${email}`);
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async (id: string, email: string) => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/invitations/${id}/resend`, { method: 'POST', headers });
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
setSuccessMsg(`Re-sent invitation to ${email}`);
|
||||
} else {
|
||||
setError(data.detail || 'Resend failed');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Staff Manager</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Invite teachers, department heads, and school admins
|
||||
</Typography>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
{successMsg && <Alert severity="success" sx={{ mt: 2 }} onClose={() => setSuccessMsg(null)}>{successMsg}</Alert>}
|
||||
|
||||
{/* Invite form */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 3, alignItems: 'flex-end' }}>
|
||||
<TextField
|
||||
label="Email address"
|
||||
value={inviteEmail}
|
||||
onChange={e => setInviteEmail(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleInvite()}
|
||||
size="small"
|
||||
sx={{ flex: 1 }}
|
||||
placeholder="[email protected]"
|
||||
disabled={inviting}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select label="Role" value={inviteRole} onChange={e => setInviteRole(e.target.value)} disabled={inviting}>
|
||||
<MenuItem value="teacher">Teacher</MenuItem>
|
||||
<MenuItem value="department_head">Department Head</MenuItem>
|
||||
<MenuItem value="school_admin">School Admin</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={inviting ? <CircularProgress size={14} /> : <PersonAdd />}
|
||||
onClick={handleInvite}
|
||||
disabled={inviting || !inviteEmail.trim()}
|
||||
sx={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
Send Invite
|
||||
</Button>
|
||||
<Tooltip title="Refresh">
|
||||
<IconButton size="small" onClick={loadData} disabled={loading}>
|
||||
<Refresh fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Pending invitations */}
|
||||
{invitations.length > 0 && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Pending Invitations ({invitations.length})</Typography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Sent</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{invitations.map(inv => (
|
||||
<TableRow key={inv.id}>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>{inv.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={inv.role} size="small" color={ROLE_COLORS[inv.role] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={inv.status} size="small" color={STATUS_COLORS[inv.status] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
|
||||
{new Date(inv.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Resend">
|
||||
<IconButton size="small" onClick={() => handleResend(inv.id, inv.email)}>
|
||||
<Send fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Cancel">
|
||||
<IconButton size="small" onClick={() => handleCancel(inv.id, inv.email)}>
|
||||
<Cancel fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Active staff */}
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Active Staff ({loading ? '…' : staff.length})
|
||||
</Typography>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
||||
) : staff.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No staff members yet.</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell>Joined</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{staff.map(s => (
|
||||
<TableRow key={s.profile_id}>
|
||||
<TableCell sx={{ fontWeight: 500, fontSize: '0.85rem' }}>
|
||||
{s.display_name || s.username || '—'}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem', color: 'text.secondary' }}>
|
||||
{s.email || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={s.role} size="small" color={ROLE_COLORS[s.role] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
|
||||
{new Date(s.joined_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaffManagerPage;
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, CircularProgress, Alert, Chip, IconButton, Tooltip,
|
||||
} from '@mui/material';
|
||||
import { ChevronLeft, ChevronRight, Today } from '@mui/icons-material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
date: string;
|
||||
period_code: string;
|
||||
period_name: string;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
class_name: string | null;
|
||||
subject: string | null;
|
||||
year_group: string | null;
|
||||
week_cycle: string;
|
||||
day_of_week: string;
|
||||
status: string;
|
||||
teacher_name: string | null;
|
||||
}
|
||||
|
||||
interface DayEntry {
|
||||
date: string;
|
||||
day_of_week: string;
|
||||
is_today: boolean;
|
||||
lessons: Lesson[];
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function toMonday(d: Date): string {
|
||||
const day = d.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
const m = new Date(d);
|
||||
m.setDate(d.getDate() + diff);
|
||||
return m.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addWeeks(iso: string, n: number): string {
|
||||
const d = new Date(iso + 'T00:00:00');
|
||||
d.setDate(d.getDate() + n * 7);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
return new Date(iso + 'T00:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
|
||||
planned: 'default',
|
||||
in_progress: 'primary',
|
||||
completed: 'success',
|
||||
cancelled: 'error',
|
||||
substituted: 'warning',
|
||||
};
|
||||
|
||||
// ─── Lesson card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function LessonCard({ lesson }: { lesson: Lesson }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.25, mb: 0.75,
|
||||
border: '1px solid', borderColor: 'divider', borderRadius: 1,
|
||||
bgcolor: lesson.status === 'completed' ? 'action.hover' : 'background.paper',
|
||||
opacity: lesson.status === 'cancelled' ? 0.5 : 1,
|
||||
display: 'flex', flexDirection: 'column', gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
|
||||
{lesson.class_name || lesson.period_code}
|
||||
</Typography>
|
||||
{lesson.subject && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
|
||||
{lesson.subject}{lesson.year_group ? ` · Y${lesson.year_group}` : ''}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
|
||||
{lesson.start_time ? `${lesson.start_time}–${lesson.end_time}` : lesson.period_name}
|
||||
</Typography>
|
||||
{lesson.week_cycle && (
|
||||
<Chip label={`W${lesson.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
|
||||
)}
|
||||
<Chip
|
||||
label={lesson.status}
|
||||
size="small"
|
||||
color={STATUS_COLORS[lesson.status] ?? 'default'}
|
||||
sx={{ height: 16, fontSize: '0.65rem' }}
|
||||
/>
|
||||
</Box>
|
||||
{lesson.teacher_name && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem' }}>
|
||||
{lesson.teacher_name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
const StudentLessonsPage: React.FC = () => {
|
||||
const { accessToken } = useAuth();
|
||||
const [weekStart, setWeekStart] = useState<string>(toMonday(new Date()));
|
||||
const [days, setDays] = useState<DayEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (ws: string) => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/timetable/student/lessons?week_start=${ws}&weeks=1`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
);
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') setDays(data.days);
|
||||
else setError(data.message || 'Failed to load lessons');
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => { load(weekStart); }, [weekStart, load]);
|
||||
|
||||
const totalLessons = days.reduce((s, d) => s + d.lessons.length, 0);
|
||||
const weekLabel = days.length > 0
|
||||
? `${fmtDate(days[0].date)} – ${fmtDate(days[days.length - 1].date)}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1000, mx: 'auto' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" fontWeight={700}>My Lessons</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{weekLabel} · {totalLessons} lesson{totalLessons !== 1 ? 's' : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||
<Tooltip title="This week">
|
||||
<IconButton size="small" onClick={() => setWeekStart(toMonday(new Date()))}>
|
||||
<Today fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton size="small" onClick={() => setWeekStart(ws => addWeeks(ws, -1))}>
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => setWeekStart(ws => addWeeks(ws, 1))}>
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 1.5 }}>
|
||||
{days.map(day => (
|
||||
<Box key={day.date}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1, mb: 0.75, borderRadius: 1, textAlign: 'center',
|
||||
bgcolor: day.is_today ? 'primary.main' : 'action.hover',
|
||||
color: day.is_today ? 'primary.contrastText' : 'text.primary',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
|
||||
{day.day_of_week.slice(0, 3)}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', opacity: 0.85 }}>
|
||||
{fmtDate(day.date)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{day.lessons.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.7rem', px: 0.5 }}>
|
||||
No lessons
|
||||
</Typography>
|
||||
) : (
|
||||
day.lessons.map(lesson => (
|
||||
<LessonCard key={lesson.id} lesson={lesson} />
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudentLessonsPage;
|
||||
@@ -0,0 +1,380 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Box, Typography, Button, CircularProgress, Alert, Chip,
|
||||
TextField, Table, TableHead, TableBody, TableRow, TableCell,
|
||||
IconButton, Tooltip, Divider, Tabs, Tab,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PersonAdd, Cancel, Send, Refresh, UploadFile,
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
interface Student {
|
||||
profile_id: string;
|
||||
email: string | null;
|
||||
username: string | null;
|
||||
display_name: string | null;
|
||||
role: string;
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
interface Invitation {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'warning' | 'success' | 'error'> = {
|
||||
pending: 'warning',
|
||||
accepted: 'success',
|
||||
expired: 'error',
|
||||
cancelled: 'default',
|
||||
};
|
||||
|
||||
const StudentManagerPage: React.FC = () => {
|
||||
const { accessToken } = useAuth();
|
||||
const [tab, setTab] = useState(0);
|
||||
const [students, setStudents] = useState<Student[]>([]);
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
// Single invite
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviting, setInviting] = useState(false);
|
||||
|
||||
// CSV import
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [csvEmails, setCsvEmails] = useState<string[]>([]);
|
||||
const [csvPreview, setCsvPreview] = useState(false);
|
||||
const [csvProgress, setCsvProgress] = useState(0);
|
||||
const [csvTotal, setCsvTotal] = useState(0);
|
||||
const [bulkRunning, setBulkRunning] = useState(false);
|
||||
|
||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [studRes, invRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/users/students`, { headers }).then(r => r.json()),
|
||||
fetch(`${API_BASE}/users/invitations?role=student`, { headers }).then(r => r.json()),
|
||||
]);
|
||||
if (studRes.status === 'ok') setStudents(studRes.students || []);
|
||||
else setError(studRes.detail || 'Failed to load students');
|
||||
setInvitations(invRes.invitations || []);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const sendInvite = async (email: string): Promise<'ok' | 'already_pending' | 'error'> => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/invite`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.trim().toLowerCase(), role: 'student' }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') return 'ok';
|
||||
if (data.status === 'already_pending') return 'already_pending';
|
||||
return 'error';
|
||||
} catch {
|
||||
return 'error';
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!inviteEmail.trim() || !accessToken) return;
|
||||
setInviting(true);
|
||||
setError(null);
|
||||
setSuccessMsg(null);
|
||||
const result = await sendInvite(inviteEmail);
|
||||
if (result === 'ok') {
|
||||
setSuccessMsg(`Invitation sent to ${inviteEmail}`);
|
||||
setInviteEmail('');
|
||||
loadData();
|
||||
} else if (result === 'already_pending') {
|
||||
setSuccessMsg(`Invitation already pending for ${inviteEmail}`);
|
||||
setInviteEmail('');
|
||||
} else {
|
||||
setError(`Failed to invite ${inviteEmail}`);
|
||||
}
|
||||
setInviting(false);
|
||||
};
|
||||
|
||||
const handleCsvFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => {
|
||||
const text = ev.target?.result as string;
|
||||
const emails = text
|
||||
.split(/[\n,;]+/)
|
||||
.map(s => s.trim().toLowerCase())
|
||||
.filter(s => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s));
|
||||
setCsvEmails([...new Set(emails)]);
|
||||
setCsvPreview(true);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// Reset file input so same file can be re-selected
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleBulkInvite = async () => {
|
||||
if (csvEmails.length === 0) return;
|
||||
setBulkRunning(true);
|
||||
setCsvTotal(csvEmails.length);
|
||||
setCsvProgress(0);
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
for (const email of csvEmails) {
|
||||
const result = await sendInvite(email);
|
||||
if (result === 'ok' || result === 'already_pending') sent++;
|
||||
else failed++;
|
||||
setCsvProgress(prev => prev + 1);
|
||||
}
|
||||
setBulkRunning(false);
|
||||
setCsvPreview(false);
|
||||
setCsvEmails([]);
|
||||
setSuccessMsg(`Bulk invite: ${sent} sent${failed > 0 ? `, ${failed} failed` : ''}`);
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string, email: string) => {
|
||||
if (!accessToken) return;
|
||||
await fetch(`${API_BASE}/users/invitations/${id}`, { method: 'DELETE', headers });
|
||||
setSuccessMsg(`Cancelled invitation for ${email}`);
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleResend = async (id: string, email: string) => {
|
||||
if (!accessToken) return;
|
||||
const res = await fetch(`${API_BASE}/users/invitations/${id}/resend`, { method: 'POST', headers });
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') setSuccessMsg(`Re-sent invitation to ${email}`);
|
||||
else setError(data.detail || 'Resend failed');
|
||||
};
|
||||
|
||||
const pendingInvitations = invitations.filter(i => i.status === 'pending');
|
||||
const otherInvitations = invitations.filter(i => i.status !== 'pending');
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Student Manager</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Invite students individually or via CSV import
|
||||
</Typography>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
{successMsg && <Alert severity="success" sx={{ mt: 2 }} onClose={() => setSuccessMsg(null)}>{successMsg}</Alert>}
|
||||
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mt: 2, mb: 2 }}>
|
||||
<Tab label="Invite" />
|
||||
<Tab label={`Invitations (${invitations.length})`} />
|
||||
<Tab label={`Students (${students.length})`} />
|
||||
</Tabs>
|
||||
|
||||
{/* ── Tab 0: Invite ─────────────────────────────────────────── */}
|
||||
{tab === 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Single invite</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end', mb: 3 }}>
|
||||
<TextField
|
||||
label="Email address"
|
||||
value={inviteEmail}
|
||||
onChange={e => setInviteEmail(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleInvite()}
|
||||
size="small"
|
||||
sx={{ flex: 1 }}
|
||||
placeholder="[email protected]"
|
||||
disabled={inviting}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={inviting ? <CircularProgress size={14} /> : <PersonAdd />}
|
||||
onClick={handleInvite}
|
||||
disabled={inviting || !inviteEmail.trim()}
|
||||
>
|
||||
Send Invite
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Bulk CSV import</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 1.5 }}>
|
||||
Upload a .csv or .txt file with one email per line (or comma/semicolon separated). Duplicate and invalid addresses are filtered automatically.
|
||||
</Typography>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv,.txt"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleCsvFile}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<UploadFile />}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={bulkRunning}
|
||||
>
|
||||
Choose File
|
||||
</Button>
|
||||
|
||||
{csvPreview && csvEmails.length > 0 && (
|
||||
<Box sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||
{csvEmails.length} valid email{csvEmails.length !== 1 ? 's' : ''} found
|
||||
</Typography>
|
||||
<Box sx={{ maxHeight: 120, overflowY: 'auto', mt: 0.5 }}>
|
||||
{csvEmails.slice(0, 20).map(e => (
|
||||
<Typography key={e} variant="caption" sx={{ display: 'block', fontFamily: 'monospace', fontSize: '0.75rem', color: 'text.secondary' }}>
|
||||
{e}
|
||||
</Typography>
|
||||
))}
|
||||
{csvEmails.length > 20 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
… and {csvEmails.length - 20} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1.5 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleBulkInvite}
|
||||
disabled={bulkRunning}
|
||||
startIcon={bulkRunning ? <CircularProgress size={14} /> : <Send />}
|
||||
>
|
||||
{bulkRunning ? `Sending… ${csvProgress}/${csvTotal}` : `Send ${csvEmails.length} Invites`}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => { setCsvEmails([]); setCsvPreview(false); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Tab 1: Invitations ────────────────────────────────────── */}
|
||||
{tab === 1 && (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle2">All Invitations</Typography>
|
||||
<Tooltip title="Refresh">
|
||||
<IconButton size="small" onClick={loadData} disabled={loading}><Refresh fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
||||
) : invitations.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No invitations yet.</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Sent</TableCell>
|
||||
<TableCell>Expires</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{invitations.map(inv => (
|
||||
<TableRow key={inv.id}>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>{inv.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={inv.status} size="small" color={STATUS_COLORS[inv.status] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
|
||||
{new Date(inv.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
|
||||
{new Date(inv.expires_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{inv.status === 'pending' && (
|
||||
<>
|
||||
<Tooltip title="Resend">
|
||||
<IconButton size="small" onClick={() => handleResend(inv.id, inv.email)}>
|
||||
<Send fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Cancel">
|
||||
<IconButton size="small" onClick={() => handleCancel(inv.id, inv.email)}>
|
||||
<Cancel fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Tab 2: Students ───────────────────────────────────────── */}
|
||||
{tab === 2 && (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle2">Enrolled Students ({students.length})</Typography>
|
||||
<Tooltip title="Refresh">
|
||||
<IconButton size="small" onClick={loadData} disabled={loading}><Refresh fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
|
||||
) : students.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No enrolled students yet.</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Joined</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{students.map(s => (
|
||||
<TableRow key={s.profile_id}>
|
||||
<TableCell sx={{ fontWeight: 500, fontSize: '0.85rem' }}>
|
||||
{s.display_name || s.username || '—'}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem', color: 'text.secondary' }}>
|
||||
{s.email || '—'}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
|
||||
{new Date(s.joined_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudentManagerPage;
|
||||
@@ -0,0 +1,512 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box, Typography, Button, CircularProgress, Alert, Chip,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
TextField, Select, MenuItem, FormControl, InputLabel,
|
||||
Divider, IconButton, Tooltip, List, ListItemButton, ListItemText,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ChevronLeft, ChevronRight, Today, EditNote, LibraryAdd, Search, OpenInNew,
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
date: string;
|
||||
period_code: string;
|
||||
period_name: string;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
class_name: string | null;
|
||||
subject: string | null;
|
||||
year_group: string | null;
|
||||
week_cycle: string;
|
||||
day_of_week: string;
|
||||
status: string;
|
||||
lesson_plan: Record<string, any>;
|
||||
notes: string | null;
|
||||
whiteboard_room_id: string | null;
|
||||
}
|
||||
|
||||
interface DayEntry {
|
||||
date: string;
|
||||
day_of_week: string;
|
||||
is_today: boolean;
|
||||
lessons: Lesson[];
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function toMonday(d: Date): string {
|
||||
const day = d.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
const monday = new Date(d);
|
||||
monday.setDate(d.getDate() + diff);
|
||||
return monday.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addWeeks(isoDate: string, n: number): string {
|
||||
const d = new Date(isoDate + 'T00:00:00');
|
||||
d.setDate(d.getDate() + n * 7);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatDate(isoDate: string): string {
|
||||
return new Date(isoDate + 'T00:00:00').toLocaleDateString('en-GB', {
|
||||
day: 'numeric', month: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
|
||||
planned: 'default',
|
||||
in_progress: 'primary',
|
||||
completed: 'success',
|
||||
cancelled: 'error',
|
||||
substituted: 'warning',
|
||||
};
|
||||
|
||||
// ─── Lesson edit dialog ───────────────────────────────────────────────────────
|
||||
|
||||
interface EditDialogProps {
|
||||
lesson: Lesson | null;
|
||||
onClose: () => void;
|
||||
onSave: (id: string, updates: { notes?: string; status?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
|
||||
const [notes, setNotes] = useState('');
|
||||
const [status, setStatus] = useState('planned');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (lesson) {
|
||||
setNotes(lesson.notes || '');
|
||||
setStatus(lesson.status || 'planned');
|
||||
}
|
||||
}, [lesson]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!lesson) return;
|
||||
setSaving(true);
|
||||
await onSave(lesson.id, { notes, status });
|
||||
setSaving(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!lesson) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={!!lesson} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="h6">
|
||||
{lesson.class_name || lesson.period_code}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{lesson.date} · {lesson.period_name}
|
||||
{lesson.start_time && ` · ${lesson.start_time}–${lesson.end_time}`}
|
||||
{lesson.week_cycle && ` · Week ${lesson.week_cycle}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select label="Status" value={status} onChange={e => setStatus(e.target.value)}>
|
||||
<MenuItem value="planned">Planned</MenuItem>
|
||||
<MenuItem value="in_progress">In Progress</MenuItem>
|
||||
<MenuItem value="completed">Completed</MenuItem>
|
||||
<MenuItem value="cancelled">Cancelled</MenuItem>
|
||||
<MenuItem value="substituted">Substituted</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Notes"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Lesson notes, objectives, reminders…"
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={saving}>
|
||||
{saving ? <CircularProgress size={18} /> : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Lesson card ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface LessonCardProps {
|
||||
lesson: Lesson;
|
||||
onEdit: (l: Lesson) => void;
|
||||
onAssignPlan: (l: Lesson) => void;
|
||||
}
|
||||
|
||||
function LessonCard({ lesson, onEdit, onAssignPlan }: LessonCardProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.25,
|
||||
mb: 0.75,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
bgcolor: lesson.status === 'completed' ? 'action.hover' : 'background.paper',
|
||||
opacity: lesson.status === 'cancelled' ? 0.5 : 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
|
||||
{lesson.class_name || lesson.period_code}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0 }}>
|
||||
<Tooltip title="Assign lesson plan">
|
||||
<IconButton size="small" onClick={() => onAssignPlan(lesson)} sx={{ p: 0.25 }}>
|
||||
<LibraryAdd fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit lesson">
|
||||
<IconButton size="small" onClick={() => onEdit(lesson)} sx={{ p: 0.25 }}>
|
||||
<EditNote fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
{lesson.subject && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
|
||||
{lesson.subject}{lesson.year_group ? ` · Y${lesson.year_group}` : ''}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
|
||||
{lesson.start_time ? `${lesson.start_time}–${lesson.end_time}` : lesson.period_name}
|
||||
</Typography>
|
||||
{lesson.week_cycle && (
|
||||
<Chip label={`W${lesson.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
|
||||
)}
|
||||
<Chip
|
||||
label={lesson.status}
|
||||
size="small"
|
||||
color={STATUS_COLORS[lesson.status] ?? 'default'}
|
||||
sx={{ height: 16, fontSize: '0.65rem' }}
|
||||
/>
|
||||
</Box>
|
||||
{lesson.notes && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem', fontStyle: 'italic', mt: 0.25 }}>
|
||||
{lesson.notes.length > 80 ? lesson.notes.slice(0, 80) + '…' : lesson.notes}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
const TaughtLessonsPage: React.FC = () => {
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [weekStart, setWeekStart] = useState<string>(toMonday(new Date()));
|
||||
const [days, setDays] = useState<DayEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [materializing, setMaterializing] = useState(false);
|
||||
const [materializeResult, setMaterializeResult] = useState<string | null>(null);
|
||||
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
|
||||
const [assigningLesson, setAssigningLesson] = useState<Lesson | null>(null);
|
||||
const [plans, setPlans] = useState<{ id: string; title: string; subject: string | null }[]>([]);
|
||||
const [planSearch, setPlanSearch] = useState('');
|
||||
const [assignSaving, setAssignSaving] = useState(false);
|
||||
const [assignResult, setAssignResult] = useState<string | null>(null);
|
||||
|
||||
const loadLessons = useCallback(async (ws: string) => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/timetable/lessons?week_start=${ws}&weeks=1`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
);
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
setDays(data.days);
|
||||
} else {
|
||||
setError(data.message || 'Failed to load lessons');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => { loadLessons(weekStart); }, [weekStart, loadLessons]);
|
||||
|
||||
const goThisWeek = () => setWeekStart(toMonday(new Date()));
|
||||
const prevWeek = () => setWeekStart(ws => addWeeks(ws, -1));
|
||||
const nextWeek = () => setWeekStart(ws => addWeeks(ws, 1));
|
||||
|
||||
const handleMaterialize = async () => {
|
||||
if (!accessToken) return;
|
||||
setMaterializing(true);
|
||||
setMaterializeResult(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/timetable/materialize`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
setMaterializeResult(`Created ${data.lessons_upserted} lessons, ${data.whiteboard_rooms_created} rooms`);
|
||||
loadLessons(weekStart);
|
||||
} else {
|
||||
setMaterializeResult(`Error: ${data.message}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setMaterializeResult(`Error: ${e.message}`);
|
||||
} finally {
|
||||
setMaterializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveLesson = async (id: string, updates: { notes?: string; status?: string }) => {
|
||||
if (!accessToken) return;
|
||||
await fetch(`${API_BASE}/timetable/lessons/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
loadLessons(weekStart);
|
||||
};
|
||||
|
||||
const openAssignDialog = async (lesson: Lesson) => {
|
||||
setAssigningLesson(lesson);
|
||||
setPlanSearch('');
|
||||
setAssignResult(null);
|
||||
if (plans.length === 0) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/lessons/plans`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) setPlans(data);
|
||||
} catch { /* silently ignore */ }
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignPlan = async (planId: string) => {
|
||||
if (!assigningLesson) return;
|
||||
setAssignSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/lessons/plans/${planId}/deliver`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ taught_lesson_id: assigningLesson.id }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.id) {
|
||||
setAssignResult('Plan linked ✓');
|
||||
setTimeout(() => { setAssigningLesson(null); setAssignResult(null); }, 1200);
|
||||
} else {
|
||||
setAssignResult(data.detail || 'Failed to link plan');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setAssignResult(e.message);
|
||||
} finally {
|
||||
setAssignSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPlans = plans.filter(p =>
|
||||
!planSearch || p.title.toLowerCase().includes(planSearch.toLowerCase())
|
||||
|| (p.subject || '').toLowerCase().includes(planSearch.toLowerCase())
|
||||
);
|
||||
|
||||
const totalLessons = days.reduce((sum, d) => sum + d.lessons.length, 0);
|
||||
|
||||
const weekLabel = days.length > 0
|
||||
? `${formatDate(days[0].date)} – ${formatDate(days[days.length - 1].date)}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1000, mx: 'auto' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>My Lessons</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{weekLabel} · {totalLessons} lesson{totalLessons !== 1 ? 's' : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={handleMaterialize}
|
||||
disabled={materializing}
|
||||
sx={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{materializing ? <CircularProgress size={14} sx={{ mr: 1 }} /> : null}
|
||||
Generate Lessons
|
||||
</Button>
|
||||
<Tooltip title="Go to this week">
|
||||
<IconButton size="small" onClick={goThisWeek}><Today fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
<IconButton size="small" onClick={prevWeek}><ChevronLeft /></IconButton>
|
||||
<IconButton size="small" onClick={nextWeek}><ChevronRight /></IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{materializeResult && (
|
||||
<Alert
|
||||
severity={materializeResult.startsWith('Error') ? 'error' : 'success'}
|
||||
onClose={() => setMaterializeResult(null)}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{materializeResult}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{/* Week grid */}
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 1.5 }}>
|
||||
{days.map(day => (
|
||||
<Box key={day.date}>
|
||||
{/* Day header */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
mb: 0.75,
|
||||
borderRadius: 1,
|
||||
bgcolor: day.is_today ? 'primary.main' : 'action.hover',
|
||||
color: day.is_today ? 'primary.contrastText' : 'text.primary',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
|
||||
{day.day_of_week.slice(0, 3)}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', opacity: 0.85 }}>
|
||||
{formatDate(day.date)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Lesson cards */}
|
||||
{day.lessons.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.7rem', px: 0.5 }}>
|
||||
No lessons
|
||||
</Typography>
|
||||
) : (
|
||||
day.lessons.map(lesson => (
|
||||
<LessonCard
|
||||
key={lesson.id}
|
||||
lesson={lesson}
|
||||
onEdit={setEditingLesson}
|
||||
onAssignPlan={openAssignDialog}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<LessonEditDialog
|
||||
lesson={editingLesson}
|
||||
onClose={() => setEditingLesson(null)}
|
||||
onSave={handleSaveLesson}
|
||||
/>
|
||||
|
||||
{/* Assign Plan dialog */}
|
||||
<Dialog open={!!assigningLesson} onClose={() => setAssigningLesson(null)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Typography variant="h6">Assign Lesson Plan</Typography>
|
||||
{assigningLesson && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{assigningLesson.class_name || assigningLesson.period_code} · {assigningLesson.date}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ pt: 1 }}>
|
||||
{assignResult ? (
|
||||
<Alert severity={assignResult.includes('✓') ? 'success' : 'error'} sx={{ mb: 1 }}>
|
||||
{assignResult}
|
||||
</Alert>
|
||||
) : null}
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Search plans…"
|
||||
value={planSearch}
|
||||
onChange={e => setPlanSearch(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: <InputAdornment position="start"><Search fontSize="small" /></InputAdornment>,
|
||||
}}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
{plans.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
No lesson plans yet.{' '}
|
||||
<Button size="small" endIcon={<OpenInNew fontSize="small" />}
|
||||
onClick={() => { setAssigningLesson(null); navigate('/lesson-plans'); }}>
|
||||
Create one
|
||||
</Button>
|
||||
</Typography>
|
||||
) : (
|
||||
<List dense disablePadding sx={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
{filteredPlans.map(p => (
|
||||
<ListItemButton
|
||||
key={p.id}
|
||||
onClick={() => handleAssignPlan(p.id)}
|
||||
disabled={assignSaving}
|
||||
sx={{ borderRadius: 1, mb: 0.25 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={p.title}
|
||||
secondary={p.subject || undefined}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
{assignSaving && <CircularProgress size={16} sx={{ ml: 1 }} />}
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setAssigningLesson(null)}>Close</Button>
|
||||
<Button size="small" endIcon={<OpenInNew fontSize="small" />}
|
||||
onClick={() => { setAssigningLesson(null); navigate('/lesson-plans'); }}>
|
||||
Manage Plans
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaughtLessonsPage;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CalendarToday, KeyboardArrowRight } from '@mui/icons-material';
|
||||
import useTimetableStore from '../../stores/timetableStore';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
const TimetableListPage: React.FC = () => {
|
||||
const { timetables, timetablesLoading, timetablesError, fetchTimetables } = useTimetableStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchTimetables();
|
||||
}, [fetchTimetables]);
|
||||
|
||||
if (timetablesLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Treat fetch errors (including 404 — endpoint not yet available) as empty state
|
||||
const showEmpty = timetablesError || timetables.length === 0;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 max-w-4xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Timetables</h1>
|
||||
<p className="mt-1 text-gray-500">Your scheduled timetables</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEmpty ? (
|
||||
<div className="text-center py-16">
|
||||
<CalendarToday sx={{ fontSize: 48 }} className="mx-auto mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium text-gray-900">No timetables yet</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
Timetables are created from a class. Go to{' '}
|
||||
<Link to="/classes" className="text-blue-600 hover:underline">Classes</Link>{' '}
|
||||
to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{timetables.map((timetable) => (
|
||||
<Link
|
||||
key={timetable.id}
|
||||
to={`/timetable/${timetable.id}`}
|
||||
className="flex items-center gap-4 p-4 bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<CalendarToday className="text-blue-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{timetable.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{format(parseISO(timetable.start_date), 'MMM d, yyyy')}
|
||||
{timetable.end_date
|
||||
? ` – ${format(parseISO(timetable.end_date), 'MMM d, yyyy')}`
|
||||
: ' (ongoing)'}
|
||||
</p>
|
||||
</div>
|
||||
{timetable.recurrence_rule && (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full flex-shrink-0">
|
||||
Recurring
|
||||
</span>
|
||||
)}
|
||||
<KeyboardArrowRight className="text-gray-400 flex-shrink-0" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimetableListPage;
|
||||
@@ -1,19 +1,20 @@
|
||||
/**
|
||||
* Timetable Module - Page Exports
|
||||
*
|
||||
* This barrel file exports all pages related to the timetable/classroom management system.
|
||||
*
|
||||
* @module pages/timetable
|
||||
*/
|
||||
|
||||
// Main pages
|
||||
export { default as TimetablePage } from './TimetablePage';
|
||||
export { default as TimetableListPage } from './TimetableListPage';
|
||||
export { default as ClassesPage } from './ClassesPage';
|
||||
export { default as LessonPage } from './LessonPage';
|
||||
|
||||
// Class management pages
|
||||
export { default as TaughtLessonsPage } from './TaughtLessonsPage';
|
||||
export { default as MyClassesPage } from './MyClassesPage';
|
||||
export { default as EnrollmentRequestsPage } from './EnrollmentRequestsPage';
|
||||
export { default as StaffManagerPage } from './StaffManagerPage';
|
||||
export { default as StudentManagerPage } from './StudentManagerPage';
|
||||
export { default as SchoolSettingsPage } from './SchoolSettingsPage';
|
||||
export { default as ClassDetailPage } from './ClassDetailPage';
|
||||
export { default as StudentLessonsPage } from './StudentLessonsPage';
|
||||
|
||||
// Re-export types if needed
|
||||
export type { ClassView, DaySchedule, WeekSchedule } from './TimetablePage';
|
||||
export { default as LessonPlansPage } from './LessonPlansPage';
|
||||
export { default as LessonPlanDetailPage } from './LessonPlanDetailPage';
|
||||
|
||||
@@ -50,8 +50,8 @@ export const CCBundleViewer: React.FC<{
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
|
||||
const API_BASE_FALLBACK = 'http://127.0.0.1:8080';
|
||||
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || '/api', []);
|
||||
const API_BASE_FALLBACK = '/api';
|
||||
|
||||
const proxyUrl = useCallback(async (bucket: string, relPath: string) => {
|
||||
const token = accessToken || '';
|
||||
|
||||
@@ -182,7 +182,7 @@ export const CCDoclingViewer: React.FC<{
|
||||
setError(null);
|
||||
try {
|
||||
// Try page-images manifest first
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||
try {
|
||||
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
|
||||
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||
@@ -199,7 +199,7 @@ export const CCDoclingViewer: React.FC<{
|
||||
}
|
||||
|
||||
// Legacy: Load artefacts for file to find docling JSON artefacts
|
||||
const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api')}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
|
||||
const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || '/api'}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
|
||||
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||
});
|
||||
if (!artefactsRes.ok) throw new Error(await artefactsRes.text());
|
||||
@@ -239,7 +239,7 @@ export const CCDoclingViewer: React.FC<{
|
||||
run();
|
||||
}, [fileId]); /* eslint-disable-line react-hooks/exhaustive-deps */
|
||||
|
||||
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
|
||||
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || '/api', []);
|
||||
|
||||
const pageProxyUrl = useMemo(() => {
|
||||
if (!manifest) return undefined;
|
||||
|
||||
@@ -148,7 +148,7 @@ export const CCDocumentIntelligence: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const loadBundles = async () => {
|
||||
if (!validFileId) return;
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||
const token = accessToken || '';
|
||||
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) return;
|
||||
@@ -223,7 +223,7 @@ export const CCDocumentIntelligence: React.FC = () => {
|
||||
const run = async () => {
|
||||
if (!validFileId) return;
|
||||
setOutlineOptions([]);
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||
try {
|
||||
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, {
|
||||
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
|
||||
@@ -486,7 +486,7 @@ export const CCDocumentIntelligence: React.FC = () => {
|
||||
<Button variant="contained" disabled={busy || !validFileId} onClick={async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||
const token = accessToken || '';
|
||||
const body: CanonicalDoclingRequest = {
|
||||
use_split_map: selectedSectionId === 'full' ? autoSplit : false,
|
||||
|
||||
@@ -84,7 +84,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
|
||||
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const API_BASE = useMemo(() =>
|
||||
import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'),
|
||||
import.meta.env.VITE_API_BASE || '/api',
|
||||
[]
|
||||
);
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export const CCFileDetailPanel: React.FC<{
|
||||
const [showAdmin, setShowAdmin] = useState(false);
|
||||
const [adminData, setAdminData] = useState<FileTasksResponse | null>(null);
|
||||
|
||||
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
|
||||
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || '/api', []);
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { TLShapeId } from '@tldraw/tldraw';
|
||||
|
||||
export interface AnnotationData {
|
||||
studentIndex?: number; // undefined for exam/markscheme annotations
|
||||
pageIndex: number;
|
||||
shapeId: TLShapeId;
|
||||
bounds: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class AnnotationManager {
|
||||
private examAnnotations: Set<TLShapeId> = new Set();
|
||||
private markSchemeAnnotations: Set<TLShapeId> = new Set();
|
||||
private studentAnnotations: Map<number, Set<TLShapeId>> = new Map();
|
||||
private annotationData: Map<TLShapeId, AnnotationData> = new Map();
|
||||
|
||||
addAnnotation(shapeId: TLShapeId, data: AnnotationData) {
|
||||
this.annotationData.set(shapeId, data);
|
||||
|
||||
if (data.studentIndex !== undefined) {
|
||||
// Student response annotation
|
||||
let studentSet = this.studentAnnotations.get(data.studentIndex);
|
||||
if (!studentSet) {
|
||||
studentSet = new Set();
|
||||
this.studentAnnotations.set(data.studentIndex, studentSet);
|
||||
}
|
||||
studentSet.add(shapeId);
|
||||
} else {
|
||||
// Exam or mark scheme annotation
|
||||
if (data.pageIndex < 0) {
|
||||
this.examAnnotations.add(shapeId);
|
||||
} else {
|
||||
this.markSchemeAnnotations.add(shapeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeAnnotation(shapeId: TLShapeId) {
|
||||
const data = this.annotationData.get(shapeId);
|
||||
if (!data) return;
|
||||
|
||||
if (data.studentIndex !== undefined) {
|
||||
const studentSet = this.studentAnnotations.get(data.studentIndex);
|
||||
studentSet?.delete(shapeId);
|
||||
} else {
|
||||
if (data.pageIndex < 0) {
|
||||
this.examAnnotations.delete(shapeId);
|
||||
} else {
|
||||
this.markSchemeAnnotations.delete(shapeId);
|
||||
}
|
||||
}
|
||||
this.annotationData.delete(shapeId);
|
||||
}
|
||||
|
||||
getAnnotationsForStudent(studentIndex: number): TLShapeId[] {
|
||||
return Array.from(this.studentAnnotations.get(studentIndex) || []);
|
||||
}
|
||||
|
||||
getAnnotationsForExam(): TLShapeId[] {
|
||||
return Array.from(this.examAnnotations);
|
||||
}
|
||||
|
||||
getAnnotationsForMarkScheme(): TLShapeId[] {
|
||||
return Array.from(this.markSchemeAnnotations);
|
||||
}
|
||||
|
||||
getAnnotationData(shapeId: TLShapeId): AnnotationData | undefined {
|
||||
return this.annotationData.get(shapeId);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.examAnnotations.clear();
|
||||
this.markSchemeAnnotations.clear();
|
||||
this.studentAnnotations.clear();
|
||||
this.annotationData.clear();
|
||||
}
|
||||
|
||||
// Future transcription support
|
||||
addTranscriptionToAnnotation(shapeId: TLShapeId) {
|
||||
const data = this.annotationData.get(shapeId);
|
||||
if (data) {
|
||||
this.annotationData.set(shapeId, {
|
||||
...data
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import 'tldraw/tldraw.css';
|
||||
import { CCPdfEditor } from './CCPdfEditor';
|
||||
import { CCPdfPicker } from './CCPdfPicker';
|
||||
import { ExamPdfState } from './types';
|
||||
import './cc-exam-marker.css';
|
||||
import { HEADER_HEIGHT } from '../../Layout';
|
||||
import { CCPanel } from '../../../utils/tldraw/ui-overrides/components/CCPanel';
|
||||
|
||||
export const CCExamMarker = () => {
|
||||
const [state, setState] = useState<ExamPdfState>({ phase: 'pick' });
|
||||
const [view, setView] = useState<'exam-and-markscheme' | 'student-responses'>('exam-and-markscheme');
|
||||
const [currentStudentIndex, setCurrentStudentIndex] = useState(0);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
|
||||
const handleViewChange = (newView: 'exam-and-markscheme' | 'student-responses') => {
|
||||
setView(newView);
|
||||
};
|
||||
|
||||
const handleNextStudent = () => {
|
||||
if (state.phase === 'edit' && 'studentResponses' in state && 'examPaper' in state) {
|
||||
const totalStudents = Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length);
|
||||
if (currentStudentIndex < totalStudents - 1) {
|
||||
setCurrentStudentIndex(prev => prev + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousStudent = () => {
|
||||
if (currentStudentIndex > 0) {
|
||||
setCurrentStudentIndex(prev => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
bgcolor: 'background.default',
|
||||
color: 'text.primary',
|
||||
}}>
|
||||
{state.phase === 'pick' ? (
|
||||
<CCPdfPicker
|
||||
onOpenPdfs={(pdfs) =>
|
||||
setState({
|
||||
phase: 'edit',
|
||||
examPaper: pdfs.examPaper,
|
||||
markScheme: pdfs.markScheme,
|
||||
studentResponses: pdfs.studentResponses,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ flex: 1, position: 'relative' }}>
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
bgcolor: 'background.paper',
|
||||
}}>
|
||||
<CCPdfEditor
|
||||
examPaper={state.examPaper}
|
||||
markScheme={state.markScheme}
|
||||
studentResponses={state.studentResponses}
|
||||
currentView={view}
|
||||
currentStudentIndex={currentStudentIndex}
|
||||
onEditorMount={(editor) => {
|
||||
if (!editor) return null;
|
||||
const examMarkerProps = {
|
||||
editor,
|
||||
currentView: view,
|
||||
onViewChange: handleViewChange,
|
||||
currentStudentIndex,
|
||||
totalStudents: Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length),
|
||||
onPreviousStudent: handlePreviousStudent,
|
||||
onNextStudent: handleNextStudent,
|
||||
getCurrentPdf: () => {
|
||||
if (!editor) return null;
|
||||
const currentPageId = editor.getCurrentPageId();
|
||||
if (currentPageId.includes('exam-page')) {
|
||||
return state.examPaper;
|
||||
} else if (currentPageId.includes('mark-scheme-page')) {
|
||||
return state.markScheme;
|
||||
} else if (currentPageId.includes('student-response')) {
|
||||
return state.studentResponses;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
return <CCPanel
|
||||
examMarkerProps={examMarkerProps}
|
||||
isExpanded={isExpanded}
|
||||
isPinned={isPinned}
|
||||
onExpandedChange={setIsExpanded}
|
||||
onPinnedChange={setIsPinned}
|
||||
/>;
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,113 +0,0 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { useState } from 'react';
|
||||
import { Editor, exportToBlob } from '@tldraw/tldraw';
|
||||
import { Button } from '@mui/material';
|
||||
import { Pdf } from './types';
|
||||
|
||||
interface CCExportPdfButtonProps {
|
||||
editor: Editor;
|
||||
pdf: Pdf;
|
||||
}
|
||||
|
||||
export function CCExportPdfButton({ editor, pdf }: CCExportPdfButtonProps) {
|
||||
const [exportProgress, setExportProgress] = useState<number | null>(null);
|
||||
|
||||
const exportPdf = async (
|
||||
editor: Editor,
|
||||
{ name, source, pages }: Pdf,
|
||||
onProgress: (progress: number) => void
|
||||
) => {
|
||||
const totalThings = pages.length * 2 + 2;
|
||||
let progressCount = 0;
|
||||
const tickProgress = () => {
|
||||
progressCount++;
|
||||
onProgress(progressCount / totalThings);
|
||||
};
|
||||
|
||||
const pdf = await PDFDocument.load(source);
|
||||
tickProgress();
|
||||
const pdfPages = pdf.getPages();
|
||||
|
||||
if (pdfPages.length !== pages.length) {
|
||||
throw new Error('PDF page count mismatch');
|
||||
}
|
||||
|
||||
const pageShapeIds = new Set(pages.map((page) => page.shapeId));
|
||||
const allIds = Array.from(editor.getCurrentPageShapeIds()).filter(
|
||||
(id) => !pageShapeIds.has(id)
|
||||
);
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const page = pages[i];
|
||||
const pdfPage = pdfPages[i];
|
||||
const {bounds} = page;
|
||||
|
||||
const shapesInBounds = allIds.filter((id) => {
|
||||
const shapePageBounds = editor.getShapePageBounds(id);
|
||||
if (!shapePageBounds) return false;
|
||||
return shapePageBounds.collides(bounds);
|
||||
});
|
||||
|
||||
if (shapesInBounds.length === 0) {
|
||||
tickProgress();
|
||||
tickProgress();
|
||||
continue;
|
||||
}
|
||||
|
||||
const exportedPng = await exportToBlob({
|
||||
editor,
|
||||
ids: allIds,
|
||||
format: 'png',
|
||||
opts: { background: false, bounds: page.bounds, padding: 0, scale: 1 },
|
||||
});
|
||||
|
||||
tickProgress();
|
||||
|
||||
pdfPage.drawImage(await pdf.embedPng(await exportedPng.arrayBuffer()), {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pdfPage.getWidth(),
|
||||
height: pdfPage.getHeight(),
|
||||
});
|
||||
|
||||
tickProgress();
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([await pdf.save()], { type: 'application/pdf' })
|
||||
);
|
||||
tickProgress();
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="CCExportPdfButton"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
setExportProgress(0);
|
||||
try {
|
||||
await exportPdf(editor, pdf, setExportProgress);
|
||||
} finally {
|
||||
setExportProgress(null);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{exportProgress
|
||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
||||
: 'Export PDF'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { Editor, TLPageId, Box as TLBox } from '@tldraw/editor';
|
||||
import { Tldraw } from '@tldraw/tldraw';
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { ExamPdfs } from './types';
|
||||
import { AnnotationManager, AnnotationData } from './AnnotationManager';
|
||||
import { logger } from '../../../debugConfig';
|
||||
|
||||
const PAGE_SPACING = 32; // Same spacing as the example
|
||||
|
||||
interface CCPdfEditorProps extends ExamPdfs {
|
||||
currentView: 'exam-and-markscheme' | 'student-responses';
|
||||
currentStudentIndex: number;
|
||||
onEditorMount: (editor: Editor) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function CCPdfEditor({
|
||||
examPaper,
|
||||
markScheme,
|
||||
studentResponses,
|
||||
currentView,
|
||||
currentStudentIndex,
|
||||
onEditorMount,
|
||||
}: CCPdfEditorProps) {
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
const [pagesInitialized, setPagesInitialized] = useState(false);
|
||||
const annotationManager = useRef(new AnnotationManager());
|
||||
|
||||
const handleMount = useCallback((editor: Editor) => {
|
||||
setEditor(editor);
|
||||
onEditorMount(editor);
|
||||
|
||||
// Subscribe to shape changes
|
||||
editor.on('change', () => {
|
||||
const shapes = editor.getCurrentPageShapeIds();
|
||||
logger.debug('cc-exam-marker', '🔄 Shape change detected', {
|
||||
totalShapes: shapes.size,
|
||||
currentPage: editor.getCurrentPageId()
|
||||
});
|
||||
|
||||
shapes.forEach(shapeId => {
|
||||
const shape = editor.getShape(shapeId);
|
||||
if (shape && !shape.isLocked) { // Only track non-locked shapes (annotations)
|
||||
const bounds = editor.getShapePageBounds(shapeId);
|
||||
if (bounds) {
|
||||
const currentPageId = editor.getCurrentPageId();
|
||||
let annotationData: AnnotationData;
|
||||
|
||||
if (currentPageId.includes('student-response')) {
|
||||
const studentIndex = parseInt(currentPageId.split('-').pop() || '0', 10);
|
||||
|
||||
// Find which page this annotation belongs to by checking collision with page bounds
|
||||
const pageShapes = Array.from(shapes).filter(id => {
|
||||
const s = editor.getShape(id);
|
||||
return s?.isLocked; // Locked shapes are our PDF pages
|
||||
});
|
||||
|
||||
let pageIndex = -1; // Default to -1 if no collision found
|
||||
for (let i = 0; i < pageShapes.length; i++) {
|
||||
const pageShape = editor.getShape(pageShapes[i]);
|
||||
if (!pageShape) continue;
|
||||
|
||||
const pageBounds = editor.getShapePageBounds(pageShapes[i]);
|
||||
if (!pageBounds) continue;
|
||||
|
||||
// Check if the annotation's center point is within the page bounds
|
||||
const annotationCenter = {
|
||||
x: bounds.x + bounds.width / 2,
|
||||
y: bounds.y + bounds.height / 2
|
||||
};
|
||||
|
||||
if (annotationCenter.x >= pageBounds.x &&
|
||||
annotationCenter.x <= pageBounds.x + pageBounds.width &&
|
||||
annotationCenter.y >= pageBounds.y &&
|
||||
annotationCenter.y <= pageBounds.y + pageBounds.height) {
|
||||
pageIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('cc-exam-marker', '📏 Calculated page index', {
|
||||
shapeId,
|
||||
shapeBounds: bounds,
|
||||
pageIndex,
|
||||
studentIndex
|
||||
});
|
||||
|
||||
annotationData = {
|
||||
studentIndex,
|
||||
pageIndex,
|
||||
shapeId,
|
||||
bounds: {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// For exam/mark scheme, use current page type as index
|
||||
const pageIndex = currentPageId.includes('exam') ? -1 : 1;
|
||||
annotationData = {
|
||||
pageIndex,
|
||||
shapeId,
|
||||
bounds: {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug('cc-exam-marker', '📝 Adding/updating annotation', {
|
||||
shapeId,
|
||||
annotationData,
|
||||
currentPage: currentPageId
|
||||
});
|
||||
|
||||
annotationManager.current.addAnnotation(shapeId, annotationData);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [onEditorMount]);
|
||||
|
||||
// Initial setup effect - runs only once when editor is mounted
|
||||
useEffect(() => {
|
||||
if (!editor || pagesInitialized) return;
|
||||
|
||||
const setupExamAndMarkScheme = async () => {
|
||||
const examPageId = 'page:exam-page' as TLPageId;
|
||||
const markSchemePageId = 'page:mark-scheme-page' as TLPageId;
|
||||
|
||||
// Calculate vertical layout for exam pages
|
||||
let top = 0;
|
||||
let widest = 0;
|
||||
const examPages = examPaper.pages.map(page => {
|
||||
const width = page.bounds.width;
|
||||
const height = page.bounds.height;
|
||||
const currentTop = top;
|
||||
top += height + PAGE_SPACING;
|
||||
widest = Math.max(widest, width);
|
||||
return { ...page, top: currentTop, width, height };
|
||||
});
|
||||
|
||||
// Center pages horizontally
|
||||
examPages.forEach(page => {
|
||||
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
|
||||
});
|
||||
|
||||
// Create exam paper page
|
||||
editor.createPage({
|
||||
id: examPageId,
|
||||
name: 'Exam Paper',
|
||||
});
|
||||
editor.setCurrentPage(examPageId);
|
||||
|
||||
// Create assets and shapes for exam pages
|
||||
examPages.forEach((page) => {
|
||||
editor.createAssets([{
|
||||
id: page.assetId,
|
||||
typeName: 'asset',
|
||||
type: 'image',
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
name: 'PDF Page',
|
||||
src: page.src,
|
||||
isAnimated: false,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
meta: {},
|
||||
}]);
|
||||
|
||||
editor.createShape({
|
||||
id: page.shapeId,
|
||||
type: 'image',
|
||||
x: page.bounds.x,
|
||||
y: page.bounds.y,
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
assetId: page.assetId,
|
||||
},
|
||||
isLocked: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Similar process for mark scheme pages
|
||||
let markSchemeTop = 0;
|
||||
const markSchemePages = markScheme.pages.map(page => {
|
||||
const width = page.bounds.width;
|
||||
const height = page.bounds.height;
|
||||
const currentTop = markSchemeTop;
|
||||
markSchemeTop += height + PAGE_SPACING;
|
||||
return {
|
||||
...page,
|
||||
bounds: new TLBox((widest - width) / 2, currentTop, width, height)
|
||||
};
|
||||
});
|
||||
|
||||
// Create mark scheme page
|
||||
editor.createPage({
|
||||
id: markSchemePageId,
|
||||
name: 'Mark Scheme',
|
||||
});
|
||||
editor.setCurrentPage(markSchemePageId);
|
||||
|
||||
// Create assets and shapes for mark scheme pages
|
||||
markSchemePages.forEach((page) => {
|
||||
editor.createAssets([{
|
||||
id: page.assetId,
|
||||
typeName: 'asset',
|
||||
type: 'image',
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
name: 'PDF Page',
|
||||
src: page.src,
|
||||
isAnimated: false,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
meta: {},
|
||||
}]);
|
||||
|
||||
editor.createShape({
|
||||
id: page.shapeId,
|
||||
type: 'image',
|
||||
x: page.bounds.x,
|
||||
y: page.bounds.y,
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
assetId: page.assetId,
|
||||
},
|
||||
isLocked: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Go back to exam page
|
||||
editor.setCurrentPage(examPageId);
|
||||
};
|
||||
|
||||
const setupStudentResponses = async () => {
|
||||
const pagesPerStudent = examPaper.pages.length;
|
||||
const totalStudents = Math.floor(studentResponses.pages.length / pagesPerStudent);
|
||||
|
||||
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
|
||||
const startPage = studentIndex * pagesPerStudent;
|
||||
const endPage = startPage + pagesPerStudent;
|
||||
const studentPageId = `page:student-response-${studentIndex}` as TLPageId;
|
||||
|
||||
// Calculate vertical layout
|
||||
let top = 0;
|
||||
let widest = 0;
|
||||
const studentPages = studentResponses.pages
|
||||
.slice(startPage, endPage)
|
||||
.map(page => {
|
||||
const width = page.bounds.width;
|
||||
const height = page.bounds.height;
|
||||
const currentTop = top;
|
||||
top += height + PAGE_SPACING;
|
||||
widest = Math.max(widest, width);
|
||||
return { ...page, top: currentTop, width, height };
|
||||
});
|
||||
|
||||
// Center pages horizontally
|
||||
studentPages.forEach(page => {
|
||||
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
|
||||
});
|
||||
|
||||
// Create page for this student
|
||||
editor.createPage({
|
||||
id: studentPageId,
|
||||
name: `Student ${studentIndex + 1}`,
|
||||
});
|
||||
editor.setCurrentPage(studentPageId);
|
||||
|
||||
// Create assets and shapes
|
||||
studentPages.forEach((page) => {
|
||||
editor.createAssets([{
|
||||
id: page.assetId,
|
||||
typeName: 'asset',
|
||||
type: 'image',
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
name: 'PDF Page',
|
||||
src: page.src,
|
||||
isAnimated: false,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
meta: {},
|
||||
}]);
|
||||
|
||||
editor.createShape({
|
||||
id: page.shapeId,
|
||||
type: 'image',
|
||||
x: page.bounds.x,
|
||||
y: page.bounds.y,
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
assetId: page.assetId,
|
||||
},
|
||||
isLocked: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initial setup of all pages
|
||||
const setup = async () => {
|
||||
await setupExamAndMarkScheme();
|
||||
await setupStudentResponses();
|
||||
setPagesInitialized(true);
|
||||
};
|
||||
|
||||
setup();
|
||||
}, [editor, pagesInitialized, examPaper, markScheme, studentResponses]);
|
||||
|
||||
// Effect to handle view changes and navigation
|
||||
useEffect(() => {
|
||||
if (!editor || !pagesInitialized) return;
|
||||
|
||||
// Switch to appropriate page based on current view
|
||||
const targetPageId = currentView === 'exam-and-markscheme'
|
||||
? ('page:exam-page' as TLPageId)
|
||||
: (`page:student-response-${currentStudentIndex}` as TLPageId);
|
||||
|
||||
logger.debug('cc-exam-marker', '🔄 Switching view', {
|
||||
currentView,
|
||||
currentStudentIndex,
|
||||
targetPageId
|
||||
});
|
||||
|
||||
editor.setCurrentPage(targetPageId);
|
||||
|
||||
// Update camera constraints for current page
|
||||
const currentPageBounds = Array.from(editor.getCurrentPageShapeIds()).reduce(
|
||||
(acc: TLBox | null, shapeId) => {
|
||||
const bounds = editor.getShapePageBounds(shapeId);
|
||||
return bounds ? (acc ? acc.union(bounds) : bounds) : acc;
|
||||
},
|
||||
null as TLBox | null
|
||||
);
|
||||
|
||||
if (currentPageBounds) {
|
||||
const isMobile = editor.getViewportScreenBounds().width < 840;
|
||||
editor.setCameraOptions({
|
||||
constraints: {
|
||||
bounds: currentPageBounds,
|
||||
padding: { x: isMobile ? 16 : 164, y: 64 },
|
||||
origin: { x: 0.5, y: 0 },
|
||||
initialZoom: 'fit-x-100',
|
||||
baseZoom: 'default',
|
||||
behavior: 'contain',
|
||||
},
|
||||
});
|
||||
editor.setCamera(editor.getCamera(), { reset: true });
|
||||
}
|
||||
}, [editor, pagesInitialized, currentView, currentStudentIndex]);
|
||||
|
||||
// Expose annotationManager to parent through onEditorMount
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
onEditorMount(editor);
|
||||
// @ts-expect-error - Adding custom property to editor for CCExamMarkerPanel access
|
||||
editor.annotationManager = annotationManager.current;
|
||||
}
|
||||
}, [editor, onEditorMount]);
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<Tldraw
|
||||
onMount={handleMount}
|
||||
components={{
|
||||
InFrontOfTheCanvas: () => onEditorMount(editor!)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||
import { AssetRecordType, Box as TLBox, createShapeId } from '@tldraw/editor';
|
||||
import { ExamPdfs, Pdf, PdfPage } from './types';
|
||||
|
||||
interface CCPdfPickerProps {
|
||||
onOpenPdfs: (pdfs: ExamPdfs) => void;
|
||||
}
|
||||
|
||||
const pageSpacing = 32;
|
||||
|
||||
export function CCPdfPicker({ onOpenPdfs }: CCPdfPickerProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedPdfs, setSelectedPdfs] = useState<Partial<ExamPdfs>>({});
|
||||
|
||||
async function loadPdf(name: string, source: ArrayBuffer): Promise<Pdf> {
|
||||
const PdfJS = await import('pdfjs-dist');
|
||||
PdfJS.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
const pdf = await PdfJS.getDocument(source.slice()).promise;
|
||||
const pages: PdfPage[] = [];
|
||||
const canvas = window.document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Failed to create canvas context');
|
||||
|
||||
const visualScale = 1.5;
|
||||
const scale = window.devicePixelRatio;
|
||||
let top = 0;
|
||||
let widest = 0;
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale: scale * visualScale });
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
};
|
||||
|
||||
await page.render(renderContext).promise;
|
||||
const width = viewport.width / scale;
|
||||
const height = viewport.height / scale;
|
||||
|
||||
pages.push({
|
||||
src: canvas.toDataURL(),
|
||||
bounds: new TLBox(0, top, width, height),
|
||||
assetId: AssetRecordType.createId(),
|
||||
shapeId: createShapeId(),
|
||||
});
|
||||
|
||||
top += height + pageSpacing;
|
||||
widest = Math.max(widest, width);
|
||||
}
|
||||
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
|
||||
for (const page of pages) {
|
||||
page.bounds.x = (widest - page.bounds.width) / 2;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
pages,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
const handleFileSelect = async (type: keyof ExamPdfs, file: File) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const pdf = await loadPdf(file.name, await file.arrayBuffer());
|
||||
|
||||
// Validate student responses page count
|
||||
if (type === 'studentResponses' && selectedPdfs.examPaper) {
|
||||
const examPageCount = selectedPdfs.examPaper.pages.length;
|
||||
if (pdf.pages.length % examPageCount !== 0) {
|
||||
alert(`Student responses PDF must have a number of pages that is a multiple of the exam paper's ${examPageCount} pages.\n\nStudent responses PDF has ${pdf.pages.length} pages, which is not a multiple of ${examPageCount}.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedPdfs((prev) => ({ ...prev, [type]: pdf }));
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
alert('Error loading PDF (mismatch between responses and exam paper). Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createFileInput = (type: keyof ExamPdfs) => {
|
||||
const input = window.document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'application/pdf';
|
||||
input.addEventListener('change', async (e) => {
|
||||
const fileList = (e.target as HTMLInputElement).files;
|
||||
if (!fileList || fileList.length === 0) return;
|
||||
await handleFileSelect(type, fileList[0]);
|
||||
});
|
||||
input.click();
|
||||
};
|
||||
|
||||
const allPdfsSelected = () => {
|
||||
return (
|
||||
selectedPdfs.examPaper &&
|
||||
selectedPdfs.markScheme &&
|
||||
selectedPdfs.studentResponses
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box className="CCPdfPicker" sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}>
|
||||
<Typography>Loading...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="CCPdfPicker" sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}>
|
||||
<Stack
|
||||
spacing={4}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
p: 3
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5">Select PDF Files</Typography>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
gap: 4 // Using MUI's spacing unit (1 unit = 8px, so 4 = 32px)
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant={selectedPdfs.examPaper ? 'contained' : 'outlined'}
|
||||
onClick={() => createFileInput('examPaper')}
|
||||
sx={{
|
||||
minWidth: '180px',
|
||||
height: '48px'
|
||||
}}
|
||||
>
|
||||
{selectedPdfs.examPaper ? '✓ Exam Paper' : 'Select Exam Paper'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={selectedPdfs.markScheme ? 'contained' : 'outlined'}
|
||||
onClick={() => createFileInput('markScheme')}
|
||||
sx={{
|
||||
minWidth: '180px',
|
||||
height: '48px'
|
||||
}}
|
||||
>
|
||||
{selectedPdfs.markScheme ? '✓ Mark Scheme' : 'Select Mark Scheme'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={selectedPdfs.studentResponses ? 'contained' : 'outlined'}
|
||||
onClick={() => createFileInput('studentResponses')}
|
||||
sx={{
|
||||
minWidth: '180px',
|
||||
height: '48px'
|
||||
}}
|
||||
>
|
||||
{selectedPdfs.studentResponses ? '✓ Student Responses' : 'Select Student Responses'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{allPdfsSelected() && (
|
||||
<Box sx={{ mt: 4, width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => onOpenPdfs(selectedPdfs as ExamPdfs)}
|
||||
sx={{
|
||||
minWidth: '180px',
|
||||
height: '48px'
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
.CCExamMarker {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.CCExamMarker .CCPdfPicker {
|
||||
position: absolute;
|
||||
inset: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.CCExamMarker .CCPdfBgRenderer {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.CCExamMarker .CCPdfBgRenderer img {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.CCExamMarker .PageOverlayScreen-screen {
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
fill: var(--color-background);
|
||||
fill-opacity: 0.8;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.CCExamMarker .PageOverlayScreen-outline {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.CCExamMarker .CCExportPdfButton {
|
||||
font: inherit;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
color: var(--color-selected-contrast);
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin: 6px;
|
||||
margin-bottom: 0;
|
||||
pointer-events: all;
|
||||
z-index: var(--layer-panels);
|
||||
border: 2px solid var(--color-background);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.CCExamMarker .CCExportPdfButton:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Box, TLAssetId, TLShapeId } from '@tldraw/tldraw';
|
||||
|
||||
export interface PdfPage {
|
||||
src: string;
|
||||
bounds: Box;
|
||||
assetId: TLAssetId;
|
||||
shapeId: TLShapeId;
|
||||
}
|
||||
|
||||
export interface Pdf {
|
||||
name: string;
|
||||
pages: PdfPage[];
|
||||
source: string | ArrayBuffer;
|
||||
}
|
||||
|
||||
export interface ExamPdfs {
|
||||
examPaper: Pdf;
|
||||
markScheme: Pdf;
|
||||
studentResponses: Pdf;
|
||||
}
|
||||
|
||||
export type ExamPdfState =
|
||||
| {
|
||||
phase: 'pick';
|
||||
}
|
||||
| {
|
||||
phase: 'edit';
|
||||
examPaper: Pdf;
|
||||
markScheme: Pdf;
|
||||
studentResponses: Pdf;
|
||||
};
|
||||
|
||||
export interface StudentResponse {
|
||||
studentId: string;
|
||||
pageStart: number;
|
||||
pageEnd: number;
|
||||
}
|
||||
|
||||
export interface ExamMetadata {
|
||||
totalPages: number;
|
||||
pagesPerStudent: number;
|
||||
totalStudents: number;
|
||||
studentResponses: StudentResponse[];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { useEffect, useRef, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Tldraw,
|
||||
@@ -26,39 +26,117 @@ import { allBindingUtils } from '../../utils/tldraw/bindings';
|
||||
import { multiplayerEmbeds } from '../../utils/tldraw/embeds';
|
||||
// Layout
|
||||
import { HEADER_HEIGHT } from '../../pages/Layout';
|
||||
import { TLSYNC_URL } from '../../config/apiConfig';
|
||||
// Styles
|
||||
import '../../utils/tldraw/tldraw.css';
|
||||
// App debug
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const SYNC_WORKER_URL = import.meta.env.VITE_FRONTEND_SITE_URL.startsWith('http')
|
||||
? `${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`
|
||||
: `https://${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`;
|
||||
const SYNC_WORKER_URL = TLSYNC_URL;
|
||||
|
||||
export default function TldrawMultiUser() {
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string) || '';
|
||||
|
||||
/**
|
||||
* Fetches a short-lived TLSync token from the API using the current Supabase session.
|
||||
* Returns { token, error } — exactly one will be non-null.
|
||||
*/
|
||||
function useTlsyncToken(accessToken: string | null) {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
let cancelled = false;
|
||||
|
||||
logger.debug('multiplayer-page', '🔑 Fetching TLSync token from API');
|
||||
fetch(`${apiBase}/api/tlsync/token`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`Token request failed: ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
logger.debug('multiplayer-page', '✅ TLSync token received', { expiresIn: data.expires_in });
|
||||
setToken(data.token);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
logger.error('multiplayer-page', '❌ Failed to fetch TLSync token', { err: err?.message });
|
||||
setError(err?.message || 'Unknown error');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken]);
|
||||
|
||||
return { token, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading / error overlay shown while the TLSync token is being fetched.
|
||||
*/
|
||||
function TlsyncStatusOverlay({ message }: { message: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '20px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component that owns the useSync hook.
|
||||
* Rendered only once a valid TLSync token has been fetched from the API.
|
||||
*/
|
||||
function TldrawCanvas({
|
||||
tlsyncToken,
|
||||
roomId,
|
||||
}: {
|
||||
tlsyncToken: string;
|
||||
roomId: string;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
|
||||
const {
|
||||
tldrawPreferences,
|
||||
setTldrawPreferences,
|
||||
initializePreferences,
|
||||
presentationMode
|
||||
presentationMode,
|
||||
} = useTLDraw();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
// Get room ID from URL params
|
||||
const roomId = searchParams.get('room') || 'multiplayer';
|
||||
const userInfo = useMemo(
|
||||
() => ({
|
||||
id: user?.id ?? '',
|
||||
name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User',
|
||||
color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)`,
|
||||
}),
|
||||
[user?.id, user?.display_name, user?.email, tldrawPreferences?.color],
|
||||
);
|
||||
|
||||
// Memoize user information to ensure consistency
|
||||
const userInfo = useMemo(() => ({
|
||||
id: user?.id ?? '',
|
||||
name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User',
|
||||
color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)`
|
||||
}), [user?.id, user?.display_name, user?.email, tldrawPreferences?.color]);
|
||||
|
||||
// Create editor user with memoization
|
||||
const editorUser = useTldrawUser({
|
||||
userPreferences: {
|
||||
id: userInfo.id,
|
||||
@@ -67,18 +145,23 @@ export default function TldrawMultiUser() {
|
||||
locale: tldrawPreferences?.locale,
|
||||
colorScheme: tldrawPreferences?.colorScheme,
|
||||
animationSpeed: tldrawPreferences?.animationSpeed,
|
||||
isSnapMode: tldrawPreferences?.isSnapMode
|
||||
isSnapMode: tldrawPreferences?.isSnapMode,
|
||||
},
|
||||
setUserPreferences: setTldrawPreferences
|
||||
setUserPreferences: setTldrawPreferences,
|
||||
});
|
||||
|
||||
const connectionOptions = useMemo(() => createSyncConnectionOptions({
|
||||
userId: userInfo.id,
|
||||
displayName: userInfo.name,
|
||||
color: userInfo.color,
|
||||
roomId,
|
||||
baseUrl: SYNC_WORKER_URL
|
||||
}), [userInfo, roomId]);
|
||||
const connectionOptions = useMemo(
|
||||
() =>
|
||||
createSyncConnectionOptions({
|
||||
userId: userInfo.id,
|
||||
displayName: userInfo.name,
|
||||
color: userInfo.color,
|
||||
roomId,
|
||||
baseUrl: SYNC_WORKER_URL,
|
||||
token: tlsyncToken,
|
||||
}),
|
||||
[userInfo, roomId, tlsyncToken],
|
||||
);
|
||||
|
||||
const store = useSync({
|
||||
...connectionOptions,
|
||||
@@ -88,19 +171,17 @@ export default function TldrawMultiUser() {
|
||||
userInfo: {
|
||||
id: userInfo.id,
|
||||
name: userInfo.name,
|
||||
color: userInfo.color
|
||||
}
|
||||
color: userInfo.color,
|
||||
},
|
||||
});
|
||||
|
||||
// Log connection status changes
|
||||
useEffect(() => {
|
||||
logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, {
|
||||
status: store.status,
|
||||
connectionOptions
|
||||
roomId: connectionOptions.roomId,
|
||||
});
|
||||
}, [store.status, connectionOptions]);
|
||||
}, [store.status, connectionOptions.roomId]);
|
||||
|
||||
// Effect for initializing preferences
|
||||
useEffect(() => {
|
||||
if (user?.id && !tldrawPreferences) {
|
||||
logger.info('multiplayer-page', '🔄 Initializing preferences');
|
||||
@@ -108,20 +189,11 @@ export default function TldrawMultiUser() {
|
||||
}
|
||||
}, [user?.id, tldrawPreferences, initializePreferences]);
|
||||
|
||||
// Effect for redirecting if user is not authenticated
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
// Effect for presentation mode
|
||||
useEffect(() => {
|
||||
if (presentationMode && editorRef.current) {
|
||||
const editor = editorRef.current;
|
||||
const presentationService = new PresentationService(editor);
|
||||
const cleanup = presentationService.startPresentationMode();
|
||||
|
||||
return () => {
|
||||
presentationService.stopPresentationMode();
|
||||
cleanup();
|
||||
@@ -129,47 +201,24 @@ export default function TldrawMultiUser() {
|
||||
}
|
||||
}, [presentationMode]);
|
||||
|
||||
// Memoize UI overrides and components
|
||||
const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]);
|
||||
const uiComponents = useMemo(() => getUiComponents(presentationMode), [presentationMode]);
|
||||
|
||||
// Render conditionally to avoid unnecessary rerenders
|
||||
if (!user) {
|
||||
return null;
|
||||
if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) {
|
||||
return <TlsyncStatusOverlay message={`Connecting to room: ${roomId}...`} />;
|
||||
}
|
||||
|
||||
if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) {
|
||||
return (
|
||||
<div style={{
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
{isInstituteLoading ? 'Loading institute data...' : `Connecting to room: ${roomId}...`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Tldraw
|
||||
user={editorUser}
|
||||
store={store.store}
|
||||
@@ -198,3 +247,33 @@ export default function TldrawMultiUser() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TldrawMultiUser() {
|
||||
const { user, accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const roomId = searchParams.get('room') || 'multiplayer';
|
||||
|
||||
const { token: tlsyncToken, error: tlsyncError } = useTlsyncToken(accessToken);
|
||||
|
||||
// Redirect unauthenticated users
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tlsyncError) {
|
||||
return <TlsyncStatusOverlay message={`Failed to connect to collaboration server: ${tlsyncError}`} />;
|
||||
}
|
||||
|
||||
if (!tlsyncToken) {
|
||||
return <TlsyncStatusOverlay message="Connecting to collaboration server..." />;
|
||||
}
|
||||
|
||||
return <TldrawCanvas tlsyncToken={tlsyncToken} roomId={roomId} />;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||
TLStore,
|
||||
TLStoreWithStatus
|
||||
createTLStore,
|
||||
} from '@tldraw/tldraw';
|
||||
import { useTLDraw } from '../../contexts/TLDrawContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
@@ -15,7 +15,6 @@ import { useUser } from '../../contexts/UserContext';
|
||||
// Tldraw services
|
||||
import { localStoreService } from '../../services/tldraw/localStoreService';
|
||||
import { PresentationService } from '../../services/tldraw/presentationService';
|
||||
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
|
||||
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
|
||||
// Tldraw utils
|
||||
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
|
||||
@@ -34,9 +33,7 @@ import '../../utils/tldraw/tldraw.css';
|
||||
// App debug
|
||||
import { logger } from '../../debugConfig';
|
||||
import { CircularProgress, Alert, Snackbar } from '@mui/material';
|
||||
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles';
|
||||
import { NodeData } from '../../types/graph-shape';
|
||||
import { NavigationNode } from '../../types/navigation';
|
||||
import { ErrorBoundary } from '../../components/ErrorBoundary';
|
||||
|
||||
interface LoadingState {
|
||||
status: 'ready' | 'loading' | 'error';
|
||||
@@ -63,13 +60,12 @@ export default function SinglePlayerPage() {
|
||||
const snapshotServiceRef = useRef<NavigationSnapshotService | null>(null);
|
||||
|
||||
// State
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>({
|
||||
status: 'ready',
|
||||
error: ''
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>({
|
||||
status: 'ready',
|
||||
error: ''
|
||||
});
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(undefined);
|
||||
const storeRef = useRef<TLStore | null>(null);
|
||||
const [storeReady, setStoreReady] = useState(false);
|
||||
|
||||
// TLDraw user preferences
|
||||
const tldrawUser = useTldrawUser({
|
||||
@@ -85,248 +81,97 @@ export default function SinglePlayerPage() {
|
||||
setUserPreferences: setTldrawPreferences
|
||||
});
|
||||
|
||||
// Initialize store — runs as soon as user is ready, no editor needed for store creation
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
logger.debug('single-player-page', '⏳ Waiting for user data');
|
||||
return;
|
||||
}
|
||||
const [canvasPhase, setCanvasPhase] = useState<
|
||||
'idle' | 'store-init' | 'snapshot-loading' | 'ready' | 'error'
|
||||
>('idle');
|
||||
|
||||
logger.info('single-player-page', '🔄 Starting store initialization', {
|
||||
isEditorReady,
|
||||
hasUser: !!user,
|
||||
userType: user.user_type,
|
||||
username: user.username
|
||||
// Effect 1: Create the store ONCE per user session. Never recreate on node changes.
|
||||
// Disposing the store while Tldraw is subscribed causes currentPageId crashes; keeping
|
||||
// the store alive for the component lifetime avoids all disposal race conditions.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
setCanvasPhase('store-init');
|
||||
const store = createTLStore({
|
||||
schema: customSchema,
|
||||
shapeUtils: allShapeUtils,
|
||||
bindingUtils: allBindingUtils,
|
||||
});
|
||||
// Initialize default tldraw records (TLINSTANCE, page, cameras) before mount
|
||||
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||
|
||||
const snapSvc = new NavigationSnapshotService(store, undefined);
|
||||
if (accessToken) snapSvc.setAccessToken(accessToken);
|
||||
snapshotServiceRef.current = snapSvc;
|
||||
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||
const unsubscribe = store.listen(() => {
|
||||
if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
|
||||
if (debounce) clearTimeout(debounce);
|
||||
debounce = setTimeout(async () => {
|
||||
await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
const initializeStoreAndSnapshot = async () => {
|
||||
try {
|
||||
setLoadingState({ status: 'loading', error: '' });
|
||||
|
||||
// 1. Create store
|
||||
logger.debug('single-player-page', '🔄 Creating TLStore');
|
||||
const newStore = localStoreService.getStore({
|
||||
schema: customSchema,
|
||||
shapeUtils: allShapeUtils,
|
||||
bindingUtils: allBindingUtils
|
||||
});
|
||||
logger.debug('single-player-page', '✅ TLStore created');
|
||||
storeRef.current = store;
|
||||
setStoreReady(true);
|
||||
setCanvasPhase('ready');
|
||||
|
||||
// 2. Initialize snapshot service
|
||||
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
||||
if (accessToken) snapshotService.setAccessToken(accessToken);
|
||||
snapshotServiceRef.current = snapshotService;
|
||||
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
|
||||
|
||||
// 3. Load initial snapshot if we have a node
|
||||
if (context.node) {
|
||||
const nodeStoragePath = getNodeStoragePath(context.node);
|
||||
if (nodeStoragePath) {
|
||||
logger.debug('single-player-page', '📥 Loading snapshot from database', {
|
||||
dbName: null,
|
||||
node: context.node,
|
||||
node_storage_path: nodeStoragePath,
|
||||
user_type: user.user_type,
|
||||
username: user.username
|
||||
});
|
||||
|
||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||
nodeStoragePath,
|
||||
accessToken || '',
|
||||
newStore,
|
||||
setLoadingState,
|
||||
undefined,
|
||||
editorRef.current || undefined
|
||||
);
|
||||
// Wire auto-save: set the current path on the service instance
|
||||
snapshotService.setCurrentNodePath(nodeStoragePath);
|
||||
logger.debug('single-player-page', '✅ Snapshot loaded from database');
|
||||
} else {
|
||||
logger.debug('single-player-page', '⚠️ No node_storage_path found in node, skipping snapshot load', {
|
||||
node: context.node
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
|
||||
}
|
||||
|
||||
// 4. Set up auto-save with debouncing (only after initial load is complete)
|
||||
let autoSaveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let isAutoSaving = false;
|
||||
|
||||
newStore.listen(() => {
|
||||
if (snapshotServiceRef.current && snapshotServiceRef.current.getCurrentNodePath()) {
|
||||
// Skip if already saving
|
||||
if (isAutoSaving) {
|
||||
logger.debug('single-player-page', '⚠️ Skipping auto-save - already saving');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timeout
|
||||
if (autoSaveTimeout) {
|
||||
clearTimeout(autoSaveTimeout);
|
||||
}
|
||||
|
||||
// Debounce auto-save to prevent excessive saves
|
||||
autoSaveTimeout = setTimeout(async () => {
|
||||
if (isAutoSaving) return; // Double-check
|
||||
|
||||
isAutoSaving = true;
|
||||
try {
|
||||
logger.debug('single-player-page', '💾 Auto-saving changes (debounced)');
|
||||
await snapshotServiceRef.current?.forceSaveCurrentNode();
|
||||
} catch (error) {
|
||||
logger.error('single-player-page', '❌ Auto-save failed', error);
|
||||
} finally {
|
||||
isAutoSaving = false;
|
||||
}
|
||||
}, 2000); // Increased to 2 seconds debounce
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Update store state
|
||||
setStore(newStore);
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
logger.info('single-player-page', '✅ Store initialization complete');
|
||||
|
||||
// 6. Handle cleanup
|
||||
return () => {
|
||||
logger.debug('single-player-page', '🧹 Starting cleanup');
|
||||
if (snapshotServiceRef.current) {
|
||||
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
|
||||
logger.error('single-player-page', '❌ Final save failed', error);
|
||||
});
|
||||
snapshotServiceRef.current.clearCurrentNode();
|
||||
snapshotServiceRef.current = null;
|
||||
}
|
||||
newStore.dispose();
|
||||
setStore(undefined);
|
||||
logger.debug('single-player-page', '🧹 Cleanup complete');
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize store';
|
||||
logger.error('single-player-page', '❌ Store initialization failed', error);
|
||||
setLoadingState({ status: 'error', error: errorMessage });
|
||||
return undefined;
|
||||
}
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||
snapshotServiceRef.current?.clearCurrentNode();
|
||||
snapshotServiceRef.current = null;
|
||||
setStoreReady(false);
|
||||
setCanvasPhase('idle');
|
||||
store.dispose();
|
||||
storeRef.current = null;
|
||||
};
|
||||
}, [user?.id]); // Only recreate store if user changes, NOT on every node navigation
|
||||
|
||||
initializeStoreAndSnapshot();
|
||||
}, [user, context.node]);
|
||||
|
||||
// Handle initial node placement
|
||||
// Effect 2: Load/reload snapshot when context.node changes. Store stays alive.
|
||||
useEffect(() => {
|
||||
const placeInitialNode = async () => {
|
||||
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
|
||||
logger.debug('single-player-page', '⚠️ Skipping placeInitialNode - missing dependencies', {
|
||||
hasNode: !!context.node,
|
||||
hasEditor: !!editorRef.current,
|
||||
hasStore: !!store,
|
||||
isInitialLoad
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!user || !storeRef.current) return;
|
||||
|
||||
// Debug: Log the actual node structure
|
||||
logger.debug('single-player-page', '🔍 Node structure for placeInitialNode', {
|
||||
node: context.node,
|
||||
nodeKeys: Object.keys(context.node),
|
||||
hasId: !!context.node.id,
|
||||
hasStoragePath: !!context.node.node_storage_path,
|
||||
hasData: !!context.node.data,
|
||||
dataKeys: context.node.data ? Object.keys(context.node.data) : null
|
||||
});
|
||||
const store = storeRef.current;
|
||||
let cancelled = false;
|
||||
|
||||
// Validate that the node has required properties
|
||||
const nodeStoragePath = getNodeStoragePath(context.node);
|
||||
if (!context.node.id || !nodeStoragePath) {
|
||||
logger.error('single-player-page', '❌ Node missing required properties', {
|
||||
nodeId: context.node.id,
|
||||
hasStoragePath: !!nodeStoragePath,
|
||||
node: context.node
|
||||
});
|
||||
setLoadingState({
|
||||
status: 'error',
|
||||
error: 'Node is missing required information'
|
||||
});
|
||||
const loadNodeSnapshot = async () => {
|
||||
if (!context.node?.node_storage_path) {
|
||||
// No node — clear to blank canvas using ensureStoreIsUsable to restore defaults
|
||||
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||
snapshotServiceRef.current?.clearCurrentNode();
|
||||
setCanvasPhase('ready');
|
||||
return;
|
||||
}
|
||||
|
||||
setCanvasPhase('snapshot-loading');
|
||||
try {
|
||||
setLoadingState({ status: 'loading', error: '' });
|
||||
|
||||
if (context.node.type !== 'workspace') {
|
||||
try {
|
||||
const nodeData = await loadNodeData(context.node);
|
||||
await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData);
|
||||
} catch (shapeErr) {
|
||||
logger.warn('single-player-page', '⚠️ Could not place node shape', { type: context.node.type, error: shapeErr });
|
||||
}
|
||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||
context.node.node_storage_path,
|
||||
accessToken || '',
|
||||
store,
|
||||
setLoadingState,
|
||||
undefined,
|
||||
editorRef.current || undefined
|
||||
);
|
||||
if (!cancelled) {
|
||||
// Repair store in case snapshot overwrote TLINSTANCE
|
||||
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||
snapshotServiceRef.current?.setCurrentNodePath(context.node.node_storage_path);
|
||||
setCanvasPhase('ready');
|
||||
}
|
||||
|
||||
setIsInitialLoad(false);
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
} catch (error) {
|
||||
logger.error('single-player-page', '❌ Failed to place initial node', error);
|
||||
setLoadingState({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Failed to place initial node'
|
||||
});
|
||||
} catch (e) {
|
||||
if (!cancelled) setCanvasPhase('ready'); // show blank canvas on error
|
||||
}
|
||||
};
|
||||
|
||||
placeInitialNode();
|
||||
}, [context.node, store, isInitialLoad]);
|
||||
loadNodeSnapshot();
|
||||
return () => { cancelled = true; };
|
||||
}, [context.node?.id, storeReady]); // re-run when node changes OR after store becomes ready
|
||||
|
||||
// Handle navigation changes
|
||||
useEffect(() => {
|
||||
const handleNodeChange = async () => {
|
||||
if (!context.node?.id || !editorRef.current || !snapshotServiceRef.current || !store) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We can safely assert these types because we've checked for null above
|
||||
const editor = editorRef.current as Editor;
|
||||
const snapshotService = snapshotServiceRef.current;
|
||||
const currentNode = context.node;
|
||||
|
||||
try {
|
||||
setLoadingState({ status: 'loading', error: '' });
|
||||
logger.debug('single-player-page', '🔄 Loading node data', {
|
||||
nodeId: currentNode.id,
|
||||
node_storage_path: currentNode.node_storage_path,
|
||||
isInitialLoad
|
||||
});
|
||||
|
||||
// Get the previous node from navigation history
|
||||
const previousNode = context.history.currentIndex > 0
|
||||
? context.history.nodes[context.history.currentIndex - 1]
|
||||
: null;
|
||||
|
||||
// Handle navigation in snapshot service (load/save snapshot)
|
||||
await snapshotService.handleNavigationStart(previousNode, currentNode);
|
||||
|
||||
if (currentNode.type !== 'workspace') {
|
||||
try {
|
||||
const nodeData = await loadNodeData(currentNode);
|
||||
await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData);
|
||||
} catch (shapeErr) {
|
||||
logger.warn('single-player-page', '⚠️ Could not place node shape', { type: currentNode.type, error: shapeErr });
|
||||
}
|
||||
}
|
||||
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
} catch (error) {
|
||||
logger.error('single-player-page', '❌ Failed to load node data', error);
|
||||
setLoadingState({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Failed to load node data'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleNodeChange();
|
||||
}, [context.node, context.history, store]);
|
||||
// Handle navigation changes — save previous snapshot, load next one.
|
||||
// No automatic node shape placement: the canvas shows only persisted state.
|
||||
|
||||
// Inject auth and trigger initial context when token is ready
|
||||
useEffect(() => {
|
||||
@@ -485,7 +330,7 @@ export default function SinglePlayerPage() {
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
}}>
|
||||
{/* Loading overlay - show when loading or contexts not initialized */}
|
||||
{(loadingState.status === 'loading' || !store) && (
|
||||
{(loadingState.status === 'loading' || !storeReady) && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
@@ -513,9 +358,15 @@ export default function SinglePlayerPage() {
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
{store && <Tldraw
|
||||
<ErrorBoundary fallback={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '1rem', padding: '2rem' }}>
|
||||
<p style={{ fontSize: '1.1rem' }}>Canvas failed to load.</p>
|
||||
<button onClick={() => window.location.reload()} style={{ padding: '0.5rem 1.25rem', cursor: 'pointer' }}>Reload</button>
|
||||
</div>
|
||||
}>
|
||||
{storeReady && storeRef.current && <Tldraw
|
||||
user={tldrawUser}
|
||||
store={store}
|
||||
store={storeRef.current}
|
||||
tools={singlePlayerTools}
|
||||
shapeUtils={allShapeUtils}
|
||||
bindingUtils={allBindingUtils}
|
||||
@@ -541,64 +392,24 @@ export default function SinglePlayerPage() {
|
||||
|
||||
editorRef.current = editor;
|
||||
logger.debug('single-player-page', '✅ Editor ref set');
|
||||
|
||||
|
||||
// Update snapshot service with editor reference
|
||||
if (snapshotServiceRef.current) {
|
||||
snapshotServiceRef.current.setEditor(editor);
|
||||
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
|
||||
}
|
||||
|
||||
setIsEditorReady(true);
|
||||
|
||||
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
|
||||
editorId: editor.store.id,
|
||||
presentationMode,
|
||||
isEditorReady: true
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('single-player-page', '❌ Error in onMount', error);
|
||||
}
|
||||
}}
|
||||
/>}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to safely extract node_storage_path from different node structures
|
||||
const getNodeStoragePath = (node: NavigationNode): string | null => {
|
||||
// Try direct access first
|
||||
if (node.node_storage_path) {
|
||||
return node.node_storage_path;
|
||||
}
|
||||
|
||||
// Try nested under data
|
||||
if (node.data?.node_storage_path) {
|
||||
return node.data.node_storage_path;
|
||||
}
|
||||
|
||||
// Try other possible locations
|
||||
if (node.data?.storage_path && typeof node.data.storage_path === 'string') {
|
||||
return node.data.storage_path;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
|
||||
if (!node?.id) throw new Error('Node parameter is required');
|
||||
const nodeStoragePath = getNodeStoragePath(node);
|
||||
if (!nodeStoragePath) throw new Error(`Node ${node.id} is missing node_storage_path`);
|
||||
|
||||
const theme = getThemeFromLabel(node.type);
|
||||
return {
|
||||
title: node.label || node.type || '',
|
||||
w: 500,
|
||||
h: 350,
|
||||
state: { parentId: null, isPageChild: true, hasChildren: null, bindings: null },
|
||||
headerColor: theme.headerColor,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
isLocked: false,
|
||||
__primarylabel__: node.type,
|
||||
uuid_string: node.id,
|
||||
node_storage_path: nodeStoragePath,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ function NotFound() {
|
||||
gap: 3
|
||||
}}
|
||||
>
|
||||
<ErrorOutline sx={{ fontSize: 60, color: theme.palette.error.main }} />
|
||||
<ErrorOutlineIcon sx={{ fontSize: 60, color: theme.palette.error.main }} />
|
||||
<Typography variant="h2" component="h1" gutterBottom>
|
||||
404
|
||||
</Typography>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Container,
|
||||
Grid,
|
||||
Paper,
|
||||
@@ -10,17 +12,28 @@ import {
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import { useUser } from '../../contexts/UserContext';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user: authUser } = useAuth();
|
||||
const { user: authUser, bootstrapData } = useAuth();
|
||||
const { profile, loading } = useUser();
|
||||
|
||||
const displayName = profile?.display_name || authUser?.display_name || authUser?.username || 'Member';
|
||||
const emailAddress = profile?.email || authUser?.email || '';
|
||||
const userType = profile?.user_type || authUser?.user_type || '';
|
||||
|
||||
const activeInstitute = bootstrapData?.active_institute;
|
||||
const activeMembership = bootstrapData?.memberships?.find(
|
||||
(m) => m.institute_id === activeInstitute?.id && m.is_active
|
||||
);
|
||||
const schoolName = activeMembership?.institute?.name;
|
||||
const onboarding = bootstrapData?.onboarding;
|
||||
const calendarOk = bootstrapData?.calendar_status?.available && !bootstrapData?.calendar_status?.needs_setup;
|
||||
const timetableOk = bootstrapData?.timetable_status?.available && !bootstrapData?.timetable_status?.needs_setup;
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<Stack spacing={6}>
|
||||
@@ -72,6 +85,12 @@ const DashboardPage: React.FC = () => {
|
||||
>
|
||||
Open workspace
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => navigate('/exam-marker')}
|
||||
>
|
||||
Exam Marker
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => navigate('/calendar')}
|
||||
@@ -88,6 +107,79 @@ const DashboardPage: React.FC = () => {
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{/* Bootstrap Status Section */}
|
||||
{bootstrapData && (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant="h5" component="h2">
|
||||
Your school
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper elevation={2} sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Institute
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body1" fontWeight={600}>
|
||||
{schoolName || 'No school assigned'}
|
||||
</Typography>
|
||||
{activeInstitute?.membership_role && (
|
||||
<Chip
|
||||
label={activeInstitute.membership_role.replace(/_/g, ' ')}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Status: {bootstrapData.school_status}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper elevation={2} sx={{ p: 3, height: '100%' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
System status
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{calendarOk ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
|
||||
<Typography variant="body2">
|
||||
Calendar: {calendarOk ? 'Ready' : 'Needs setup'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{timetableOk ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
|
||||
<Typography variant="body2">
|
||||
Timetable: {timetableOk ? 'Ready' : 'Needs setup'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{bootstrapData?.graph_status?.available ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
|
||||
<Typography variant="body2">
|
||||
Graph projection: {bootstrapData?.graph_status?.projection_state || 'unknown'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{onboarding && onboarding.next_step !== 'complete' && (
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{onboarding.message}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>
|
||||
Next step: {onboarding.next_step.replace(/_/g, ' ')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string) || '';
|
||||
|
||||
export interface BootstrapProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
user_type: string;
|
||||
school_id: string | null;
|
||||
}
|
||||
|
||||
export interface BootstrapMembership {
|
||||
institute_id: string;
|
||||
role: string;
|
||||
status: string;
|
||||
is_active: boolean;
|
||||
institute: {
|
||||
id: string;
|
||||
name: string;
|
||||
urn: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BootstrapActiveInstitute {
|
||||
id: string | null;
|
||||
source: string;
|
||||
membership_role: string | null;
|
||||
}
|
||||
|
||||
export interface BootstrapPermissions {
|
||||
platform_admin: boolean;
|
||||
platform_super_admin: boolean;
|
||||
can_create_school: boolean;
|
||||
can_manage_school: boolean;
|
||||
can_manage_calendar: boolean;
|
||||
can_manage_timetable: boolean;
|
||||
can_invite_staff: boolean;
|
||||
can_manage_classes: boolean;
|
||||
can_view_student_data: boolean;
|
||||
can_use_canvas: boolean;
|
||||
}
|
||||
|
||||
export interface BootstrapOnboarding {
|
||||
next_step: string;
|
||||
required: string[];
|
||||
optional: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BootstrapCalendarStatus {
|
||||
available: boolean;
|
||||
academic_year_count: number;
|
||||
term_count: number;
|
||||
current_academic_year_id: string | null;
|
||||
needs_setup: boolean;
|
||||
}
|
||||
|
||||
export interface BootstrapTimetableStatus {
|
||||
available: boolean;
|
||||
teacher_timetable_id: string | null;
|
||||
slot_count: number;
|
||||
needs_setup: boolean;
|
||||
}
|
||||
|
||||
export interface BootstrapGraphStatus {
|
||||
available: boolean;
|
||||
user_db: string | null;
|
||||
institute_db: string | null;
|
||||
projection_state: string;
|
||||
needs_rebuild: boolean;
|
||||
last_checked_at: string | null;
|
||||
error_code: string | null;
|
||||
}
|
||||
|
||||
export interface BootstrapResponse {
|
||||
profile: BootstrapProfile;
|
||||
memberships: BootstrapMembership[];
|
||||
active_institute: BootstrapActiveInstitute;
|
||||
permissions: BootstrapPermissions;
|
||||
school_status: string;
|
||||
onboarding: BootstrapOnboarding;
|
||||
calendar_status: BootstrapCalendarStatus;
|
||||
timetable_status: BootstrapTimetableStatus;
|
||||
graph_status: BootstrapGraphStatus;
|
||||
}
|
||||
|
||||
let cachedBootstrap: BootstrapResponse | null = null;
|
||||
let cacheTimestamp = 0;
|
||||
const CACHE_TTL_MS = 60_000; // 60 second cache
|
||||
|
||||
export async function fetchBootstrapData(accessToken: string): Promise<BootstrapResponse | null> {
|
||||
if (!accessToken) return null;
|
||||
|
||||
// Return cached data if fresh
|
||||
if (cachedBootstrap && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
|
||||
return cachedBootstrap;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/me/bootstrap`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
logger.warn('bootstrap-service', `Bootstrap fetch failed: ${res.status}`);
|
||||
return cachedBootstrap; // Return stale cache on error
|
||||
}
|
||||
|
||||
const data: BootstrapResponse = await res.json();
|
||||
cachedBootstrap = data;
|
||||
cacheTimestamp = Date.now();
|
||||
return data;
|
||||
} catch (err) {
|
||||
logger.error('bootstrap-service', 'Bootstrap fetch error', { err });
|
||||
return cachedBootstrap; // Return stale cache on error
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateBootstrapCache(): void {
|
||||
cachedBootstrap = null;
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* examRepository — the SINGLE module that talks to the /api/exam backend (spec R2.1 seam).
|
||||
*
|
||||
* All exam-marker persistence flows through here so a later dual-write / offline cache can slot
|
||||
* in without touching feature code. Mirrors the auth pattern of timetableService: take the
|
||||
* Supabase session JWT and send it as a Bearer token; the API enforces RLS as the user.
|
||||
*/
|
||||
import axios from 'axios';
|
||||
|
||||
import { API_BASE } from '../../config/apiConfig';
|
||||
import { logger } from '../../debugConfig';
|
||||
import { supabase } from '../../supabaseClient';
|
||||
import type {
|
||||
CreateTemplatePayload,
|
||||
ExamBoundary,
|
||||
ExamQuestion,
|
||||
ExamResponseArea,
|
||||
ExamTemplate,
|
||||
ExamTemplateDetail,
|
||||
UpdateTemplateMetaPayload,
|
||||
} from '../../types/exam.types';
|
||||
|
||||
const EXAM_BASE = `${API_BASE}/api/exam`;
|
||||
|
||||
async function authHeaders(): Promise<Record<string, string>> {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) {
|
||||
throw new Error('No authentication token available');
|
||||
}
|
||||
return { Authorization: `Bearer ${session.access_token}` };
|
||||
}
|
||||
|
||||
function newUuid(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.floor(Math.random() * 16);
|
||||
const v = c === 'x' ? r : (r % 4) + 8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
|
||||
return {
|
||||
id: idMap?.get(q.id) ?? q.id,
|
||||
parent_id: q.parent_id ? (idMap?.get(q.parent_id) ?? q.parent_id) : null,
|
||||
label: q.label,
|
||||
order: q.order,
|
||||
max_marks: q.max_marks,
|
||||
answer_type: q.answer_type,
|
||||
mcq_options: q.mcq_options,
|
||||
mark_scheme: q.mark_scheme ?? {},
|
||||
is_container: q.is_container,
|
||||
spec_ref: q.spec_ref,
|
||||
bounds: q.bounds ?? null,
|
||||
page: q.page ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, duplicate = false) {
|
||||
return {
|
||||
id: duplicate ? newUuid() : r.id,
|
||||
question_id: idMap?.get(r.question_id) ?? r.question_id,
|
||||
page: r.page,
|
||||
bounds: r.bounds,
|
||||
kind: r.kind,
|
||||
response_form: r.response_form,
|
||||
context_type: r.context_type ?? null,
|
||||
source: r.source,
|
||||
confirmed: r.confirmed,
|
||||
confidence: r.confidence,
|
||||
};
|
||||
}
|
||||
|
||||
function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate = false) {
|
||||
return {
|
||||
id: duplicate ? newUuid() : b.id,
|
||||
question_id: b.question_id ? (idMap?.get(b.question_id) ?? b.question_id) : null,
|
||||
label: b.label,
|
||||
page_index: b.page_index,
|
||||
y: b.y,
|
||||
bounds: b.bounds,
|
||||
source: b.source,
|
||||
confirmed: b.confirmed,
|
||||
};
|
||||
}
|
||||
|
||||
async function replaceTemplate(
|
||||
templateId: string,
|
||||
detail: ExamTemplateDetail,
|
||||
meta?: UpdateTemplateMetaPayload,
|
||||
duplicateIds = false,
|
||||
): Promise<ExamTemplateDetail> {
|
||||
const headers = await authHeaders();
|
||||
const idMap = new Map<string, string>();
|
||||
if (duplicateIds) {
|
||||
detail.questions.forEach((q) => idMap.set(q.id, newUuid()));
|
||||
}
|
||||
const res = await axios.put<ExamTemplateDetail>(
|
||||
`${EXAM_BASE}/templates/${templateId}`,
|
||||
{
|
||||
meta,
|
||||
questions: detail.questions.map((q) => questionPayload(q, idMap)),
|
||||
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
|
||||
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export const examRepository = {
|
||||
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<{ templates: ExamTemplate[] }>(
|
||||
`${EXAM_BASE}/templates`,
|
||||
{ headers, params: { include_archived: includeArchived } },
|
||||
);
|
||||
return res.data.templates ?? [];
|
||||
},
|
||||
|
||||
async getTemplate(templateId: string): Promise<ExamTemplateDetail> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise<ExamTemplate> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.patch<ExamTemplate>(`${EXAM_BASE}/templates/${templateId}`, meta, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async duplicateTemplate(templateId: string, title: string): Promise<ExamTemplateDetail> {
|
||||
const detail = await this.getTemplate(templateId);
|
||||
const created = await this.createTemplate({
|
||||
title,
|
||||
subject: detail.subject ?? undefined,
|
||||
exam_id: detail.exam_id ?? undefined,
|
||||
exam_code: detail.exam_code ?? undefined,
|
||||
source_file_id: detail.source_file_id ?? undefined,
|
||||
page_count: detail.page_count ?? undefined,
|
||||
institute_id: detail.institute_id,
|
||||
});
|
||||
try {
|
||||
return await replaceTemplate(created.id, { ...detail, id: created.id }, { title, status: 'draft' }, true);
|
||||
} catch (error) {
|
||||
try {
|
||||
await this.archiveTemplate(created.id);
|
||||
} catch (archiveError) {
|
||||
logger.error('cc-exam-marker', 'Failed to archive incomplete duplicate template', {
|
||||
templateId: created.id,
|
||||
message: archiveError instanceof Error ? archiveError.message : String(archiveError),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async archiveTemplate(templateId: string): Promise<void> {
|
||||
const headers = await authHeaders();
|
||||
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||
},
|
||||
};
|
||||
|
||||
export default examRepository;
|
||||
@@ -2,6 +2,7 @@ import axios from 'axios';
|
||||
import { supabase } from '../supabaseClient';
|
||||
import {
|
||||
Class,
|
||||
ClassWithRole,
|
||||
ClassWithRelations,
|
||||
ClassFilters,
|
||||
Timetable,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
} from '../types/timetable.types';
|
||||
|
||||
// API base URL
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
// ============================================================================
|
||||
// Helper: Get auth headers
|
||||
@@ -79,10 +80,30 @@ export const classService = {
|
||||
await axios.delete(`${API_BASE}/database/timetable/classes/${classId}`, { headers });
|
||||
},
|
||||
|
||||
async getMyClasses(): Promise<Class[]> {
|
||||
async getMyClasses(): Promise<ClassWithRole[]> {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await axios.get(`${API_BASE}/database/timetable/classes/me/student`, { headers });
|
||||
return response.data.classes;
|
||||
const [teacherRes, studentRes] = await Promise.allSettled([
|
||||
axios.get(`${API_BASE}/database/timetable/classes/me/teacher`, { headers }),
|
||||
axios.get(`${API_BASE}/database/timetable/classes/me/student`, { headers }),
|
||||
]);
|
||||
const teacherClasses: ClassWithRole[] = teacherRes.status === 'fulfilled'
|
||||
? (teacherRes.value.data.classes || []).map((c: Class) => ({
|
||||
...c,
|
||||
class_id: c.id,
|
||||
role: 'teacher' as const,
|
||||
status: 'active',
|
||||
}))
|
||||
: [];
|
||||
const studentClasses: ClassWithRole[] = studentRes.status === 'fulfilled'
|
||||
? (studentRes.value.data.classes || []).map((c: Class) => ({
|
||||
...c,
|
||||
class_id: c.id,
|
||||
role: 'student' as const,
|
||||
status: 'active',
|
||||
}))
|
||||
: [];
|
||||
const seen = new Set(teacherClasses.map(c => c.id));
|
||||
return [...teacherClasses, ...studentClasses.filter(c => !seen.has(c.id))];
|
||||
},
|
||||
|
||||
async getMyTeachingClasses(): Promise<Class[]> {
|
||||
|
||||
@@ -19,12 +19,17 @@ export class SharedStoreService {
|
||||
private lastSaveTime: number = Date.now();
|
||||
private autoSaveInterval: ReturnType<typeof setTimeout> | null = null;
|
||||
private config: AutoSaveConfig;
|
||||
private isDirty = false;
|
||||
private dirtyListener: (() => void) | null = null;
|
||||
|
||||
constructor(private store: TLStore, config?: Partial<AutoSaveConfig>) {
|
||||
this.config = {
|
||||
...DEFAULT_CONFIG,
|
||||
...config
|
||||
};
|
||||
this.dirtyListener = store.listen(() => {
|
||||
this.isDirty = true;
|
||||
});
|
||||
logger.debug('shared-store-service', '🏗️ Initializing SharedStoreService');
|
||||
}
|
||||
|
||||
@@ -52,18 +57,16 @@ export class SharedStoreService {
|
||||
}
|
||||
|
||||
private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (now - this.lastSaveTime >= this.config.saveInterval) {
|
||||
const currentSnapshot = getSnapshot(this.store);
|
||||
const savedSnapshot = storageService.get(StorageKeys.LOCAL_SNAPSHOT);
|
||||
if (!this.isDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!savedSnapshot || JSON.stringify(currentSnapshot) !== JSON.stringify(savedSnapshot)) {
|
||||
logger.debug('shared-store-service', '💾 Auto-saving snapshot - changes detected');
|
||||
await this.saveSnapshot(currentSnapshot, setLoadingState);
|
||||
this.lastSaveTime = now;
|
||||
} else {
|
||||
logger.trace('shared-store-service', '📝 No changes detected, skipping auto-save');
|
||||
}
|
||||
this.isDirty = false;
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastSaveTime >= this.config.saveInterval) {
|
||||
await this.saveSnapshot(getSnapshot(this.store), setLoadingState);
|
||||
this.lastSaveTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +112,10 @@ export class SharedStoreService {
|
||||
|
||||
public clear(): void {
|
||||
this.stopAutoSave();
|
||||
this.dirtyListener?.();
|
||||
this.dirtyListener = null;
|
||||
this.store.clear();
|
||||
this.isDirty = false;
|
||||
logger.debug('shared-store-service', '🧹 Store cleared');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// External imports
|
||||
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw';
|
||||
import { TLStore, getSnapshot, Editor, loadSnapshot, TLINSTANCE_ID } from '@tldraw/tldraw';
|
||||
import logger from '../../debugConfig';
|
||||
import { SharedStoreService } from './sharedStoreService';
|
||||
|
||||
@@ -109,17 +109,35 @@ export class NavigationSnapshotService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize session.currentPageId against pages actually in the document
|
||||
const docStore = (snap.document as { store?: Record<string, { typeName: string; id: string }> })?.store ?? {};
|
||||
const pageIds = Object.values(docStore).filter((r) => (r as { typeName: string }).typeName === 'page').map((r) => r.id);
|
||||
const session = snap.session as { currentPageId?: string } | undefined;
|
||||
if (session?.currentPageId && pageIds.length > 0 && !pageIds.includes(session.currentPageId)) {
|
||||
logger.warn('snapshot-service', '⚠️ session.currentPageId not in snapshot pages — resetting to first page', {
|
||||
currentPageId: session.currentPageId, availablePages: pageIds
|
||||
});
|
||||
session.currentPageId = pageIds[0];
|
||||
}
|
||||
|
||||
const snapshotCopy = {
|
||||
schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
|
||||
document: snap.document,
|
||||
session: snap.session,
|
||||
};
|
||||
|
||||
const targetStore = editor ? editor.store : store;
|
||||
try {
|
||||
if (editor) {
|
||||
loadSnapshot(editor.store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
|
||||
} else {
|
||||
loadSnapshot(store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
|
||||
loadSnapshot(targetStore, snapshotCopy as any);
|
||||
// Post-load validation: ensure instance.currentPageId is still valid
|
||||
const instance = targetStore.get(TLINSTANCE_ID);
|
||||
if (instance) {
|
||||
const pages = targetStore.allRecords().filter((r) => (r as { typeName: string }).typeName === 'page');
|
||||
const pageValid = pages.some((p) => p.id === instance.currentPageId);
|
||||
if (!pageValid) {
|
||||
logger.warn('snapshot-service', '⚠️ Post-load: currentPageId invalid — clearing corrupt store', { currentPageId: instance.currentPageId });
|
||||
targetStore.clear();
|
||||
}
|
||||
}
|
||||
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
|
||||
} catch (err) {
|
||||
@@ -128,7 +146,8 @@ export class NavigationSnapshotService {
|
||||
if (isSchemaMigration) {
|
||||
logger.debug('snapshot-service', 'ℹ️ Schema migration warning (non-critical)', { error: msg });
|
||||
} else {
|
||||
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error', { error: msg });
|
||||
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error — clearing store to allow fresh start', { error: msg });
|
||||
targetStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface SyncConnectionOptions {
|
||||
color: string;
|
||||
roomId?: string;
|
||||
baseUrl: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export function createSyncConnectionOptions(options: SyncConnectionOptions) {
|
||||
@@ -22,8 +23,8 @@ export function createSyncConnectionOptions(options: SyncConnectionOptions) {
|
||||
userId,
|
||||
displayName,
|
||||
roomId = 'multiplayer',
|
||||
baseUrl
|
||||
|
||||
baseUrl,
|
||||
token
|
||||
} = options;
|
||||
|
||||
// Ensure we have valid user info
|
||||
@@ -72,8 +73,7 @@ export function createSyncConnectionOptions(options: SyncConnectionOptions) {
|
||||
roomId: effectiveRoomId
|
||||
});
|
||||
|
||||
const token = import.meta.env.VITE_TLSYNC_SECRET ?? ''
|
||||
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ''
|
||||
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
|
||||
return {
|
||||
uri: `${baseUrl}/connect/${effectiveRoomId}${tokenParam}`,
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import '@testing-library/jest-dom';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
if (typeof globalThis.matchMedia !== 'function') {
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
matches: false,
|
||||
media: '',
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
} as unknown as MediaQueryList))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import {
|
||||
Class,
|
||||
ClassWithRole,
|
||||
ClassWithRelations,
|
||||
Timetable,
|
||||
TimetableWithRelations,
|
||||
@@ -21,7 +22,7 @@ interface TimetableState {
|
||||
// Classes
|
||||
classes: Class[];
|
||||
currentClass: ClassWithRelations | null;
|
||||
myClasses: Class[];
|
||||
myClasses: ClassWithRole[];
|
||||
myTeachingClasses: Class[];
|
||||
classesLoading: boolean;
|
||||
classesError: string | null;
|
||||
|
||||
@@ -577,7 +577,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => {
|
||||
set({ isExporting: true, exportError: null });
|
||||
|
||||
try {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
|
||||
const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${sessionId}/export`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -627,7 +627,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => {
|
||||
try {
|
||||
const { _accessToken: token } = get();
|
||||
if (!token) return;
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
|
||||
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
@@ -643,7 +643,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => {
|
||||
try {
|
||||
const { _accessToken: token, _userId: userId } = get();
|
||||
if (!token || !userId) return;
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
|
||||
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -669,7 +669,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => {
|
||||
try {
|
||||
const { _accessToken: token } = get();
|
||||
if (!token) return;
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
|
||||
await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
@@ -709,7 +709,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => {
|
||||
if (activeSession) {
|
||||
try {
|
||||
const { _accessToken: kwToken } = get();
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
|
||||
await fetch(`${apiBaseUrl}/transcribe/keywords/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* tailwind-compat.css
|
||||
* Minimal CSS shim replacing the Tailwind build pipeline.
|
||||
* Covers only the utility classes actually used in this codebase.
|
||||
* Generated from grep audit of 8 affected files (B3 Tailwind removal sprint).
|
||||
* Over time, replace these with --cc-* tokens or MUI sx props and remove entries.
|
||||
*/
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────────────────────── */
|
||||
.flex { display: flex; }
|
||||
.grid { display: grid; }
|
||||
.inline-flex { display: inline-flex; }
|
||||
.inline-block { display: inline-block; }
|
||||
.block { display: block; }
|
||||
.hidden { display: none; }
|
||||
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.flex-1 { flex: 1 1 0%; }
|
||||
.flex-shrink-0 { flex-shrink: 0; }
|
||||
|
||||
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
||||
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
|
||||
.items-center { align-items: center; }
|
||||
.items-start { align-items: flex-start; }
|
||||
.items-end { align-items: flex-end; }
|
||||
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-end { justify-content: flex-end; }
|
||||
.justify-start { justify-content: flex-start; }
|
||||
|
||||
/* ── Position ───────────────────────────────────────────────────────────── */
|
||||
.relative { position: relative; }
|
||||
.absolute { position: absolute; }
|
||||
.fixed { position: fixed; }
|
||||
.sticky { position: sticky; }
|
||||
|
||||
/* ── Spacing — gap ──────────────────────────────────────────────────────── */
|
||||
.gap-1 { gap: 0.25rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-3 { gap: 0.75rem; }
|
||||
.gap-4 { gap: 1rem; }
|
||||
.gap-6 { gap: 1.5rem; }
|
||||
|
||||
/* ── Spacing — padding ──────────────────────────────────────────────────── */
|
||||
.p-0 { padding: 0; }
|
||||
.p-2 { padding: 0.5rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-5 { padding: 1.25rem; }
|
||||
.p-6 { padding: 1.5rem; }
|
||||
.p-12 { padding: 3rem; }
|
||||
|
||||
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
||||
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||
.px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||
|
||||
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
|
||||
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
|
||||
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
|
||||
.py-5 { padding-top: 1.25rem; padding-bottom: 1.25rem; }
|
||||
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
||||
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
|
||||
.py-12{ padding-top: 3rem; padding-bottom: 3rem; }
|
||||
.py-16{ padding-top: 4rem; padding-bottom: 4rem; }
|
||||
|
||||
.pt-4 { padding-top: 1rem; }
|
||||
.pl-10{ padding-left: 2.5rem; }
|
||||
.pr-4 { padding-right: 1rem; }
|
||||
|
||||
/* ── Spacing — margin ───────────────────────────────────────────────────── */
|
||||
.mt-0 { margin-top: 0; }
|
||||
.mt-1 { margin-top: 0.25rem; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
.mt-8 { margin-top: 2rem; }
|
||||
|
||||
.mb-1 { margin-bottom: 0.25rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 0.75rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
.mb-6 { margin-bottom: 1.5rem; }
|
||||
.mb-8 { margin-bottom: 2rem; }
|
||||
|
||||
.ml-2 { margin-left: 0.5rem; }
|
||||
.ml-4 { margin-left: 1rem; }
|
||||
.mr-1 { margin-right: 0.25rem; }
|
||||
.mr-2 { margin-right: 0.5rem; }
|
||||
.my-8 { margin-top: 2rem; margin-bottom: 2rem; }
|
||||
|
||||
/* ── Space-between children ─────────────────────────────────────────────── */
|
||||
.space-x-2 > * + * { margin-left: 0.5rem; }
|
||||
.space-y-3 > * + * { margin-top: 0.75rem; }
|
||||
.space-y-4 > * + * { margin-top: 1rem; }
|
||||
.space-y-8 > * + * { margin-top: 2rem; }
|
||||
|
||||
/* ── Sizing ─────────────────────────────────────────────────────────────── */
|
||||
.w-0 { width: 0; }
|
||||
.w-4 { width: 1rem; }
|
||||
.w-8 { width: 2rem; }
|
||||
.w-10 { width: 2.5rem; }
|
||||
.w-12 { width: 3rem; }
|
||||
.w-16 { width: 4rem; }
|
||||
.w-full { width: 100%; }
|
||||
|
||||
.h-4 { height: 1rem; }
|
||||
.h-8 { height: 2rem; }
|
||||
.h-10 { height: 2.5rem; }
|
||||
.h-12 { height: 3rem; }
|
||||
.h-64 { height: 16rem; }
|
||||
.h-full { height: 100%; }
|
||||
|
||||
.min-h-screen { min-height: 100vh; }
|
||||
.max-w-md { max-width: 28rem; }
|
||||
.max-w-4xl { max-width: 56rem; }
|
||||
.max-w-6xl { max-width: 72rem; }
|
||||
.max-w-7xl { max-width: 80rem; }
|
||||
|
||||
/* ── Typography ─────────────────────────────────────────────────────────── */
|
||||
.text-xs { font-size: 0.75rem; line-height: 1rem; }
|
||||
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
|
||||
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
|
||||
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
|
||||
|
||||
.font-medium { font-weight: 500; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-bold { font-weight: 700; }
|
||||
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
.uppercase { text-transform: uppercase; }
|
||||
.capitalize { text-transform: capitalize; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ── Colours — text ─────────────────────────────────────────────────────── */
|
||||
.text-white { color: #ffffff; }
|
||||
.text-gray-300 { color: #d1d5db; }
|
||||
.text-gray-400 { color: #9ca3af; }
|
||||
.text-gray-500 { color: #6b7280; }
|
||||
.text-gray-600 { color: #4b5563; }
|
||||
.text-gray-700 { color: #374151; }
|
||||
.text-gray-900 { color: #111827; }
|
||||
.text-blue-600 { color: #2563eb; }
|
||||
.text-blue-700 { color: #1d4ed8; }
|
||||
.text-red-600 { color: #dc2626; }
|
||||
.text-red-700 { color: #b91c1c; }
|
||||
.text-red-800 { color: #991b1b; }
|
||||
.text-green-600 { color: #16a34a; }
|
||||
.text-green-700 { color: #15803d; }
|
||||
.text-purple-600 { color: #9333ea; }
|
||||
.text-purple-700 { color: #7e22ce; }
|
||||
.text-yellow-700 { color: #a16207; }
|
||||
|
||||
/* ── Colours — background ───────────────────────────────────────────────── */
|
||||
.bg-white { background-color: #ffffff; }
|
||||
.bg-transparent { background-color: transparent; }
|
||||
.bg-gray-50 { background-color: #f9fafb; }
|
||||
.bg-gray-100 { background-color: #f3f4f6; }
|
||||
.bg-gray-500 { background-color: #6b7280; }
|
||||
.bg-blue-50 { background-color: #eff6ff; }
|
||||
.bg-blue-100 { background-color: #dbeafe; }
|
||||
.bg-blue-600 { background-color: #2563eb; }
|
||||
.bg-red-50 { background-color: #fef2f2; }
|
||||
.bg-red-100 { background-color: #fee2e2; }
|
||||
.bg-red-600 { background-color: #dc2626; }
|
||||
.bg-green-100 { background-color: #dcfce7; }
|
||||
.bg-purple-100 { background-color: #f3e8ff; }
|
||||
.bg-yellow-100 { background-color: #fef9c3; }
|
||||
|
||||
/* ── Borders ────────────────────────────────────────────────────────────── */
|
||||
.border { border-width: 1px; border-style: solid; }
|
||||
.border-t { border-top-width: 1px; border-top-style: solid; }
|
||||
.border-b { border-bottom-width: 1px; border-bottom-style: solid; }
|
||||
.border-2 { border-width: 2px; border-style: solid; }
|
||||
.border-transparent { border-color: transparent; }
|
||||
.border-gray-100 { border-color: #f3f4f6; }
|
||||
.border-gray-200 { border-color: #e5e7eb; }
|
||||
.border-gray-300 { border-color: #d1d5db; }
|
||||
.border-blue-500 { border-color: #3b82f6; }
|
||||
.border-blue-600 { border-color: #2563eb; }
|
||||
.border-red-200 { border-color: #fecaca; }
|
||||
|
||||
/* ── Border radius ──────────────────────────────────────────────────────── */
|
||||
.rounded { border-radius: 0.25rem; }
|
||||
.rounded-md { border-radius: 0.375rem; }
|
||||
.rounded-lg { border-radius: 0.5rem; }
|
||||
.rounded-xl { border-radius: 0.75rem; }
|
||||
.rounded-full { border-radius: 9999px; }
|
||||
|
||||
/* ── Shadows ────────────────────────────────────────────────────────────── */
|
||||
.shadow { box-shadow: 0 1px 3px 0 rgb(0 0 0/.1),0 1px 2px -1px rgb(0 0 0/.1); }
|
||||
.shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0/.05); }
|
||||
.shadow-md { box-shadow: 0 4px 6px -1px rgb(0 0 0/.1),0 2px 4px -2px rgb(0 0 0/.1); }
|
||||
.shadow-lg { box-shadow: 0 10px 15px -3px rgb(0 0 0/.1),0 4px 6px -4px rgb(0 0 0/.1); }
|
||||
.shadow-xl { box-shadow: 0 20px 25px -5px rgb(0 0 0/.1),0 8px 10px -6px rgb(0 0 0/.1); }
|
||||
|
||||
/* ── Overflow / z-index ─────────────────────────────────────────────────── */
|
||||
.overflow-hidden { overflow: hidden; }
|
||||
.overflow-y-auto { overflow-y: auto; }
|
||||
.z-50 { z-index: 50; }
|
||||
|
||||
/* ── Misc ───────────────────────────────────────────────────────────────── */
|
||||
.opacity-50 { opacity: 0.5; }
|
||||
.opacity-75 { opacity: 0.75; }
|
||||
.cursor-not-allowed { cursor: not-allowed; }
|
||||
|
||||
/* ── Pseudo-classes ─────────────────────────────────────────────────────── */
|
||||
.hover\:bg-gray-50:hover { background-color: #f9fafb; }
|
||||
.hover\:bg-gray-100:hover { background-color: #f3f4f6; }
|
||||
.hover\:bg-blue-200:hover { background-color: #bfdbfe; }
|
||||
.hover\:bg-blue-700:hover { background-color: #1d4ed8; }
|
||||
.hover\:bg-green-200:hover { background-color: #bbf7d0; }
|
||||
.hover\:bg-red-200:hover { background-color: #fecaca; }
|
||||
.hover\:bg-red-700:hover { background-color: #b91c1c; }
|
||||
.hover\:text-gray-500:hover { color: #6b7280; }
|
||||
.hover\:text-gray-700:hover { color: #374151; }
|
||||
.hover\:text-gray-800:hover { color: #1f2937; }
|
||||
.hover\:text-blue-600:hover { color: #2563eb; }
|
||||
.hover\:text-red-800:hover { color: #991b1b; }
|
||||
.hover\:border-blue-300:hover { border-color: #93c5fd; }
|
||||
|
||||
.focus\:outline-none:focus { outline: none; }
|
||||
.focus\:ring-2:focus { box-shadow: 0 0 0 2px #3b82f6; }
|
||||
|
||||
.disabled\:opacity-50:disabled { opacity: 0.5; }
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Exam-marker types — mirror the FastAPI /api/exam contract (see api routers/exam/schemas.py).
|
||||
* Supabase is source of truth for this operational data; the graph (cc.public.exams) joins by
|
||||
* the shared UUIDs (template/question/region ids, exam_code).
|
||||
*/
|
||||
|
||||
export type ExamTemplateStatus = 'draft' | 'ready' | 'archived';
|
||||
|
||||
export interface ExamTemplate {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string | null;
|
||||
exam_id: string | null;
|
||||
exam_code: string | null;
|
||||
source_file_id: string | null;
|
||||
page_count: number | null;
|
||||
institute_id: string;
|
||||
teacher_id: string;
|
||||
status: ExamTemplateStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTemplatePayload {
|
||||
title: string;
|
||||
subject?: string;
|
||||
exam_id?: string;
|
||||
exam_code?: string;
|
||||
source_file_id?: string;
|
||||
page_count?: number;
|
||||
institute_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTemplateMetaPayload {
|
||||
title?: string;
|
||||
subject?: string | null;
|
||||
page_count?: number | null;
|
||||
status?: ExamTemplateStatus;
|
||||
}
|
||||
|
||||
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
||||
export interface ExamQuestion {
|
||||
id: string;
|
||||
template_id: string;
|
||||
parent_id: string | null;
|
||||
label: string;
|
||||
order: number;
|
||||
max_marks: number;
|
||||
answer_type: string | null;
|
||||
mcq_options: unknown | null;
|
||||
mark_scheme: Record<string, unknown>;
|
||||
is_container: boolean;
|
||||
spec_ref: string | null;
|
||||
bounds?: Record<string, number> | null;
|
||||
page?: number | null;
|
||||
}
|
||||
|
||||
export type ExamResponseAreaKind =
|
||||
| 'response'
|
||||
| 'context'
|
||||
| 'question_number'
|
||||
| 'mark_area'
|
||||
| 'reference'
|
||||
| 'furniture';
|
||||
|
||||
export interface ExamResponseArea {
|
||||
id: string;
|
||||
question_id: string;
|
||||
template_id: string;
|
||||
page: number;
|
||||
bounds: Record<string, number>;
|
||||
kind: ExamResponseAreaKind;
|
||||
response_form: string | null;
|
||||
context_type?: string | null;
|
||||
source: 'manual' | 'ai';
|
||||
confirmed: boolean;
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
export interface ExamBoundary {
|
||||
id: string;
|
||||
template_id: string;
|
||||
question_id: string | null;
|
||||
label: string | null;
|
||||
page_index: number;
|
||||
y: number;
|
||||
bounds: Record<string, number> | null;
|
||||
source: 'manual' | 'ai';
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
export interface ExamTemplateDetail extends ExamTemplate {
|
||||
questions: ExamQuestion[];
|
||||
response_areas: ExamResponseArea[];
|
||||
boundaries: ExamBoundary[];
|
||||
}
|
||||
@@ -27,6 +27,15 @@ export interface Class {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_active: boolean;
|
||||
code?: string;
|
||||
institute_id?: string;
|
||||
}
|
||||
|
||||
export interface ClassWithRole extends Class {
|
||||
role: 'teacher' | 'student' | 'assistant';
|
||||
status: string;
|
||||
class_id?: string;
|
||||
is_primary_teacher?: boolean;
|
||||
}
|
||||
|
||||
export interface ClassTeacher {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import { BaseBoxShapeUtil, HTMLContainer, toDomPrecision } from '@tldraw/tldraw'
|
||||
import { CCBaseShape } from './cc-types'
|
||||
import { CC_BASE_STYLE_CONSTANTS } from './cc-styles'
|
||||
import { logger } from '../../../debugConfig'
|
||||
|
||||
export interface ToolbarItem {
|
||||
@@ -40,44 +39,32 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
|
||||
|
||||
component(shape: T) {
|
||||
const {
|
||||
props: { w, h, isLocked },
|
||||
props: { w, h, isLocked, headerColor },
|
||||
} = shape
|
||||
const toolbarItems = this.getToolbarItems(shape)
|
||||
|
||||
return (
|
||||
<HTMLContainer
|
||||
id={shape.id}
|
||||
className="cc-shape-container"
|
||||
style={{
|
||||
width: toDomPrecision(w),
|
||||
height: toDomPrecision(h),
|
||||
backgroundColor: shape.props.headerColor,
|
||||
borderRadius: CC_BASE_STYLE_CONSTANTS.CONTAINER.borderRadius,
|
||||
boxShadow: CC_BASE_STYLE_CONSTANTS.CONTAINER.boxShadow,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
'--shape-header-color': headerColor,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: shape.props.headerColor,
|
||||
padding: CC_BASE_STYLE_CONSTANTS.HEADER.padding,
|
||||
height: CC_BASE_STYLE_CONSTANTS.HEADER.height,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: isLocked ? 'not-allowed' : 'move',
|
||||
pointerEvents: 'all',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
className="cc-shape-header"
|
||||
style={{ cursor: isLocked ? 'not-allowed' : 'move' }}
|
||||
>
|
||||
<span style={{ color: 'white', fontWeight: 'bold' }}>{shape.props.title}</span>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', pointerEvents: 'all' }}>
|
||||
<span className="shape-title">{shape.props.title}</span>
|
||||
<div className="cc-shape-toolbar">
|
||||
{toolbarItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
title={item.label}
|
||||
style={{ opacity: item.isActive ? 1 : 0.7 }}
|
||||
onClick={(e) => {
|
||||
logger.info('cc-base-shape-util', 'toolbar item clicked', item.id)
|
||||
e.preventDefault()
|
||||
@@ -89,46 +76,19 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '4px',
|
||||
cursor: 'pointer',
|
||||
color: 'white',
|
||||
opacity: item.isActive ? 1 : 0.7,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'all',
|
||||
fontSize: '16px',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
zIndex: 100,
|
||||
userSelect: 'none',
|
||||
position: 'relative',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ pointerEvents: 'none' }}>
|
||||
{item.icon}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{isLocked && <span style={{ color: 'white' }}>🔒</span>}
|
||||
{isLocked && <span style={{ color: 'var(--cc-text-inverse)' }}>🔒</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: CC_BASE_STYLE_CONSTANTS.HEADER.height,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: 'auto',
|
||||
padding: CC_BASE_STYLE_CONSTANTS.CONTENT.padding,
|
||||
backgroundColor: shape.props.backgroundColor,
|
||||
}}
|
||||
<div
|
||||
className="cc-shape-content"
|
||||
style={{ backgroundColor: shape.props.backgroundColor }}
|
||||
>
|
||||
{this.renderContent(shape)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { Editor, TLStoreEventInfo } from '@tldraw/tldraw';
|
||||
import { useTranscriptionStore } from '../../stores/transcriptionStore';
|
||||
import { API_BASE } from '../../../../config/apiConfig';
|
||||
|
||||
export class CanvasEventLogger {
|
||||
private editor: Editor | null = null;
|
||||
@@ -221,7 +222,7 @@ export class CanvasEventLogger {
|
||||
this.buffer = [];
|
||||
|
||||
try {
|
||||
const response = await fetch('http://192.168.0.64:8000/transcribe/canvas-events', {
|
||||
const response = await fetch(`${API_BASE}/transcribe/canvas-events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
|
||||
import { CCBaseShape } from '../cc-types'
|
||||
import { ccGraphShapeProps, getDefaultCCJournalNodeProps } from './cc-graph-props'
|
||||
import { getNodeStyles, NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
|
||||
import { CCJournalNodeProps } from './cc-graph-types'
|
||||
|
||||
export interface CCJournalNodeShape extends CCBaseShape {
|
||||
type: 'cc-journal-node'
|
||||
props: CCJournalNodeProps
|
||||
}
|
||||
|
||||
export class CCJournalNodeShapeUtil extends CCBaseShapeUtil<CCJournalNodeShape> {
|
||||
static type = 'cc-journal-node' as const
|
||||
static props = ccGraphShapeProps['cc-journal-node']
|
||||
|
||||
getDefaultProps(): CCJournalNodeShape['props'] {
|
||||
const defaultProps = getDefaultCCJournalNodeProps()
|
||||
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCJournalNodeShapeUtil.type]] ?? NODE_THEMES.resource
|
||||
return {
|
||||
...defaultProps,
|
||||
headerColor: theme.headerColor,
|
||||
}
|
||||
}
|
||||
|
||||
DefaultComponent = () => null
|
||||
|
||||
renderContent = (shape: CCJournalNodeShape) => {
|
||||
const styles = getNodeStyles(shape.type)
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={{ ...styles.header, color: shape.props.headerColor }}>Journal</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-2)' }}>Personal teaching journal</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { CCBaseShapeUtil } from '../CCBaseShapeUtil'
|
||||
import { CCBaseShape } from '../cc-types'
|
||||
import { ccGraphShapeProps, getDefaultCCPlannerNodeProps } from './cc-graph-props'
|
||||
import { getNodeStyles, NODE_THEMES, NODE_TYPE_THEMES } from './cc-graph-styles'
|
||||
import { CCPlannerNodeProps } from './cc-graph-types'
|
||||
|
||||
export interface CCPlannerNodeShape extends CCBaseShape {
|
||||
type: 'cc-planner-node'
|
||||
props: CCPlannerNodeProps
|
||||
}
|
||||
|
||||
export class CCPlannerNodeShapeUtil extends CCBaseShapeUtil<CCPlannerNodeShape> {
|
||||
static type = 'cc-planner-node' as const
|
||||
static props = ccGraphShapeProps['cc-planner-node']
|
||||
|
||||
getDefaultProps(): CCPlannerNodeShape['props'] {
|
||||
const defaultProps = getDefaultCCPlannerNodeProps()
|
||||
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCPlannerNodeShapeUtil.type]] ?? NODE_THEMES.resource
|
||||
return {
|
||||
...defaultProps,
|
||||
headerColor: theme.headerColor,
|
||||
}
|
||||
}
|
||||
|
||||
DefaultComponent = () => null
|
||||
|
||||
renderContent = (shape: CCPlannerNodeShape) => {
|
||||
const styles = getNodeStyles(shape.type)
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={{ ...styles.header, color: shape.props.headerColor }}>Planner</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-2)' }}>Lesson and weekly planner</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,16 @@ export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableL
|
||||
|
||||
getDefaultProps(): CCTimetableLessonNodeShape['props'] {
|
||||
const defaultProps = getDefaultCCTimetableLessonNodeProps()
|
||||
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCTimetableLessonNodeShapeUtil.type]]
|
||||
return {
|
||||
...defaultProps,
|
||||
headerColor: theme.headerColor,
|
||||
headerColor: '',
|
||||
school_db_name: '',
|
||||
school_period_id: '',
|
||||
subject_class: '',
|
||||
date: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
period_code: '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,34 +35,34 @@ export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableL
|
||||
|
||||
renderContent = (shape: CCTimetableLessonNodeShape) => {
|
||||
const styles = getNodeStyles(shape.type)
|
||||
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<NodeProperty
|
||||
<NodeProperty
|
||||
label="Subject Class"
|
||||
value={shape.props.subject_class}
|
||||
labelStyle={styles.property.label}
|
||||
valueStyle={styles.property.value}
|
||||
/>
|
||||
<NodeProperty
|
||||
<NodeProperty
|
||||
label="Date"
|
||||
value={shape.props.date}
|
||||
labelStyle={styles.property.label}
|
||||
valueStyle={styles.property.value}
|
||||
/>
|
||||
<NodeProperty
|
||||
<NodeProperty
|
||||
label="Start Time"
|
||||
value={shape.props.start_time}
|
||||
labelStyle={styles.property.label}
|
||||
valueStyle={styles.property.value}
|
||||
/>
|
||||
<NodeProperty
|
||||
<NodeProperty
|
||||
label="End Time"
|
||||
value={shape.props.end_time}
|
||||
labelStyle={styles.property.label}
|
||||
valueStyle={styles.property.value}
|
||||
/>
|
||||
<NodeProperty
|
||||
<NodeProperty
|
||||
label="Period Code"
|
||||
value={shape.props.period_code}
|
||||
labelStyle={styles.property.label}
|
||||
@@ -65,4 +71,4 @@ export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableL
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +272,14 @@ export const ccGraphShapeProps = {
|
||||
school_db_name: T.string,
|
||||
school_period_id: T.string,
|
||||
},
|
||||
'cc-journal-node': {
|
||||
...graphBaseProps,
|
||||
path: T.string,
|
||||
},
|
||||
'cc-planner-node': {
|
||||
...graphBaseProps,
|
||||
path: T.string,
|
||||
},
|
||||
} as const
|
||||
|
||||
// Default props getters
|
||||
@@ -535,6 +543,8 @@ export const getDefaultCCTimetableLessonNodeProps = () => ({
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
period_code: '',
|
||||
school_db_name: '',
|
||||
school_period_id: '',
|
||||
})
|
||||
|
||||
export const getDefaultCCPlannedLessonNodeProps = () => ({
|
||||
@@ -640,3 +650,17 @@ export const getDefaultCCUserTeacherTimetableNodeProps = () => ({
|
||||
school_db_name: '',
|
||||
school_timetable_id: '',
|
||||
})
|
||||
|
||||
export const getDefaultCCJournalNodeProps = () => ({
|
||||
...getDefaultBaseProps(),
|
||||
title: 'Journal',
|
||||
__primarylabel__: 'Journal',
|
||||
path: '',
|
||||
})
|
||||
|
||||
export const getDefaultCCPlannerNodeProps = () => ({
|
||||
...getDefaultBaseProps(),
|
||||
title: 'Planner',
|
||||
__primarylabel__: 'Planner',
|
||||
path: '',
|
||||
})
|
||||
@@ -22,7 +22,6 @@ import { CCSubjectNodeShape, CCSubjectNodeShapeUtil } from './CCSubjectNodeShape
|
||||
import { CCTopicNodeShape, CCTopicNodeShapeUtil } from './CCTopicNodeShapeUtil'
|
||||
import { CCTopicLessonNodeShape, CCTopicLessonNodeShapeUtil } from './CCTopicLessonNodeShapeUtil'
|
||||
import { CCLearningStatementNodeShape, CCLearningStatementNodeShapeUtil } from './CCLearningStatementNodeShapeUtil'
|
||||
import { CCScienceLabNodeShape, CCScienceLabNodeShapeUtil } from './CCScienceLabNodeShapeUtil'
|
||||
import { CCSchoolTimetableNodeShape, CCSchoolTimetableNodeShapeUtil } from './CCSchoolTimetableNodeShapeUtil'
|
||||
import { CCAcademicYearNodeShape, CCAcademicYearNodeShapeUtil } from './CCAcademicYearNodeShapeUtil'
|
||||
import { CCAcademicTermNodeShape, CCAcademicTermNodeShapeUtil } from './CCAcademicTermNodeShapeUtil'
|
||||
@@ -31,12 +30,11 @@ import { CCAcademicDayNodeShape, CCAcademicDayNodeShapeUtil } from './CCAcademic
|
||||
import { CCAcademicPeriodNodeShape, CCAcademicPeriodNodeShapeUtil } from './CCAcademicPeriodNodeShapeUtil'
|
||||
import { CCRegistrationPeriodNodeShape, CCRegistrationPeriodNodeShapeUtil } from './CCRegistrationPeriodNodeShapeUtil'
|
||||
import { CCTeacherTimetableNodeShape, CCTeacherTimetableNodeShapeUtil } from './CCTeacherTimetableNodeShapeUtil'
|
||||
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
|
||||
import { CCPlannedLessonNodeShape, CCPlannedLessonNodeShapeUtil } from './CCPlannedLessonNodeShapeUtil'
|
||||
import { CCDepartmentStructureNodeShape, CCDepartmentStructureNodeShapeUtil } from './CCDepartmentStructureNodeShapeUtil'
|
||||
import { CCUserTeacherTimetableNodeShape, CCUserTeacherTimetableNodeShapeUtil } from './CCUserTeacherTimetableNodeShapeUtil'
|
||||
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
|
||||
|
||||
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCUserTimetableLessonNodeShapeUtil'
|
||||
import { CCScienceLabNodeShape, CCScienceLabNodeShapeUtil } from './CCScienceLabNodeShapeUtil'
|
||||
// Create a const object with all node types
|
||||
export const NODE_SHAPE_TYPES = {
|
||||
USER: CCUserNodeShapeUtil.type,
|
||||
@@ -82,7 +80,7 @@ export const NODE_SHAPE_TYPES = {
|
||||
export type NodeShapeType = typeof NODE_SHAPE_TYPES[keyof typeof NODE_SHAPE_TYPES];
|
||||
|
||||
// Define AllNodeShapes as a union type of all shape types
|
||||
export type AllNodeShapes =
|
||||
export type AllNodeShapes =
|
||||
| CCUserNodeShape
|
||||
| CCTeacherNodeShape
|
||||
| CCStudentNodeShape
|
||||
@@ -118,8 +116,7 @@ export type AllNodeShapes =
|
||||
| CCTimetableLessonNodeShape
|
||||
| CCPlannedLessonNodeShape
|
||||
| CCDepartmentStructureNodeShape
|
||||
| CCUserTeacherTimetableNodeShape
|
||||
| CCTimetableLessonNodeShape;
|
||||
| CCUserTeacherTimetableNodeShape;
|
||||
|
||||
// Export all shape utils in an object for easy access
|
||||
export const ShapeUtils = {
|
||||
@@ -159,17 +156,16 @@ export const ShapeUtils = {
|
||||
[CCPlannedLessonNodeShapeUtil.type]: CCPlannedLessonNodeShapeUtil,
|
||||
[CCDepartmentStructureNodeShapeUtil.type]: CCDepartmentStructureNodeShapeUtil,
|
||||
[CCUserTeacherTimetableNodeShapeUtil.type]: CCUserTeacherTimetableNodeShapeUtil,
|
||||
[CCTimetableLessonNodeShapeUtil.type]: CCTimetableLessonNodeShapeUtil,
|
||||
} as const;
|
||||
|
||||
// Add a type guard to check if a shape is a valid node shape
|
||||
export const isValidNodeShape = (shape: TLShape): shape is AllNodeShapes => {
|
||||
return shape &&
|
||||
typeof shape.type === 'string' &&
|
||||
return shape &&
|
||||
typeof shape.type === 'string' &&
|
||||
Object.values(NODE_SHAPE_TYPES).includes(shape.type as NodeShapeType);
|
||||
};
|
||||
|
||||
// Add a type guard to check if a type string is a valid node type
|
||||
export const isValidNodeType = (type: string): type is NodeShapeType => {
|
||||
return Object.values(NODE_SHAPE_TYPES).includes(type as NodeShapeType);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -148,12 +148,18 @@ export const NODE_TYPE_THEMES: Record<string, keyof typeof NODE_THEMES> = {
|
||||
'cc-department-node': 'resource',
|
||||
'cc-room-node': 'resource',
|
||||
'cc-subject-class-node': 'resource',
|
||||
// Timetable nodes (missing from original map)
|
||||
'cc-teacher-timetable-node': 'academic',
|
||||
'cc-department-structure-node': 'resource',
|
||||
// Personal nodes
|
||||
'cc-journal-node': 'calendar',
|
||||
'cc-planner-node': 'calendar',
|
||||
} as const
|
||||
|
||||
// Helper function to get theme for a node type
|
||||
export const getNodeTheme = (nodeType: string) => {
|
||||
const themeKey = NODE_TYPE_THEMES[nodeType]
|
||||
return themeKey ? NODE_THEMES[themeKey] : NODE_THEMES.resource // Default to resource theme
|
||||
return (themeKey ? NODE_THEMES[themeKey] : null) ?? NODE_THEMES.resource
|
||||
}
|
||||
|
||||
// Helper function to get theme from primary label
|
||||
|
||||
@@ -279,6 +279,14 @@ export type CCTimetableLessonNodeProps = CCGraphShapeProps & {
|
||||
}
|
||||
|
||||
// Define a type-safe mapping of node types to their configurations
|
||||
export type CCJournalNodeProps = CCGraphShapeProps & {
|
||||
path: string
|
||||
}
|
||||
|
||||
export type CCPlannerNodeProps = CCGraphShapeProps & {
|
||||
path: string
|
||||
}
|
||||
|
||||
export type CCNodeTypes = {
|
||||
User: { props: CCUserNodeProps }
|
||||
Developer: { props: CCUserNodeProps }
|
||||
@@ -316,6 +324,8 @@ export type CCNodeTypes = {
|
||||
DepartmentStructure: { props: CCDepartmentStructureNodeProps }
|
||||
UserTeacherTimetable: { props: CCUserTeacherTimetableNodeProps }
|
||||
UserTimetableLesson: { props: CCTimetableLessonNodeProps }
|
||||
Journal: { props: CCJournalNodeProps }
|
||||
Planner: { props: CCPlannerNodeProps }
|
||||
}
|
||||
|
||||
// Helper function to get shape type from node type
|
||||
@@ -376,5 +386,7 @@ export const isValidNodeType = (type: string): type is keyof CCNodeTypes => {
|
||||
DepartmentStructure: true,
|
||||
UserTeacherTimetable: true,
|
||||
UserTimetableLesson: true,
|
||||
Journal: true,
|
||||
Planner: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Unit tests for CCLiveTranscriptionShapeUtil
|
||||
*
|
||||
* Intended production location:
|
||||
* src/utils/tldraw/cc-base/cc-transcription/CCLiveTranscriptionShapeUtil.test.ts
|
||||
*
|
||||
* Run from classroom-copilot-app root:
|
||||
* npx vitest run transcription-shape
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { Editor, createShapeId, createTLStore } from '@tldraw/tldraw'
|
||||
import { CCLiveTranscriptionShapeUtil, type CCLiveTranscriptionShape, type TranscriptionSegment } from './CCLiveTranscriptionShapeUtil'
|
||||
import { CCSlideShapeUtil } from '../cc-slideshow/CCSlideShapeUtil'
|
||||
import { CCSlideShowShapeUtil } from '../cc-slideshow/CCSlideShowShapeUtil'
|
||||
import { getDefaultCCLiveTranscriptionProps } from '../cc-props'
|
||||
|
||||
// ─── Mock TranscriptionManager ──────────────────────────────────────────────
|
||||
// Must be hoisted before the module under test is imported in a real project.
|
||||
// Here we patch the module directly since we control the import order.
|
||||
|
||||
// Declare symbols before vi.mock so hoisting/factory capture stays consistent.
|
||||
const mockStartTranscription = () => {}
|
||||
const mockStopTranscription = () => {}
|
||||
|
||||
vi.mock('./TranscriptionManager', () => {
|
||||
const start = vi.fn()
|
||||
const stop = vi.fn()
|
||||
Object.assign(globalThis, { __mockStartTranscription: start, __mockStopTranscription: stop })
|
||||
return {
|
||||
TranscriptionManager: {
|
||||
getManager: vi.fn().mockReturnValue({ startTranscription: start, stopTranscription: stop }),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const sm = () => (globalThis as unknown as Record<string, any>).__mockStartTranscription
|
||||
const st = () => (globalThis as unknown as Record<string, any>).__mockStopTranscription
|
||||
|
||||
const clearMocks = () => { sm().mockClear(); st().mockClear() }
|
||||
|
||||
// Provide a minimal matchMedia so tldraw's UserPreferencesManager
|
||||
// can call addEventListener/removeEventListener in jsdom.
|
||||
if (typeof globalThis.matchMedia !== 'function') {
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
matches: false,
|
||||
media: '',
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
} as unknown as MediaQueryList))
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Minimal shape utils needed to boot the Editor for transcription tests. */
|
||||
const shapeUtils = [CCLiveTranscriptionShapeUtil, CCSlideShapeUtil, CCSlideShowShapeUtil]
|
||||
|
||||
function makeEditor(): Editor {
|
||||
const store = createTLStore({ shapeUtils, bindingUtils: [] })
|
||||
return new Editor({
|
||||
shapeUtils,
|
||||
bindingUtils: [],
|
||||
tools: [],
|
||||
store,
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
}
|
||||
|
||||
/** Create a transcription shape with optional prop overrides and return its id. */
|
||||
function createTranscriptionShape(
|
||||
editor: Editor,
|
||||
overrides: Partial<CCLiveTranscriptionShape['props']> = {}
|
||||
) {
|
||||
const id = createShapeId()
|
||||
editor.createShape<CCLiveTranscriptionShape>({
|
||||
id,
|
||||
type: 'cc-live-transcription',
|
||||
x: 0,
|
||||
y: 0,
|
||||
props: {
|
||||
...getDefaultCCLiveTranscriptionProps(),
|
||||
...overrides,
|
||||
} as CCLiveTranscriptionShape['props'],
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/** Typed getter for the shape record. */
|
||||
function getShape(editor: Editor, id: ReturnType<typeof createShapeId>) {
|
||||
return editor.getShape<CCLiveTranscriptionShape>(id)!
|
||||
}
|
||||
|
||||
/** Build a finished TranscriptionSegment fixture. */
|
||||
function makeSegment(text: string, overrides: Partial<TranscriptionSegment> = {}): TranscriptionSegment {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
text,
|
||||
completed: true,
|
||||
start: '0.000',
|
||||
end: '1.000',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test Suite ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('CCLiveTranscriptionShapeUtil', () => {
|
||||
let editor: Editor
|
||||
let util: CCLiveTranscriptionShapeUtil
|
||||
|
||||
beforeEach(() => {
|
||||
editor = makeEditor()
|
||||
util = editor.getShapeUtil<CCLiveTranscriptionShapeUtil>('cc-live-transcription')
|
||||
clearMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
editor.dispose()
|
||||
})
|
||||
|
||||
// ── getDefaultProps ─────────────────────────────────────────────────────
|
||||
|
||||
describe('getDefaultProps()', () => {
|
||||
it('returns the expected default structure', () => {
|
||||
const defaults = util.getDefaultProps()
|
||||
|
||||
expect(defaults).toMatchObject({
|
||||
isRecording: false,
|
||||
segments: [],
|
||||
currentSegment: undefined,
|
||||
lastProcessedSegment: undefined,
|
||||
})
|
||||
// Base props present
|
||||
expect(typeof defaults.title).toBe('string')
|
||||
expect(typeof defaults.w).toBe('number')
|
||||
expect(typeof defaults.h).toBe('number')
|
||||
expect(typeof defaults.headerColor).toBe('string')
|
||||
expect(typeof defaults.backgroundColor).toBe('string')
|
||||
expect(defaults.isLocked).toBe(false)
|
||||
})
|
||||
|
||||
it('segments array is empty, not null or undefined', () => {
|
||||
const { segments } = util.getDefaultProps()
|
||||
expect(Array.isArray(segments)).toBe(true)
|
||||
expect(segments).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── updateText — unconfirmed (live partial) ─────────────────────────────
|
||||
|
||||
describe('updateText() — unconfirmed segment (isConfirmed = false)', () => {
|
||||
it('updates only currentSegment, does not touch the segments array', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
util.updateText(id, 'hello wor', false, { start: 0, end: 0.8 })
|
||||
|
||||
const shape = getShape(editor, id)
|
||||
expect(shape.props.segments).toHaveLength(0)
|
||||
expect(shape.props.currentSegment).toMatchObject({
|
||||
text: 'hello wor',
|
||||
completed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('overwrites currentSegment when called again with new partial text', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
util.updateText(id, 'hello wor', false, { start: 0, end: 0.8 })
|
||||
util.updateText(id, 'hello world', false, { start: 0, end: 1.2 })
|
||||
|
||||
const shape = getShape(editor, id)
|
||||
expect(shape.props.currentSegment?.text).toBe('hello world')
|
||||
expect(shape.props.segments).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does NOT call updateShape when the partial text is unchanged', () => {
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: true,
|
||||
currentSegment: makeSegment('same text', { completed: false }),
|
||||
})
|
||||
|
||||
const spy = vi.spyOn(editor, 'updateShape')
|
||||
util.updateText(id, 'same text', false, { start: 0, end: 1.0 })
|
||||
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// ── updateText — confirmed (completed) segment ─────────────────────────
|
||||
|
||||
describe('updateText() — confirmed segment (isConfirmed = true)', () => {
|
||||
it('appends to segments array when text is new', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
util.updateText(id, 'First sentence.', true, { start: 0, end: 2.1 })
|
||||
|
||||
const shape = getShape(editor, id)
|
||||
expect(shape.props.segments).toHaveLength(1)
|
||||
expect(shape.props.segments[0]).toMatchObject({
|
||||
text: 'First sentence.',
|
||||
completed: true,
|
||||
start: '0.000',
|
||||
end: '2.100',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates lastProcessedSegment to the confirmed text', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
util.updateText(id, 'Second sentence.', true, { start: 2, end: 4 })
|
||||
|
||||
expect(getShape(editor, id).props.lastProcessedSegment).toBe('Second sentence.')
|
||||
})
|
||||
|
||||
it('clears currentSegment when its text matches the confirmed text', () => {
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: true,
|
||||
currentSegment: makeSegment('Final words.', { completed: false }),
|
||||
})
|
||||
|
||||
util.updateText(id, 'Final words.', true, { start: 5, end: 7 })
|
||||
|
||||
expect(getShape(editor, id).props.currentSegment).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves currentSegment when its text differs from the confirmed text', () => {
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: true,
|
||||
currentSegment: makeSegment('still typing...', { completed: false }),
|
||||
})
|
||||
|
||||
util.updateText(id, 'Earlier sentence confirmed.', true, { start: 0, end: 3 })
|
||||
|
||||
expect(getShape(editor, id).props.currentSegment?.text).toBe('still typing...')
|
||||
})
|
||||
|
||||
it('DEDUPLICATION — does not add a segment whose text is already in segments[]', () => {
|
||||
const existingSegment = makeSegment('Duplicate text.')
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: true,
|
||||
segments: [existingSegment],
|
||||
lastProcessedSegment: 'Duplicate text.',
|
||||
})
|
||||
|
||||
util.updateText(id, 'Duplicate text.', true, { start: 10, end: 12 })
|
||||
|
||||
const shape = getShape(editor, id)
|
||||
expect(shape.props.segments).toHaveLength(1)
|
||||
expect(shape.props.segments[0].id).toBe(existingSegment.id)
|
||||
})
|
||||
|
||||
it('DEDUPLICATION — blocks re-add even when lastProcessedSegment differs (isDuplicate text check)', () => {
|
||||
const existingSegment = makeSegment('Already confirmed.')
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: true,
|
||||
segments: [existingSegment],
|
||||
lastProcessedSegment: undefined,
|
||||
})
|
||||
|
||||
util.updateText(id, 'Already confirmed.', true, { start: 15, end: 17 })
|
||||
|
||||
expect(getShape(editor, id).props.segments).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accumulates multiple unique confirmed segments in order', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
util.updateText(id, 'Sentence one.', true, { start: 0, end: 2 })
|
||||
util.updateText(id, 'Sentence two.', true, { start: 2, end: 4 })
|
||||
util.updateText(id, 'Sentence three.', true, { start: 4, end: 6 })
|
||||
|
||||
const { segments } = getShape(editor, id).props
|
||||
expect(segments).toHaveLength(3)
|
||||
expect(segments.map((s) => s.text)).toEqual([
|
||||
'Sentence one.',
|
||||
'Sentence two.',
|
||||
'Sentence three.',
|
||||
])
|
||||
})
|
||||
|
||||
it('assigns unique ids to each confirmed segment', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
util.updateText(id, 'Alpha.', true, { start: 0, end: 1 })
|
||||
util.updateText(id, 'Beta.', true, { start: 1, end: 2 })
|
||||
|
||||
const { segments } = getShape(editor, id).props
|
||||
expect(segments[0].id).not.toBe(segments[1].id)
|
||||
})
|
||||
|
||||
it('formats numeric start/end timestamps to 3 decimal places', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
util.updateText(id, 'Timestamped.', true, { start: 1.1, end: 2.5678 })
|
||||
|
||||
const seg = getShape(editor, id).props.segments[0]
|
||||
expect(seg.start).toBe('1.100')
|
||||
expect(seg.end).toBe('2.568')
|
||||
})
|
||||
})
|
||||
|
||||
// ── toggleRecording ────────────────────────────────────────────────────
|
||||
|
||||
describe('toggleRecording() — via private accessor', () => {
|
||||
// TypeScript private methods are still callable at runtime; we use
|
||||
// bracket notation to bypass the access check cleanly in tests.
|
||||
const toggle = (u: CCLiveTranscriptionShapeUtil, shape: CCLiveTranscriptionShape) =>
|
||||
(u as unknown as { toggleRecording(s: CCLiveTranscriptionShape): void }).toggleRecording(shape)
|
||||
|
||||
it('STARTING — sets isRecording to true', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: false })
|
||||
const shape = getShape(editor, id)
|
||||
|
||||
toggle(util, shape)
|
||||
|
||||
expect(getShape(editor, id).props.isRecording).toBe(true)
|
||||
})
|
||||
|
||||
it('STARTING — resets segments to an empty array', () => {
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: false,
|
||||
segments: [makeSegment('old transcript'), makeSegment('more old text')],
|
||||
})
|
||||
|
||||
toggle(util, getShape(editor, id))
|
||||
|
||||
expect(getShape(editor, id).props.segments).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('STARTING — clears currentSegment and lastProcessedSegment', () => {
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: false,
|
||||
currentSegment: makeSegment('partial', { completed: false }),
|
||||
lastProcessedSegment: 'some prior segment',
|
||||
})
|
||||
|
||||
toggle(util, getShape(editor, id))
|
||||
|
||||
const props = getShape(editor, id).props
|
||||
expect(props.currentSegment).toBeUndefined()
|
||||
expect(props.lastProcessedSegment).toBeUndefined()
|
||||
})
|
||||
|
||||
it('STARTING — calls TranscriptionManager.startTranscription with the shape id', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: false })
|
||||
const shape = getShape(editor, id)
|
||||
|
||||
toggle(util, shape)
|
||||
|
||||
expect(sm()).toHaveBeenCalledOnce()
|
||||
expect(sm()).toHaveBeenCalledWith(id)
|
||||
})
|
||||
|
||||
it('STOPPING — sets isRecording to false', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
const shape = getShape(editor, id)
|
||||
|
||||
toggle(util, shape)
|
||||
|
||||
expect(getShape(editor, id).props.isRecording).toBe(false)
|
||||
})
|
||||
|
||||
it('STOPPING — preserves existing segments (does not wipe them)', () => {
|
||||
const seg1 = makeSegment('Keep me.')
|
||||
const seg2 = makeSegment('Keep me too.')
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: true,
|
||||
segments: [seg1, seg2],
|
||||
})
|
||||
|
||||
toggle(util, getShape(editor, id))
|
||||
|
||||
const { segments } = getShape(editor, id).props
|
||||
expect(segments).toHaveLength(2)
|
||||
expect(segments[0].text).toBe('Keep me.')
|
||||
expect(segments[1].text).toBe('Keep me too.')
|
||||
})
|
||||
|
||||
it('STOPPING — calls TranscriptionManager.stopTranscription', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
toggle(util, getShape(editor, id))
|
||||
|
||||
expect(st()).toHaveBeenCalledOnce()
|
||||
expect(sm()).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('start-stop cycle leaves shape in consistent state', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: false })
|
||||
|
||||
toggle(util, getShape(editor, id))
|
||||
expect(getShape(editor, id).props.isRecording).toBe(true)
|
||||
|
||||
util.updateText(id, 'Live lesson note.', true, { start: 0, end: 3 })
|
||||
|
||||
toggle(util, getShape(editor, id))
|
||||
|
||||
const props = getShape(editor, id).props
|
||||
expect(props.isRecording).toBe(false)
|
||||
expect(props.segments).toHaveLength(1)
|
||||
expect(props.segments[0].text).toBe('Live lesson note.')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Edge cases ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateText() — edge cases', () => {
|
||||
it('does nothing if the shape id does not exist in the store', () => {
|
||||
const ghostId = createShapeId('nonexistent')
|
||||
expect(() => util.updateText(ghostId, 'text', true, { start: 0, end: 1 })).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles empty string text without crashing', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
expect(() => util.updateText(id, '', true, { start: 0, end: 0 })).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles string start/end metadata (passed as already-formatted strings)', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
util.updateText(id, 'String timestamps.', true, { start: '1.234' as unknown as number, end: '5.678' as unknown as number })
|
||||
|
||||
const seg = getShape(editor, id).props.segments[0]
|
||||
expect(typeof seg.start).toBe('string')
|
||||
expect(typeof seg.end).toBe('string')
|
||||
})
|
||||
|
||||
it('handles missing metadata gracefully, falling back to "0.000"', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
util.updateText(id, 'No timestamps.', true, undefined as unknown as { start: number; end: number })
|
||||
|
||||
const seg = getShape(editor, id).props.segments[0]
|
||||
expect(seg.start).toBe('0.000')
|
||||
expect(seg.end).toBe('0.000')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { TranscriptionManager } from '../cc-transcription/TranscriptionManager';
|
||||
import { ccShapeProps, getDefaultCCLiveTranscriptionProps } from '../cc-props';
|
||||
import { ccShapeMigrations } from '../cc-migrations';
|
||||
import { CC_BASE_STYLE_CONSTANTS } from '../cc-styles';
|
||||
import { logger } from '../../../../debugConfig';
|
||||
|
||||
export interface TranscriptionSegment {
|
||||
id: string
|
||||
@@ -83,7 +84,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
||||
height: '48px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
backgroundColor: isRecording ? '#f44336' : '#4CAF50',
|
||||
backgroundColor: isRecording ? 'var(--cc-recording-active)' : 'var(--cc-recording-idle)',
|
||||
color: 'white',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
@@ -105,7 +106,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: isRecording ? '#f44336' : '#4CAF50'
|
||||
color: isRecording ? 'var(--cc-recording-active)' : 'var(--cc-recording-idle)'
|
||||
}}>
|
||||
{isRecording ? 'Recording...' : 'Ready'}
|
||||
</div>
|
||||
@@ -207,11 +208,11 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
||||
}
|
||||
|
||||
private toggleRecording(shape: CCLiveTranscriptionShape) {
|
||||
console.log('🎤 Toggle recording clicked');
|
||||
logger.debug('cc-live-transcription', '🎤 Toggle recording clicked');
|
||||
const { id } = shape;
|
||||
const { isRecording } = shape.props;
|
||||
|
||||
console.log('Current state:', { id, isRecording });
|
||||
logger.debug('cc-live-transcription', 'Current state', { id, isRecording });
|
||||
|
||||
// When starting new recording, preserve existing props but reset segments
|
||||
const newProps = !isRecording ? {
|
||||
@@ -232,13 +233,13 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
||||
});
|
||||
|
||||
const manager = TranscriptionManager.getManager(this.editor);
|
||||
console.log('Got transcription manager');
|
||||
logger.debug('cc-live-transcription', 'Got transcription manager');
|
||||
|
||||
if (!isRecording) {
|
||||
console.log('Starting transcription...');
|
||||
logger.debug('cc-live-transcription', 'Starting transcription...');
|
||||
manager.startTranscription(id);
|
||||
} else {
|
||||
console.log('Stopping transcription...');
|
||||
logger.debug('cc-live-transcription', 'Stopping transcription...');
|
||||
manager.stopTranscription();
|
||||
}
|
||||
}
|
||||
@@ -249,10 +250,10 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
||||
isConfirmed: boolean,
|
||||
metadata?: { start: string | number, end: string | number }
|
||||
) {
|
||||
console.log('📝 Updating text:', { id, text, isConfirmed, metadata });
|
||||
logger.debug('cc-live-transcription', '📝 Updating text', { id, text, isConfirmed, metadata });
|
||||
const shape = this.editor.getShape<CCLiveTranscriptionShape>(id);
|
||||
if (!shape) {
|
||||
console.warn('❌ Shape not found for updating text:', id);
|
||||
logger.warn('cc-live-transcription', '❌ Shape not found for updating text', id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -308,7 +309,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ Text updated');
|
||||
logger.debug('cc-live-transcription', '✅ Text updated');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ export class TranscriptionManager {
|
||||
this.transcriptionService = new TranscriptionService();
|
||||
|
||||
// Set up callback for transcription updates
|
||||
this.transcriptionService.setTranscriptionCallback((text: string, isFinal: boolean, metadata: { start: number, end: number }) => {
|
||||
console.log('📝 Transcription update received:', { text, metadata });
|
||||
this.transcriptionService.setServerSegmentsCallback((segments: Array<{ text: string; start: number; end: number }>, isLastLive: boolean) => {
|
||||
console.log('📝 Transcription update received:', { segments: segments.length, isLastLive });
|
||||
const util = this.editor.getShapeUtil<CCLiveTranscriptionShapeUtil>('cc-live-transcription');
|
||||
if (!util) {
|
||||
console.warn('❌ Shape util not found');
|
||||
@@ -36,6 +36,10 @@ export class TranscriptionManager {
|
||||
}
|
||||
console.log('Found transcription util:', !!util);
|
||||
|
||||
const text = segments.map(s => s.text).join('') || '';
|
||||
const metadata = segments.length ? { start: segments[0].start, end: segments[segments.length - 1].end } : { start: 0, end: 0 };
|
||||
const isFinal = isLastLive;
|
||||
|
||||
// Check if text is stable (same output multiple times)
|
||||
const isStable = text === this.lastText;
|
||||
if (isStable) {
|
||||
|
||||
@@ -18,40 +18,40 @@ export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
|
||||
overflowY: 'auto',
|
||||
maxHeight: '100%',
|
||||
minWidth: '200px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderLeft: '1px solid #ddd',
|
||||
backgroundColor: 'var(--cc-navy-50)',
|
||||
borderLeft: '1px solid var(--cc-border)',
|
||||
},
|
||||
title: {
|
||||
margin: '0 0 10px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
color: 'var(--cc-text-primary)',
|
||||
},
|
||||
line: {
|
||||
padding: '5px',
|
||||
marginBottom: '5px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #eee',
|
||||
color: 'var(--cc-text-primary)',
|
||||
backgroundColor: 'var(--cc-bg-canvas)',
|
||||
border: '1px solid var(--cc-border-strong)',
|
||||
},
|
||||
activeLine: {
|
||||
backgroundColor: '#e3f2fd',
|
||||
border: '1px solid #2196f3',
|
||||
color: '#1565c0',
|
||||
backgroundColor: 'var(--cc-navy-50)',
|
||||
border: '1px solid var(--cc-action-primary)',
|
||||
color: 'var(--cc-action-primary)',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.12)',
|
||||
transform: 'scale(1.02)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
},
|
||||
timestamp: {
|
||||
color: '#666',
|
||||
color: 'var(--cc-text-tertiary)',
|
||||
marginRight: '8px',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
loading: {
|
||||
color: '#666',
|
||||
color: 'var(--cc-text-muted)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
},
|
||||
@@ -64,14 +64,14 @@ export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
|
||||
},
|
||||
button: {
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
border: '1px solid #ddd',
|
||||
backgroundColor: 'var(--cc-navy-50)',
|
||||
border: '1px solid var(--cc-border)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
color: '#333',
|
||||
color: 'var(--cc-text-primary)',
|
||||
fontSize: '14px',
|
||||
'&:hover': {
|
||||
backgroundColor: '#e0e0e0',
|
||||
backgroundColor: 'var(--cc-border-strong)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,976 @@
|
||||
/* ============================================================
|
||||
CLASSROOM COPILOT — DESIGN SYSTEM
|
||||
Aesthetic Direction: Technical Editorial / Industrial Utilitarian
|
||||
|
||||
Philosophy: Drafting table discipline. Every element earns
|
||||
its place. Tone contrast over shadow depth. Grid over flow.
|
||||
============================================================ */
|
||||
|
||||
/* ─── TYPOGRAPHY IMPORTS ───────────────────────────────────── */
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
/* ─── ROOT TOKENS — LIGHT MODE ─────────────────────────────── */
|
||||
:root {
|
||||
/* Typography */
|
||||
--cc-font-sans: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
|
||||
--cc-font-mono: 'IBM Plex Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
|
||||
--cc-text-xs: 0.6875rem; /* 11px */
|
||||
--cc-text-sm: 0.75rem; /* 12px */
|
||||
--cc-text-base: 0.875rem; /* 14px */
|
||||
--cc-text-md: 1rem; /* 16px */
|
||||
--cc-text-lg: 1.125rem; /* 18px */
|
||||
--cc-text-xl: 1.25rem; /* 20px */
|
||||
|
||||
--cc-weight-normal: 400;
|
||||
--cc-weight-medium: 500;
|
||||
--cc-weight-semibold: 600;
|
||||
|
||||
--cc-leading-tight: 1.2;
|
||||
--cc-leading-normal: 1.5;
|
||||
--cc-leading-loose: 1.7;
|
||||
|
||||
--cc-tracking-tight: -0.01em;
|
||||
--cc-tracking-normal: 0;
|
||||
--cc-tracking-wide: 0.04em;
|
||||
--cc-tracking-widest: 0.12em;
|
||||
|
||||
/* ── Color Primitives ── */
|
||||
--cc-navy-950: #0D1117;
|
||||
--cc-navy-900: #14191F;
|
||||
--cc-navy-800: #1C2330;
|
||||
--cc-navy-700: #263040;
|
||||
--cc-navy-600: #344256;
|
||||
--cc-navy-500: #435470;
|
||||
--cc-navy-400: #5A6E8A;
|
||||
--cc-navy-300: #7A8FA8;
|
||||
--cc-navy-200: #A4B4C8;
|
||||
--cc-navy-100: #D2DCE8;
|
||||
--cc-navy-50: #EBF0F5;
|
||||
|
||||
--cc-parchment-950: #2A2519;
|
||||
--cc-parchment-900: #3D3626;
|
||||
--cc-parchment-800: #5C5238;
|
||||
--cc-parchment-700: #7A6E4E;
|
||||
--cc-parchment-600: #9A8C6A;
|
||||
--cc-parchment-500: #B8A882;
|
||||
--cc-parchment-400: #CEC0A0;
|
||||
--cc-parchment-300: #DED2BA;
|
||||
--cc-parchment-200: #EDE5D4;
|
||||
--cc-parchment-100: #F5F2ED;
|
||||
--cc-parchment-50: #FAF8F5;
|
||||
|
||||
--cc-amber-700: #8A5C1A;
|
||||
--cc-amber-600: #A8711F;
|
||||
--cc-amber-500: #C9882A;
|
||||
--cc-amber-400: #D9A24E;
|
||||
--cc-amber-300: #E6BC78;
|
||||
--cc-amber-200: #F0D4A8;
|
||||
--cc-amber-100: #F8EDDA;
|
||||
|
||||
--cc-sage-700: #2E3E36;
|
||||
--cc-sage-600: #3A5046;
|
||||
--cc-sage-500: #4A5E52;
|
||||
--cc-sage-400: #607A6C;
|
||||
--cc-sage-300: #7E9688;
|
||||
--cc-sage-200: #A8BDB4;
|
||||
--cc-sage-100: #D2DDD9;
|
||||
|
||||
--cc-crimson-600: #8B1A1A;
|
||||
--cc-crimson-500: #A82828;
|
||||
--cc-crimson-400: #C44040;
|
||||
--cc-crimson-100: #F5DADA;
|
||||
|
||||
--cc-signal-green-600: #1A5C36;
|
||||
--cc-signal-green-500: #246B42;
|
||||
--cc-signal-green-400: #347A52;
|
||||
--cc-signal-green-100: #D4EDE0;
|
||||
|
||||
/* ── Semantic Color Assignments — Light ── */
|
||||
|
||||
/* Surfaces */
|
||||
--cc-bg-canvas: var(--cc-parchment-100);
|
||||
--cc-bg-panel: var(--cc-parchment-50);
|
||||
--cc-bg-panel-alt: #FFFFFF;
|
||||
--cc-bg-shape: #FFFFFF;
|
||||
--cc-bg-shape-hover: var(--cc-parchment-100);
|
||||
--cc-bg-overlay: rgba(13, 17, 23, 0.48);
|
||||
--cc-bg-code: var(--cc-navy-900);
|
||||
|
||||
/* Shape / card header tones */
|
||||
--cc-header-default: var(--cc-navy-700);
|
||||
--cc-header-accent: var(--cc-amber-500);
|
||||
--cc-header-sage: var(--cc-sage-600);
|
||||
--cc-header-dim: var(--cc-navy-500);
|
||||
|
||||
/* Text */
|
||||
--cc-text-primary: var(--cc-navy-900);
|
||||
--cc-text-secondary: var(--cc-navy-500);
|
||||
--cc-text-tertiary: var(--cc-navy-300);
|
||||
--cc-text-inverse: var(--cc-parchment-100);
|
||||
--cc-text-accent: var(--cc-amber-500);
|
||||
--cc-text-code: var(--cc-parchment-200);
|
||||
--cc-text-link: var(--cc-amber-600);
|
||||
|
||||
/* Borders */
|
||||
--cc-border: var(--cc-navy-100);
|
||||
--cc-border-strong: var(--cc-navy-200);
|
||||
--cc-border-accent: var(--cc-amber-500);
|
||||
--cc-border-focus: var(--cc-amber-400);
|
||||
--cc-border-error: var(--cc-crimson-500);
|
||||
--cc-border-success: var(--cc-signal-green-500);
|
||||
|
||||
/* Interactive */
|
||||
--cc-action-primary: var(--cc-amber-500);
|
||||
--cc-action-primary-hover: var(--cc-amber-400);
|
||||
--cc-action-primary-active: var(--cc-amber-600);
|
||||
--cc-action-secondary: var(--cc-navy-700);
|
||||
--cc-action-secondary-hover: var(--cc-navy-600);
|
||||
--cc-action-danger: var(--cc-crimson-500);
|
||||
--cc-action-success: var(--cc-signal-green-500);
|
||||
|
||||
/* States */
|
||||
--cc-selected: var(--cc-amber-100);
|
||||
--cc-hover: var(--cc-parchment-200);
|
||||
--cc-active: var(--cc-parchment-300);
|
||||
--cc-disabled: var(--cc-navy-100);
|
||||
|
||||
/* Recording / live indicator */
|
||||
--cc-recording-active: var(--cc-crimson-500);
|
||||
--cc-recording-idle: var(--cc-signal-green-500);
|
||||
|
||||
/* ── Geometry ── */
|
||||
--cc-radius-none: 0;
|
||||
--cc-radius-xs: 1px;
|
||||
--cc-radius-sm: 2px; /* maximum for shapes */
|
||||
--cc-radius-md: 4px; /* panels, menus */
|
||||
--cc-radius-full: 9999px;
|
||||
|
||||
/* ── Spacing scale (8pt grid) ── */
|
||||
--cc-space-0: 0;
|
||||
--cc-space-px: 1px;
|
||||
--cc-space-0_5: 2px;
|
||||
--cc-space-1: 4px;
|
||||
--cc-space-2: 8px;
|
||||
--cc-space-3: 12px;
|
||||
--cc-space-4: 16px;
|
||||
--cc-space-5: 20px;
|
||||
--cc-space-6: 24px;
|
||||
--cc-space-8: 32px;
|
||||
--cc-space-10: 40px;
|
||||
--cc-space-12: 48px;
|
||||
--cc-space-16: 64px;
|
||||
|
||||
/* ── Shape dimensions ── */
|
||||
--cc-shape-header-h: 32px;
|
||||
--cc-shape-header-pad: var(--cc-space-2);
|
||||
--cc-shape-content-pad: var(--cc-space-2);
|
||||
--cc-shape-min-w: 120px;
|
||||
--cc-shape-min-h: 80px;
|
||||
|
||||
/* ── Panel dimensions ── */
|
||||
--cc-panel-w: 280px;
|
||||
--cc-panel-header-h: 44px;
|
||||
--cc-panel-handle-w: 20px;
|
||||
|
||||
/* ── Borders ── */
|
||||
--cc-border-width: 1px;
|
||||
--cc-border-width-thick: 2px;
|
||||
|
||||
/* No blurry box shadows. Depth through tone. */
|
||||
--cc-shadow-none: none;
|
||||
--cc-shadow-edge: 1px 0 0 0 var(--cc-border);
|
||||
--cc-shadow-crisp: 2px 2px 0 0 var(--cc-navy-200);
|
||||
--cc-shadow-raised: 3px 3px 0 0 var(--cc-navy-200);
|
||||
|
||||
/* ── Z-index system ── */
|
||||
--cc-z-canvas: 0;
|
||||
--cc-z-shape: 1;
|
||||
--cc-z-panel: 200;
|
||||
--cc-z-toolbar: 300;
|
||||
--cc-z-menu: 400;
|
||||
--cc-z-modal: 500;
|
||||
--cc-z-toast: 600;
|
||||
|
||||
/* ── Motion ── */
|
||||
--cc-transition-snap: 80ms linear;
|
||||
--cc-transition-quick: 120ms ease-out;
|
||||
--cc-transition-base: 200ms ease-out;
|
||||
--cc-transition-slow: 320ms ease-out;
|
||||
}
|
||||
|
||||
/* ─── DARK MODE TOKENS ──────────────────────────────────────── */
|
||||
[data-color-mode="dark"] {
|
||||
/* Surfaces */
|
||||
--cc-bg-canvas: var(--cc-navy-950);
|
||||
--cc-bg-panel: var(--cc-navy-900);
|
||||
--cc-bg-panel-alt: var(--cc-navy-800);
|
||||
--cc-bg-shape: var(--cc-navy-800);
|
||||
--cc-bg-shape-hover: var(--cc-navy-700);
|
||||
--cc-bg-overlay: rgba(13, 17, 23, 0.72);
|
||||
--cc-bg-code: var(--cc-navy-950);
|
||||
|
||||
/* Shape headers get slightly brighter in dark */
|
||||
--cc-header-default: var(--cc-navy-600);
|
||||
--cc-header-accent: var(--cc-amber-600);
|
||||
--cc-header-sage: var(--cc-sage-500);
|
||||
--cc-header-dim: var(--cc-navy-700);
|
||||
|
||||
/* Text */
|
||||
--cc-text-primary: var(--cc-parchment-100);
|
||||
--cc-text-secondary: var(--cc-navy-200);
|
||||
--cc-text-tertiary: var(--cc-navy-300);
|
||||
--cc-text-inverse: var(--cc-navy-900);
|
||||
--cc-text-accent: var(--cc-amber-400);
|
||||
--cc-text-code: var(--cc-parchment-300);
|
||||
--cc-text-link: var(--cc-amber-400);
|
||||
|
||||
/* Borders */
|
||||
--cc-border: var(--cc-navy-700);
|
||||
--cc-border-strong: var(--cc-navy-600);
|
||||
--cc-border-accent: var(--cc-amber-500);
|
||||
--cc-border-focus: var(--cc-amber-300);
|
||||
--cc-border-error: var(--cc-crimson-400);
|
||||
--cc-border-success: var(--cc-signal-green-400);
|
||||
|
||||
/* Interactive */
|
||||
--cc-action-primary: var(--cc-amber-500);
|
||||
--cc-action-primary-hover: var(--cc-amber-300);
|
||||
--cc-action-primary-active: var(--cc-amber-600);
|
||||
--cc-action-secondary: var(--cc-navy-400);
|
||||
--cc-action-secondary-hover: var(--cc-navy-300);
|
||||
|
||||
/* States */
|
||||
--cc-selected: rgba(201, 136, 42, 0.15);
|
||||
--cc-hover: var(--cc-navy-800);
|
||||
--cc-active: var(--cc-navy-700);
|
||||
--cc-disabled: var(--cc-navy-700);
|
||||
|
||||
/* Shadows become tone dividers in dark */
|
||||
--cc-shadow-edge: 1px 0 0 0 var(--cc-navy-700);
|
||||
--cc-shadow-crisp: 2px 2px 0 0 var(--cc-navy-950);
|
||||
--cc-shadow-raised: 3px 3px 0 0 var(--cc-navy-950);
|
||||
}
|
||||
|
||||
/* ─── BASE RESET (minimal — respects tldraw's own base) ────── */
|
||||
.cc-root * {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--cc-font-sans);
|
||||
}
|
||||
|
||||
.cc-root code,
|
||||
.cc-root pre,
|
||||
.cc-mono {
|
||||
font-family: var(--cc-font-mono);
|
||||
}
|
||||
|
||||
/* ─── SHAPE CARD SYSTEM ─────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
.cc-shape-container
|
||||
└── .cc-shape-header
|
||||
└── .cc-shape-content
|
||||
*/
|
||||
|
||||
.cc-shape-container {
|
||||
display: grid;
|
||||
grid-template-rows: var(--cc-shape-header-h) 1fr;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--cc-bg-shape);
|
||||
border: var(--cc-border-width) solid var(--cc-border);
|
||||
border-radius: var(--cc-radius-sm);
|
||||
overflow: hidden;
|
||||
/* Flat depth — no blur */
|
||||
box-shadow: var(--cc-shadow-crisp);
|
||||
transition: box-shadow var(--cc-transition-quick);
|
||||
}
|
||||
|
||||
.cc-shape-container:hover {
|
||||
box-shadow: var(--cc-shadow-raised);
|
||||
}
|
||||
|
||||
.cc-shape-container--locked {
|
||||
opacity: 0.72;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Header strip — the primary identification band */
|
||||
.cc-shape-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
padding: 0 var(--cc-shape-header-pad);
|
||||
height: var(--cc-shape-header-h);
|
||||
background: var(--cc-header-default);
|
||||
color: var(--cc-text-inverse);
|
||||
/* Hairline rule below header, not shadow */
|
||||
border-bottom: var(--cc-border-width) solid rgba(255,255,255,0.12);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Accent variant headers */
|
||||
.cc-shape-header--accent { background: var(--cc-header-accent); color: var(--cc-navy-900); }
|
||||
.cc-shape-header--sage { background: var(--cc-header-sage); }
|
||||
.cc-shape-header--dim { background: var(--cc-header-dim); }
|
||||
|
||||
.cc-shape-header__title {
|
||||
font-size: var(--cc-text-sm);
|
||||
font-weight: var(--cc-weight-semibold);
|
||||
letter-spacing: var(--cc-tracking-wide);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cc-shape-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cc-space-1);
|
||||
}
|
||||
|
||||
/* Toolbar buttons inside shape headers */
|
||||
.cc-shape-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--cc-radius-xs);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
touch-action: none;
|
||||
transition:
|
||||
opacity var(--cc-transition-snap),
|
||||
background var(--cc-transition-snap);
|
||||
}
|
||||
|
||||
.cc-shape-btn:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.cc-shape-btn:active {
|
||||
background: rgba(255,255,255,0.25);
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.cc-shape-btn--active {
|
||||
opacity: 1;
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.cc-shape-content {
|
||||
padding: var(--cc-shape-content-pad);
|
||||
overflow: auto;
|
||||
background: var(--cc-bg-shape);
|
||||
color: var(--cc-text-primary);
|
||||
font-size: var(--cc-text-base);
|
||||
line-height: var(--cc-leading-normal);
|
||||
/* Hide scrollbar visually, keep functionality */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--cc-border-strong) transparent;
|
||||
}
|
||||
|
||||
.cc-shape-content::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.cc-shape-content::-webkit-scrollbar-thumb {
|
||||
background: var(--cc-border-strong);
|
||||
border-radius: var(--cc-radius-xs);
|
||||
}
|
||||
|
||||
.cc-shape-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ─── PANEL SYSTEM ──────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
.cc-panel
|
||||
└── .cc-panel-header
|
||||
└── .cc-panel-content
|
||||
*/
|
||||
|
||||
.cc-panel {
|
||||
display: grid;
|
||||
grid-template-rows: var(--cc-panel-header-h) 1fr;
|
||||
width: var(--cc-panel-w);
|
||||
height: 100%;
|
||||
background: var(--cc-bg-panel);
|
||||
border-right: var(--cc-border-width) solid var(--cc-border);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: var(--cc-z-panel);
|
||||
}
|
||||
|
||||
.cc-panel-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--cc-space-2);
|
||||
padding: 0 var(--cc-space-2);
|
||||
height: var(--cc-panel-header-h);
|
||||
background: var(--cc-bg-panel);
|
||||
border-bottom: var(--cc-border-width) solid var(--cc-border-strong);
|
||||
/* Left accent stripe — the editorial mark */
|
||||
box-shadow: inset 2px 0 0 0 var(--cc-border-accent);
|
||||
}
|
||||
|
||||
.cc-panel-content {
|
||||
overflow-y: auto;
|
||||
padding: var(--cc-space-2);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--cc-border) transparent;
|
||||
}
|
||||
|
||||
.cc-panel-content::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.cc-panel-content::-webkit-scrollbar-thumb {
|
||||
background: var(--cc-border-strong);
|
||||
}
|
||||
|
||||
/* Panel tab / type selector */
|
||||
.cc-panel-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cc-space-1);
|
||||
padding: var(--cc-space-1) var(--cc-space-2);
|
||||
background: transparent;
|
||||
border: var(--cc-border-width) solid var(--cc-border);
|
||||
border-radius: var(--cc-radius-sm);
|
||||
color: var(--cc-text-primary);
|
||||
font-family: var(--cc-font-sans);
|
||||
font-size: var(--cc-text-sm);
|
||||
font-weight: var(--cc-weight-medium);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--cc-transition-quick),
|
||||
background var(--cc-transition-quick);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cc-panel-selector:hover {
|
||||
border-color: var(--cc-border-strong);
|
||||
background: var(--cc-hover);
|
||||
}
|
||||
|
||||
.cc-panel-selector__icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cc-panel-selector__chevron {
|
||||
margin-left: auto;
|
||||
opacity: 0.5;
|
||||
transition: transform var(--cc-transition-quick);
|
||||
}
|
||||
|
||||
.cc-panel-selector[aria-expanded="true"] .cc-panel-selector__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Panel collapse handle */
|
||||
.cc-panel-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--cc-panel-handle-w);
|
||||
height: 40px;
|
||||
background: var(--cc-bg-panel);
|
||||
border: var(--cc-border-width) solid var(--cc-border);
|
||||
border-left: none;
|
||||
border-radius: 0 var(--cc-radius-md) var(--cc-radius-md) 0;
|
||||
color: var(--cc-text-tertiary);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
z-index: var(--cc-z-panel);
|
||||
transition:
|
||||
color var(--cc-transition-quick),
|
||||
background var(--cc-transition-quick);
|
||||
}
|
||||
|
||||
.cc-panel-handle:hover {
|
||||
background: var(--cc-hover);
|
||||
color: var(--cc-text-primary);
|
||||
}
|
||||
|
||||
/* Pin button */
|
||||
.cc-pin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--cc-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--cc-text-tertiary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--cc-transition-quick),
|
||||
background var(--cc-transition-quick);
|
||||
}
|
||||
|
||||
.cc-pin-btn:hover {
|
||||
background: var(--cc-hover);
|
||||
color: var(--cc-text-primary);
|
||||
}
|
||||
|
||||
.cc-pin-btn--active {
|
||||
color: var(--cc-action-primary);
|
||||
}
|
||||
|
||||
/* ─── PANEL MENU / DROPDOWN ─────────────────────────────────── */
|
||||
|
||||
.cc-menu {
|
||||
position: absolute;
|
||||
background: var(--cc-bg-panel-alt);
|
||||
border: var(--cc-border-width) solid var(--cc-border-strong);
|
||||
border-radius: var(--cc-radius-md);
|
||||
padding: var(--cc-space-1) 0;
|
||||
min-width: 200px;
|
||||
z-index: var(--cc-z-menu);
|
||||
/* Hard-edged drop — no blur */
|
||||
box-shadow: 4px 4px 0 0 var(--cc-navy-200);
|
||||
}
|
||||
|
||||
[data-color-mode="dark"] .cc-menu {
|
||||
box-shadow: 4px 4px 0 0 var(--cc-navy-950);
|
||||
}
|
||||
|
||||
.cc-menu-item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
align-items: start;
|
||||
gap: var(--cc-space-2);
|
||||
padding: var(--cc-space-2) var(--cc-space-3);
|
||||
color: var(--cc-text-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-family: var(--cc-font-sans);
|
||||
font-size: var(--cc-text-sm);
|
||||
transition: background var(--cc-transition-snap);
|
||||
}
|
||||
|
||||
.cc-menu-item:hover {
|
||||
background: var(--cc-hover);
|
||||
}
|
||||
|
||||
.cc-menu-item--selected {
|
||||
background: var(--cc-selected);
|
||||
color: var(--cc-text-accent);
|
||||
}
|
||||
|
||||
.cc-menu-item__icon {
|
||||
color: var(--cc-text-secondary);
|
||||
transition: color var(--cc-transition-snap);
|
||||
}
|
||||
|
||||
.cc-menu-item:hover .cc-menu-item__icon,
|
||||
.cc-menu-item--selected .cc-menu-item__icon {
|
||||
color: var(--cc-action-primary);
|
||||
}
|
||||
|
||||
.cc-menu-item__label {
|
||||
font-weight: var(--cc-weight-medium);
|
||||
line-height: var(--cc-leading-tight);
|
||||
}
|
||||
|
||||
.cc-menu-item__desc {
|
||||
font-size: var(--cc-text-xs);
|
||||
color: var(--cc-text-secondary);
|
||||
line-height: var(--cc-leading-normal);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.cc-menu-divider {
|
||||
height: var(--cc-border-width);
|
||||
background: var(--cc-border);
|
||||
margin: var(--cc-space-1) 0;
|
||||
}
|
||||
|
||||
/* ─── BUTTONS ────────────────────────────────────────────────── */
|
||||
|
||||
.cc-btn {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
padding: var(--cc-space-1) var(--cc-space-3);
|
||||
border: var(--cc-border-width) solid transparent;
|
||||
border-radius: var(--cc-radius-sm);
|
||||
font-family: var(--cc-font-sans);
|
||||
font-size: var(--cc-text-sm);
|
||||
font-weight: var(--cc-weight-medium);
|
||||
letter-spacing: var(--cc-tracking-wide);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background var(--cc-transition-quick),
|
||||
border-color var(--cc-transition-quick),
|
||||
transform var(--cc-transition-snap),
|
||||
box-shadow var(--cc-transition-snap);
|
||||
}
|
||||
|
||||
.cc-btn:active {
|
||||
transform: translate(1px, 1px);
|
||||
}
|
||||
|
||||
.cc-btn--primary {
|
||||
background: var(--cc-action-primary);
|
||||
color: var(--cc-navy-900);
|
||||
border-color: var(--cc-amber-600);
|
||||
box-shadow: 2px 2px 0 0 var(--cc-amber-700);
|
||||
}
|
||||
|
||||
.cc-btn--primary:hover {
|
||||
background: var(--cc-action-primary-hover);
|
||||
box-shadow: 3px 3px 0 0 var(--cc-amber-700);
|
||||
}
|
||||
|
||||
.cc-btn--primary:active {
|
||||
box-shadow: none;
|
||||
background: var(--cc-action-primary-active);
|
||||
}
|
||||
|
||||
.cc-btn--secondary {
|
||||
background: transparent;
|
||||
color: var(--cc-text-primary);
|
||||
border-color: var(--cc-border-strong);
|
||||
}
|
||||
|
||||
.cc-btn--secondary:hover {
|
||||
background: var(--cc-hover);
|
||||
border-color: var(--cc-navy-300);
|
||||
}
|
||||
|
||||
.cc-btn--danger {
|
||||
background: transparent;
|
||||
color: var(--cc-action-danger);
|
||||
border-color: var(--cc-border-error);
|
||||
}
|
||||
|
||||
.cc-btn--danger:hover {
|
||||
background: var(--cc-crimson-100);
|
||||
}
|
||||
|
||||
.cc-btn--icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: var(--cc-radius-sm);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--cc-text-secondary);
|
||||
}
|
||||
|
||||
.cc-btn--icon:hover {
|
||||
background: var(--cc-hover);
|
||||
color: var(--cc-text-primary);
|
||||
}
|
||||
|
||||
/* ─── INPUT / FORM ELEMENTS ─────────────────────────────────── */
|
||||
|
||||
.cc-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--cc-space-1) var(--cc-space-2);
|
||||
background: var(--cc-bg-panel-alt);
|
||||
border: var(--cc-border-width) solid var(--cc-border-strong);
|
||||
border-radius: var(--cc-radius-sm);
|
||||
color: var(--cc-text-primary);
|
||||
font-family: var(--cc-font-sans);
|
||||
font-size: var(--cc-text-base);
|
||||
line-height: var(--cc-leading-normal);
|
||||
transition: border-color var(--cc-transition-quick);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cc-input::placeholder {
|
||||
color: var(--cc-text-tertiary);
|
||||
}
|
||||
|
||||
.cc-input:focus {
|
||||
border-color: var(--cc-border-focus);
|
||||
box-shadow: 0 0 0 2px rgba(201,136,42,0.18);
|
||||
}
|
||||
|
||||
.cc-input:disabled {
|
||||
background: var(--cc-disabled);
|
||||
color: var(--cc-text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── RECORDING / LIVE INDICATOR ────────────────────────────── */
|
||||
|
||||
.cc-record-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
transition:
|
||||
background var(--cc-transition-quick),
|
||||
transform var(--cc-transition-snap),
|
||||
box-shadow var(--cc-transition-quick);
|
||||
}
|
||||
|
||||
.cc-record-btn--idle {
|
||||
background: var(--cc-recording-idle);
|
||||
color: #fff;
|
||||
box-shadow: 2px 2px 0 0 var(--cc-signal-green-600);
|
||||
}
|
||||
|
||||
.cc-record-btn--idle:hover {
|
||||
background: var(--cc-signal-green-400);
|
||||
box-shadow: 3px 3px 0 0 var(--cc-signal-green-600);
|
||||
}
|
||||
|
||||
.cc-record-btn--active {
|
||||
background: var(--cc-recording-active);
|
||||
color: #fff;
|
||||
box-shadow: 2px 2px 0 0 var(--cc-crimson-600);
|
||||
/* Pulsing border — CSS-only, no JS */
|
||||
animation: cc-record-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cc-record-pulse {
|
||||
0%,100% { box-shadow: 2px 2px 0 0 var(--cc-crimson-600), 0 0 0 0 rgba(168,40,40,0.4); }
|
||||
50% { box-shadow: 2px 2px 0 0 var(--cc-crimson-600), 0 0 0 6px rgba(168,40,40,0); }
|
||||
}
|
||||
|
||||
.cc-record-btn--active:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* Status text accompanying record button */
|
||||
.cc-record-status {
|
||||
font-family: var(--cc-font-mono);
|
||||
font-size: var(--cc-text-sm);
|
||||
font-weight: var(--cc-weight-medium);
|
||||
letter-spacing: var(--cc-tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cc-record-status--idle { color: var(--cc-recording-idle); }
|
||||
.cc-record-status--active { color: var(--cc-recording-active); }
|
||||
|
||||
/* ─── TRANSCRIPT SEGMENTS ───────────────────────────────────── */
|
||||
|
||||
.cc-transcript-segment {
|
||||
padding: var(--cc-space-2);
|
||||
border-left: 2px solid var(--cc-border);
|
||||
margin-bottom: var(--cc-space-1);
|
||||
transition: border-color var(--cc-transition-quick);
|
||||
}
|
||||
|
||||
.cc-transcript-segment:hover {
|
||||
border-color: var(--cc-border-accent);
|
||||
}
|
||||
|
||||
.cc-transcript-segment--current {
|
||||
border-color: var(--cc-action-primary);
|
||||
background: var(--cc-selected);
|
||||
}
|
||||
|
||||
.cc-transcript-timestamp {
|
||||
font-family: var(--cc-font-mono);
|
||||
font-size: var(--cc-text-xs);
|
||||
color: var(--cc-text-tertiary);
|
||||
letter-spacing: var(--cc-tracking-wide);
|
||||
margin-bottom: var(--cc-space-0_5);
|
||||
}
|
||||
|
||||
.cc-transcript-text {
|
||||
font-size: var(--cc-text-base);
|
||||
color: var(--cc-text-primary);
|
||||
line-height: var(--cc-leading-normal);
|
||||
}
|
||||
|
||||
/* ─── GRAPH NODE CARDS ──────────────────────────────────────── */
|
||||
|
||||
.cc-graph-node {
|
||||
display: grid;
|
||||
grid-template-rows: var(--cc-shape-header-h) 1fr;
|
||||
border: var(--cc-border-width) solid var(--cc-border);
|
||||
border-radius: var(--cc-radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--cc-bg-shape);
|
||||
box-shadow: var(--cc-shadow-crisp);
|
||||
transition: box-shadow var(--cc-transition-quick);
|
||||
}
|
||||
|
||||
.cc-graph-node:hover {
|
||||
box-shadow: var(--cc-shadow-raised);
|
||||
}
|
||||
|
||||
/* Node type label pill */
|
||||
.cc-node-type-tag {
|
||||
display: inline-block;
|
||||
padding: 1px var(--cc-space-1);
|
||||
border: var(--cc-border-width) solid currentColor;
|
||||
border-radius: var(--cc-radius-xs);
|
||||
font-family: var(--cc-font-mono);
|
||||
font-size: var(--cc-text-xs);
|
||||
font-weight: var(--cc-weight-medium);
|
||||
letter-spacing: var(--cc-tracking-widest);
|
||||
text-transform: uppercase;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* ─── SECTION / DIVIDER TYPOGRAPHY ─────────────────────────── */
|
||||
|
||||
.cc-section-label {
|
||||
font-size: var(--cc-text-xs);
|
||||
font-weight: var(--cc-weight-semibold);
|
||||
letter-spacing: var(--cc-tracking-widest);
|
||||
text-transform: uppercase;
|
||||
color: var(--cc-text-tertiary);
|
||||
padding-bottom: var(--cc-space-1);
|
||||
border-bottom: var(--cc-border-width) solid var(--cc-border);
|
||||
margin-bottom: var(--cc-space-2);
|
||||
}
|
||||
|
||||
.cc-divider {
|
||||
height: var(--cc-border-width);
|
||||
background: var(--cc-border);
|
||||
margin: var(--cc-space-2) 0;
|
||||
}
|
||||
|
||||
/* ─── LOADING STATES ─────────────────────────────────────────── */
|
||||
|
||||
.cc-spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--cc-border-strong);
|
||||
border-top-color: var(--cc-action-primary);
|
||||
border-radius: 50%;
|
||||
animation: cc-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cc-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.cc-skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--cc-border) 25%,
|
||||
var(--cc-hover) 50%,
|
||||
var(--cc-border) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: cc-shimmer 1.4s ease-in-out infinite;
|
||||
border-radius: var(--cc-radius-xs);
|
||||
}
|
||||
|
||||
@keyframes cc-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ─── TOASTS / ALERTS ────────────────────────────────────────── */
|
||||
|
||||
.cc-alert {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: start;
|
||||
gap: var(--cc-space-2);
|
||||
padding: var(--cc-space-2) var(--cc-space-3);
|
||||
border: var(--cc-border-width) solid;
|
||||
border-radius: var(--cc-radius-sm);
|
||||
font-size: var(--cc-text-sm);
|
||||
}
|
||||
|
||||
.cc-alert--error {
|
||||
background: var(--cc-crimson-100);
|
||||
border-color: var(--cc-border-error);
|
||||
color: var(--cc-crimson-600);
|
||||
}
|
||||
|
||||
.cc-alert--success {
|
||||
background: var(--cc-signal-green-100);
|
||||
border-color: var(--cc-border-success);
|
||||
color: var(--cc-signal-green-600);
|
||||
}
|
||||
|
||||
[data-color-mode="dark"] .cc-alert--error {
|
||||
background: rgba(168,40,40,0.12);
|
||||
color: var(--cc-crimson-400);
|
||||
}
|
||||
|
||||
[data-color-mode="dark"] .cc-alert--success {
|
||||
background: rgba(36,107,66,0.12);
|
||||
color: var(--cc-signal-green-400);
|
||||
}
|
||||
|
||||
/* ─── UTILITY CLASSES ────────────────────────────────────────── */
|
||||
|
||||
.cc-font-mono { font-family: var(--cc-font-mono); }
|
||||
.cc-text-xs { font-size: var(--cc-text-xs); }
|
||||
.cc-text-sm { font-size: var(--cc-text-sm); }
|
||||
.cc-text-base { font-size: var(--cc-text-base); }
|
||||
|
||||
.cc-text-primary { color: var(--cc-text-primary); }
|
||||
.cc-text-secondary { color: var(--cc-text-secondary); }
|
||||
.cc-text-tertiary { color: var(--cc-text-tertiary); }
|
||||
.cc-text-accent { color: var(--cc-text-accent); }
|
||||
|
||||
.cc-uppercase { text-transform: uppercase; letter-spacing: var(--cc-tracking-wide); }
|
||||
|
||||
.cc-border-accent { border-left: 2px solid var(--cc-border-accent); }
|
||||
.cc-border-top { border-top: var(--cc-border-width) solid var(--cc-border); }
|
||||
.cc-border-bottom { border-bottom: var(--cc-border-width) solid var(--cc-border); }
|
||||
|
||||
.cc-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ─── GRID LAYOUT HELPERS ────────────────────────────────────── */
|
||||
|
||||
.cc-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--cc-space-2); }
|
||||
.cc-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--cc-space-2); }
|
||||
.cc-stack { display: grid; gap: var(--cc-space-2); }
|
||||
.cc-stack-sm { display: grid; gap: var(--cc-space-1); }
|
||||
|
||||
/* ─── FOCUS VISIBLE ──────────────────────────────────────────── */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--cc-border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--cc-radius-xs);
|
||||
}
|
||||
@@ -43,6 +43,8 @@ import { CCDepartmentStructureNodeShapeUtil } from './cc-base/cc-graph/CCDepartm
|
||||
import { CCUserTeacherTimetableNodeShapeUtil } from './cc-base/cc-graph/CCUserTeacherTimetableNodeShapeUtil'
|
||||
import { CCSearchShapeUtil } from './cc-base/cc-search/CCSearchShapeUtil'
|
||||
import { CCWebBrowserShapeUtil } from './cc-base/cc-web-browser/CCWebBrowserUtil'
|
||||
import { CCJournalNodeShapeUtil } from './cc-base/cc-graph/CCJournalNodeShapeUtil'
|
||||
import { CCPlannerNodeShapeUtil } from './cc-base/cc-graph/CCPlannerNodeShapeUtil'
|
||||
// Define all shape utils in a single object for easy maintenance
|
||||
export const ShapeUtils = {
|
||||
CCSlideShow: CCSlideShowShapeUtil,
|
||||
@@ -89,6 +91,8 @@ export const ShapeUtils = {
|
||||
CCUserTeacherTimetableNode: CCUserTeacherTimetableNodeShapeUtil,
|
||||
CCSearch: CCSearchShapeUtil,
|
||||
CCWebBrowser: CCWebBrowserShapeUtil,
|
||||
CCJournalNode: CCJournalNodeShapeUtil,
|
||||
CCPlannerNode: CCPlannerNodeShapeUtil,
|
||||
}
|
||||
|
||||
export const allShapeUtils = Object.values(ShapeUtils)
|
||||
@@ -1,2 +1,12 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap");
|
||||
@import url("@tldraw/tldraw/tldraw.css");
|
||||
@import url('./cc-design-system.css');
|
||||
|
||||
.tl-container {
|
||||
--color-panel: var(--cc-bg-panel);
|
||||
--color-text: var(--cc-text-primary);
|
||||
--color-muted: var(--cc-border);
|
||||
--color-muted-1: var(--cc-border-strong);
|
||||
--color-background: var(--cc-bg-canvas);
|
||||
--color-selected: var(--cc-selected);
|
||||
--color-accent: var(--cc-action-primary);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BasePanel } from './shared/BasePanel';
|
||||
import { CCExamMarkerPanel } from './shared/CCExamMarkerPanel';
|
||||
import { BaseContext, ViewContext } from '../../../../types/navigation';
|
||||
|
||||
interface CCPanelProps {
|
||||
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
|
||||
isExpanded?: boolean;
|
||||
isPinned?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
onPinnedChange?: (pinned: boolean) => void;
|
||||
}
|
||||
|
||||
export const CCPanel: React.FC<CCPanelProps> = ({
|
||||
examMarkerProps,
|
||||
export const CCPanel: React.FC<CCPanelProps> = ({
|
||||
isExpanded,
|
||||
isPinned,
|
||||
onExpandedChange,
|
||||
@@ -31,8 +28,7 @@ export const CCPanel: React.FC<CCPanelProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<BasePanel
|
||||
examMarkerProps={examMarkerProps}
|
||||
<BasePanel
|
||||
isExpanded={isExpanded}
|
||||
isPinned={isPinned}
|
||||
onExpandedChange={handleExpandedChange}
|
||||
|
||||
@@ -1,41 +1,14 @@
|
||||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import React, { useEffect, useRef, useMemo, useState } from 'react';
|
||||
import { TldrawUiButton } from '@tldraw/tldraw';
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
styled,
|
||||
ThemeProvider,
|
||||
createTheme,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PushPin as PushPinIcon,
|
||||
PushPinOutlined as PushPinOutlinedIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Category as ShapesIcon,
|
||||
Slideshow as SlidesIcon,
|
||||
YouTube as YouTubeIcon,
|
||||
AccountTree as GraphIcon,
|
||||
Search as SearchIcon,
|
||||
Navigation as NavigationIcon,
|
||||
Save as NodeIcon,
|
||||
Assignment as ExamIcon,
|
||||
Mic as MicIcon
|
||||
} from '@mui/icons-material';
|
||||
import { CCShapesPanel } from './CCShapesPanel';
|
||||
import { CCSlidesPanel } from './CCSlidesPanel';
|
||||
import { CCFilesPanel } from './CCFilesPanel';
|
||||
import { CCCabinetsPanel } from './CCCabinetsPanel';
|
||||
import { CCYoutubePanel } from './CCYoutubePanel';
|
||||
import { CCGraphPanel } from './CCGraphPanel';
|
||||
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
|
||||
import { CCSearchPanel } from './CCSearchPanel'
|
||||
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel'
|
||||
import { CCTranscriptionPanel } from './CCTranscriptionPanel'
|
||||
import { CCSearchPanel } from './CCSearchPanel';
|
||||
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel';
|
||||
import { CCTranscriptionPanel } from './CCTranscriptionPanel';
|
||||
import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles';
|
||||
import './panel.css';
|
||||
// import { CCNavigationPanel } from './navigation/CCNavigationPanel';
|
||||
@@ -56,16 +29,12 @@ export const PANEL_TYPES = {
|
||||
{ id: 'graph', label: 'Graph', order: 60 },
|
||||
{ id: 'search', label: 'Search', order: 70 },
|
||||
],
|
||||
examMarker: [
|
||||
{ id: 'exam-marker', label: 'Exam Marker', order: 10 },
|
||||
],
|
||||
} as const;
|
||||
|
||||
export type PanelType = typeof PANEL_TYPES.default[number]['id'] | typeof PANEL_TYPES.examMarker[number]['id'];
|
||||
export type PanelType = typeof PANEL_TYPES.default[number]['id'];
|
||||
|
||||
interface BasePanelProps {
|
||||
initialPanelType?: PanelType;
|
||||
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
|
||||
isExpanded?: boolean;
|
||||
isPinned?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
@@ -78,49 +47,95 @@ interface BasePanelProps {
|
||||
onMenuOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const PanelTypeButton = styled(Button)(() => ({
|
||||
textTransform: 'none',
|
||||
padding: '6px 12px',
|
||||
gap: '8px',
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid transparent',
|
||||
transition: 'border-color 200ms ease',
|
||||
justifyContent: 'space-between',
|
||||
minWidth: '200px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
borderColor: 'var(--color-text)',
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: '1.25rem',
|
||||
color: 'inherit',
|
||||
function getIconSvg(panelId: PanelType, size = '1.25rem') {
|
||||
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
|
||||
switch (panelId) {
|
||||
case 'cabinets':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
);
|
||||
case 'transcription':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<line x1="12" y1="19" x2="12" y2="23" />
|
||||
<line x1="8" y1="23" x2="6" y2="21" />
|
||||
<line x1="16" y1="23" x2="14" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
case 'cc-shapes':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
);
|
||||
case 'slides':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="2" y="3" width="13" height="18" rx="2" />
|
||||
<path d="M15 8h4l3 3v7h-7V8z" />
|
||||
</svg>
|
||||
);
|
||||
case 'youtube':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="2" y="4" width="20" height="16" rx="4" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
case 'graph':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
||||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||
</svg>
|
||||
);
|
||||
case 'search':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
case 'navigation':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<polygon points="3 11 22 2 13 21 11 13 3 11" />
|
||||
</svg>
|
||||
);
|
||||
case 'node-snapshot':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
const StyledMenuItem = styled(MenuItem)(() => ({
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
transition: 'background-color 200ms ease',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--color-hover)',
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: 'var(--color-selected)',
|
||||
}
|
||||
},
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: 'var(--color-text)',
|
||||
minWidth: '32px',
|
||||
transition: 'color 200ms ease',
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: '1.25rem',
|
||||
}
|
||||
}
|
||||
}));
|
||||
function isDarkMode() {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return document.documentElement.dataset.colorMode === 'dark' ||
|
||||
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
export const BasePanel: React.FC<BasePanelProps> = ({
|
||||
initialPanelType = 'files',
|
||||
examMarkerProps,
|
||||
isExpanded: controlledIsExpanded,
|
||||
isPinned: controlledIsPinned,
|
||||
onExpandedChange,
|
||||
@@ -128,47 +143,37 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
||||
isMenuOpen = false,
|
||||
onMenuOpenChange = () => {},
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const { tldrawPreferences } = useTLDraw();
|
||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||
const [menuAnchorEl, setMenuAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
|
||||
// Create a dynamic theme based on TLDraw preferences
|
||||
const theme = useMemo(() => {
|
||||
let mode: 'light' | 'dark';
|
||||
|
||||
if (tldrawPreferences?.colorScheme === 'system') {
|
||||
mode = prefersDarkMode ? 'dark' : 'light';
|
||||
} else {
|
||||
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode());
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
},
|
||||
});
|
||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => setPrefersDarkMode(isDarkMode());
|
||||
mq.addEventListener?.('change', handler);
|
||||
return () => mq.removeEventListener?.('change', handler);
|
||||
}, []);
|
||||
|
||||
const colorMode = (tldrawPreferences?.colorScheme === 'system' ? prefersDarkMode : tldrawPreferences?.colorScheme === 'dark')
|
||||
? 'dark'
|
||||
: 'light';
|
||||
|
||||
const availablePanels = PANEL_TYPES.default;
|
||||
|
||||
const [currentPanelType, setCurrentPanelType] = useState<PanelType>(initialPanelType);
|
||||
|
||||
const [internalIsExpanded, setInternalIsExpanded] = useState(true);
|
||||
const [internalIsPinned, setInternalIsPinned] = useState(true);
|
||||
|
||||
const isExamMarkerRoute = location.pathname === '/exam-marker';
|
||||
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
|
||||
|
||||
const [currentPanelType, setCurrentPanelType] = React.useState<PanelType>(
|
||||
isExamMarkerRoute ? 'exam-marker' : initialPanelType
|
||||
);
|
||||
|
||||
// Use controlled state if provided, otherwise use internal state
|
||||
const [internalIsExpanded, setInternalIsExpanded] = React.useState(true);
|
||||
const [internalIsPinned, setInternalIsPinned] = React.useState(true);
|
||||
|
||||
const isExpanded = controlledIsExpanded ?? internalIsExpanded;
|
||||
const isPinned = controlledIsPinned ?? internalIsPinned;
|
||||
|
||||
|
||||
const handleExpandedChange = (expanded: boolean) => {
|
||||
setInternalIsExpanded(expanded);
|
||||
onExpandedChange?.(expanded);
|
||||
};
|
||||
|
||||
|
||||
const handlePinToggle = () => {
|
||||
const newPinned = !isPinned;
|
||||
setInternalIsPinned(newPinned);
|
||||
@@ -178,92 +183,25 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = PANEL_DIMENSIONS[currentPanelType as keyof typeof PANEL_DIMENSIONS];
|
||||
|
||||
// Handle click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
// Don't close if pinned
|
||||
if (isPinned) return;
|
||||
|
||||
// Check if click is outside panel
|
||||
const isClickOutside = panelRef.current && !panelRef.current.contains(event.target as Node);
|
||||
|
||||
// Check if click is not on a panel-related element
|
||||
const target = event.target as HTMLElement;
|
||||
const isPanelElement = target.closest('.panel-root, .panel-handle, .tlui-button');
|
||||
|
||||
if (isClickOutside && !isPanelElement) {
|
||||
handleExpandedChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isExpanded) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isExpanded, isPinned]);
|
||||
|
||||
const getIconForPanel = (panelId: PanelType) => {
|
||||
switch (panelId) {
|
||||
case 'cabinets':
|
||||
return <NavigationIcon />;
|
||||
case 'cc-shapes':
|
||||
return <ShapesIcon />;
|
||||
case 'transcription':
|
||||
return <MicIcon />;
|
||||
case 'slides':
|
||||
return <SlidesIcon />;
|
||||
case 'youtube':
|
||||
return <YouTubeIcon />;
|
||||
case 'graph':
|
||||
return <GraphIcon />;
|
||||
case 'search':
|
||||
return <SearchIcon />;
|
||||
case 'navigation':
|
||||
return <NavigationIcon />;
|
||||
case 'node-snapshot':
|
||||
return <NodeIcon />;
|
||||
case 'exam-marker':
|
||||
return <ExamIcon />;
|
||||
default:
|
||||
return <ShapesIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
const getDescriptionForPanel = (panelId: PanelType) => {
|
||||
switch (panelId) {
|
||||
case 'cabinets':
|
||||
return 'Manage file cabinets';
|
||||
case 'transcription':
|
||||
return 'Record and transcribe lessons';
|
||||
case 'cc-shapes':
|
||||
return 'Add shapes and elements to your canvas';
|
||||
case 'slides':
|
||||
return 'Manage presentation slides';
|
||||
case 'youtube':
|
||||
return 'Embed YouTube videos';
|
||||
case 'graph':
|
||||
return 'View and manage graph connections';
|
||||
case 'search':
|
||||
return 'Search through your content';
|
||||
case 'navigation':
|
||||
return 'Navigate through different contexts';
|
||||
case 'node-snapshot':
|
||||
return 'Manage node snapshots';
|
||||
case 'exam-marker':
|
||||
return 'Mark and grade exams';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const renderCurrentPanel = () => {
|
||||
if (isExamMarkerRoute && currentPanelType === 'exam-marker') {
|
||||
return examMarkerProps ? <CCExamMarkerPanel {...examMarkerProps} /> : null;
|
||||
}
|
||||
|
||||
switch (currentPanelType) {
|
||||
case 'cabinets':
|
||||
return <CCCabinetsPanel />;
|
||||
@@ -288,22 +226,15 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle menu button click
|
||||
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setMenuAnchorEl(event.currentTarget);
|
||||
onMenuOpenChange(true);
|
||||
};
|
||||
|
||||
// Handle menu close
|
||||
const handleMenuClose = () => {
|
||||
setMenuAnchorEl(null);
|
||||
onMenuOpenChange(false);
|
||||
const toggleMenu = (open: boolean) => {
|
||||
setMenuOpen(open);
|
||||
onMenuOpenChange(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isExpanded && (
|
||||
<div
|
||||
<div
|
||||
className="panel-handle"
|
||||
onClick={() => handleExpandedChange(true)}
|
||||
onTouchEnd={(e) => {
|
||||
@@ -316,7 +247,7 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
||||
)}
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="panel-root"
|
||||
style={{
|
||||
@@ -327,55 +258,60 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
||||
}}
|
||||
>
|
||||
<div className="panel-header">
|
||||
<ThemeProvider theme={theme}>
|
||||
<PanelTypeButton
|
||||
onClick={handleMenuClick}
|
||||
endIcon={<ExpandMoreIcon />}
|
||||
startIcon={getIconForPanel(currentPanelType)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="cc-btn cc-btn--secondary cc-panel-selector"
|
||||
onClick={() => toggleMenu(!menuOpen)}
|
||||
>
|
||||
<span className="cc-btn-start-icon">
|
||||
{getIconSvg(currentPanelType)}
|
||||
</span>
|
||||
<span className="cc-btn-label">
|
||||
{availablePanels.find(p => p.id === currentPanelType)?.label}
|
||||
</PanelTypeButton>
|
||||
</span>
|
||||
<span className="cc-btn-end-icon">
|
||||
<svg width="1.25rem" height="1.25rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Menu
|
||||
anchorEl={menuAnchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
PaperProps={{
|
||||
elevation: 8,
|
||||
sx: {
|
||||
border: '1px solid var(--color-divider)',
|
||||
boxShadow: 'var(--shadow-popup)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{menuOpen && (
|
||||
<div className="cc-menu panel-type-menu" role="menu">
|
||||
{[...availablePanels]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(type => (
|
||||
<StyledMenuItem
|
||||
key={type.id}
|
||||
onClick={() => {
|
||||
setCurrentPanelType(type.id as PanelType);
|
||||
handleMenuClose();
|
||||
}}
|
||||
selected={currentPanelType === type.id}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{getIconForPanel(type.id as PanelType)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={type.label}
|
||||
secondary={getDescriptionForPanel(type.id as PanelType)}
|
||||
primaryTypographyProps={{
|
||||
sx: { color: 'var(--color-text)' }
|
||||
<button
|
||||
key={type.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`cc-menu-item ${type.id === currentPanelType ? 'cc-menu-item--active' : ''}`}
|
||||
onClick={() => {
|
||||
setCurrentPanelType(type.id as PanelType);
|
||||
toggleMenu(false);
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
sx: { color: 'var(--color-text-secondary)' }
|
||||
}}
|
||||
/>
|
||||
</StyledMenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</ThemeProvider>
|
||||
>
|
||||
<span className="cc-menu-item-icon">
|
||||
{getIconSvg(type.id as PanelType)}
|
||||
</span>
|
||||
<span className="cc-menu-item-text">
|
||||
<span className="cc-menu-item-primary">{type.label}</span>
|
||||
<span className="cc-menu-item-secondary">
|
||||
{type.id === 'cabinets' && 'Manage file cabinets'}
|
||||
{type.id === 'transcription' && 'Record and transcribe lessons'}
|
||||
{type.id === 'cc-shapes' && 'Add shapes and elements to your canvas'}
|
||||
{type.id === 'slides' && 'Manage presentation slides'}
|
||||
{type.id === 'youtube' && 'Embed YouTube videos'}
|
||||
{type.id === 'graph' && 'View and manage graph connections'}
|
||||
{type.id === 'search' && 'Search through your content'}
|
||||
{type.id === 'navigation' && 'Navigate through different contexts'}
|
||||
{type.id === 'node-snapshot' && 'Manage node snapshots'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel-header-actions">
|
||||
<TldrawUiButton
|
||||
@@ -383,18 +319,38 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
||||
onClick={handlePinToggle}
|
||||
className="pin-button"
|
||||
>
|
||||
{isPinned ? <PushPinIcon /> : <PushPinOutlinedIcon />}
|
||||
<span className="cc-icon-button">
|
||||
<svg
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{isPinned ? (
|
||||
<>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M7.5 7.5l9 9" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</span>
|
||||
</TldrawUiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ThemeProvider theme={theme}>
|
||||
<div className="panel-content">
|
||||
{renderCurrentPanel()}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ export const CCCabinetsPanel: React.FC = () => {
|
||||
return createTheme({ palette: { mode, divider: 'var(--color-divider)' } });
|
||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||
|
||||
const API_BASE: string = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
||||
const API_BASE: string = import.meta.env.VITE_API_BASE || '/api';
|
||||
|
||||
type RequestInitLite = { method?: string; body?: string | FormData | Blob | null; headers?: Record<string, string> } | undefined;
|
||||
const apiFetch = useCallback(async (url: string, init?: RequestInitLite) => {
|
||||
|
||||
@@ -1,495 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Button, Typography, Divider, Stack } from '@mui/material';
|
||||
import { Editor, exportToBlob, TLPageId, Box as TLBox } from '@tldraw/tldraw';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { Pdf } from '../../../../../pages/tldraw/CCExamMarker/types';
|
||||
import { logger } from '../../../../../debugConfig';
|
||||
|
||||
interface CCExamMarkerPanelProps {
|
||||
editor: Editor | null;
|
||||
currentView: 'exam-and-markscheme' | 'student-responses';
|
||||
onViewChange: (view: 'exam-and-markscheme' | 'student-responses') => void;
|
||||
currentStudentIndex: number;
|
||||
totalStudents: number;
|
||||
onPreviousStudent: () => void;
|
||||
onNextStudent: () => void;
|
||||
getCurrentPdf: () => Pdf | null;
|
||||
}
|
||||
|
||||
export const CCExamMarkerPanel: React.FC<CCExamMarkerPanelProps> = ({
|
||||
editor,
|
||||
currentView,
|
||||
onViewChange,
|
||||
currentStudentIndex,
|
||||
totalStudents,
|
||||
onPreviousStudent,
|
||||
onNextStudent,
|
||||
getCurrentPdf,
|
||||
}) => {
|
||||
const [exportProgress, setExportProgress] = useState<number | null>(null);
|
||||
|
||||
const exportPdf = async (
|
||||
editor: Editor,
|
||||
{ name, source, pages }: Pdf,
|
||||
onProgress: (progress: number) => void,
|
||||
startPage?: number,
|
||||
endPage?: number,
|
||||
studentIndex?: number
|
||||
) => {
|
||||
logger.debug('cc-exam-marker', '📤 Starting PDF export', {
|
||||
name,
|
||||
startPage,
|
||||
endPage,
|
||||
studentIndex,
|
||||
currentView,
|
||||
totalPages: pages.length
|
||||
});
|
||||
|
||||
const pdfPages = pages.slice(startPage, endPage);
|
||||
logger.debug('cc-exam-marker', '📄 Selected pages for export', {
|
||||
pdfPages: pdfPages.length,
|
||||
pageIndices: pdfPages.map((_, i) => (startPage || 0) + i)
|
||||
});
|
||||
|
||||
const totalThings = pdfPages.length * 2 + 2;
|
||||
let progressCount = 0;
|
||||
const tickProgress = () => {
|
||||
progressCount++;
|
||||
onProgress(progressCount / totalThings);
|
||||
};
|
||||
|
||||
const sourcePdf = await PDFDocument.load(source);
|
||||
tickProgress();
|
||||
|
||||
// Create a new PDF document for the selected pages
|
||||
const newPdf = await PDFDocument.create();
|
||||
|
||||
// Copy pages from source PDF
|
||||
const pageIndices = pdfPages.map((_, i) => (startPage || 0) + i);
|
||||
const copiedPages = await newPdf.copyPages(sourcePdf, pageIndices);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
tickProgress();
|
||||
|
||||
// Store current page to restore later
|
||||
const currentPageId = editor.getCurrentPageId();
|
||||
logger.debug('cc-exam-marker', '📍 Current page before export', { currentPageId });
|
||||
|
||||
// Switch to the correct page based on context
|
||||
const targetPageId = (studentIndex !== undefined
|
||||
? `page:student-response-${studentIndex}`
|
||||
: currentView === 'exam-and-markscheme'
|
||||
? 'page:exam-page'
|
||||
: 'page:mark-scheme-page') as TLPageId;
|
||||
|
||||
logger.debug('cc-exam-marker', '🎯 Switching to target page', { targetPageId });
|
||||
editor.setCurrentPage(targetPageId);
|
||||
|
||||
// Get all shape IDs that are not page shapes (i.e., annotations)
|
||||
const pageShapeIds = new Set(pages.map(page => page.shapeId));
|
||||
const allShapeIds = Array.from(editor.getCurrentPageShapeIds()).filter(id => !pageShapeIds.has(id));
|
||||
|
||||
logger.debug('cc-exam-marker', '📝 Found shapes on current page', {
|
||||
totalShapes: editor.getCurrentPageShapeIds().size,
|
||||
pageShapes: pageShapeIds.size,
|
||||
annotationShapes: allShapeIds.length
|
||||
});
|
||||
|
||||
// For each page, draw annotations on top
|
||||
for (let i = 0; i < pdfPages.length; i++) {
|
||||
const page = pdfPages[i];
|
||||
const pdfPage = newPdf.getPages()[i];
|
||||
const {bounds} = page;
|
||||
|
||||
logger.debug('cc-exam-marker', `📄 Processing page ${i + 1}/${pdfPages.length}`, {
|
||||
bounds,
|
||||
pageIndex: i,
|
||||
globalPageIndex: (startPage || 0) + i
|
||||
});
|
||||
|
||||
// Get shapes that intersect with this page using editor's bounds checking
|
||||
const shapesInBounds = allShapeIds.filter((id) => {
|
||||
const shape = editor.getShape(id);
|
||||
if (!shape || shape.isLocked) return false;
|
||||
|
||||
// @ts-expect-error - annotationManager is added to editor in CCPdfEditor
|
||||
const annotationManager = editor.annotationManager;
|
||||
const annotationData = annotationManager.getAnnotationData(id);
|
||||
if (!annotationData) return false;
|
||||
|
||||
// Filter by student index if provided
|
||||
if (studentIndex !== undefined && annotationData.studentIndex !== studentIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For exam/markscheme view, only include those annotations
|
||||
if (studentIndex === undefined && annotationData.studentIndex !== undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For individual student exports, use the annotation's original page index
|
||||
// For full exports, use the stored page index
|
||||
const adjustedPageIndex = annotationData.pageIndex;
|
||||
|
||||
// Check if this shape belongs to this page index
|
||||
if (adjustedPageIndex !== i) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.debug('cc-exam-marker', `🔍 Found matching annotation`, {
|
||||
shapeId: id,
|
||||
annotationData,
|
||||
adjustedPageIndex,
|
||||
currentPageIndex: i,
|
||||
bounds: editor.getShapePageBounds(id)
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
logger.debug('cc-exam-marker', `✨ Found shapes for page ${i + 1}`, {
|
||||
shapesInBounds: shapesInBounds.length,
|
||||
pageIndex: i,
|
||||
globalPageIndex: (startPage || 0) + i
|
||||
});
|
||||
|
||||
if (shapesInBounds.length === 0) {
|
||||
tickProgress();
|
||||
tickProgress();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Export the annotations as PNG
|
||||
const exportedPng = await exportToBlob({
|
||||
editor,
|
||||
ids: shapesInBounds,
|
||||
format: 'png',
|
||||
opts: {
|
||||
background: false,
|
||||
// Create a new bounds that's relative to the current page
|
||||
bounds: new TLBox(
|
||||
bounds.x,
|
||||
0, // Reset to 0 since we want annotations relative to current page
|
||||
bounds.width,
|
||||
bounds.height
|
||||
),
|
||||
padding: 0,
|
||||
scale: 1
|
||||
},
|
||||
});
|
||||
|
||||
tickProgress();
|
||||
|
||||
// Draw the annotations on the PDF page
|
||||
const pngImage = await newPdf.embedPng(await exportedPng.arrayBuffer());
|
||||
const pdfWidth = pdfPage.getWidth();
|
||||
const pdfHeight = pdfPage.getHeight();
|
||||
|
||||
pdfPage.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pdfWidth,
|
||||
height: pdfHeight,
|
||||
});
|
||||
|
||||
tickProgress();
|
||||
}
|
||||
|
||||
// Restore original page
|
||||
logger.debug('cc-exam-marker', '🔄 Restoring original page', { currentPageId });
|
||||
editor.setCurrentPage(currentPageId);
|
||||
|
||||
const pdfBytes = await newPdf.save();
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([pdfBytes], { type: 'application/pdf' })
|
||||
);
|
||||
tickProgress();
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
logger.debug('cc-exam-marker', '✅ PDF export completed', { name });
|
||||
};
|
||||
|
||||
const handleExportCurrentView = async () => {
|
||||
if (!editor) return;
|
||||
const currentPdf = getCurrentPdf();
|
||||
if (!currentPdf) return;
|
||||
|
||||
setExportProgress(0);
|
||||
try {
|
||||
if (currentView === 'student-responses') {
|
||||
// For student responses, we need to handle each student's annotations separately
|
||||
const pagesPerStudent = currentPdf.pages.length / totalStudents;
|
||||
let currentProgress = 0;
|
||||
|
||||
// Create a new PDF with all pages
|
||||
const sourcePdf = await PDFDocument.load(currentPdf.source);
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(sourcePdf, Array.from({ length: currentPdf.pages.length }, (_, i) => i));
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
|
||||
// For each student, export their annotations onto their pages
|
||||
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
|
||||
const startPage = studentIndex * pagesPerStudent;
|
||||
|
||||
// Switch to the student's page to get their annotations
|
||||
const targetPageId = `page:student-response-${studentIndex}` as TLPageId;
|
||||
editor.setCurrentPage(targetPageId);
|
||||
|
||||
// Get all annotations for this student
|
||||
const pageShapeIds = new Set(currentPdf.pages.map(page => page.shapeId));
|
||||
const allShapeIds = Array.from(editor.getCurrentPageShapeIds()).filter(id => !pageShapeIds.has(id));
|
||||
|
||||
// Process each page for this student
|
||||
for (let i = 0; i < pagesPerStudent; i++) {
|
||||
const pageIndex = startPage + i;
|
||||
const page = currentPdf.pages[pageIndex];
|
||||
const pdfPage = newPdf.getPages()[pageIndex];
|
||||
|
||||
// Get shapes for this page
|
||||
const shapesInBounds = allShapeIds.filter((id) => {
|
||||
const shape = editor.getShape(id);
|
||||
if (!shape || shape.isLocked) return false;
|
||||
|
||||
// @ts-expect-error - annotationManager is added to editor in CCPdfEditor
|
||||
const annotationManager = editor.annotationManager;
|
||||
const annotationData = annotationManager.getAnnotationData(id);
|
||||
if (!annotationData) return false;
|
||||
|
||||
return annotationData.studentIndex === studentIndex && annotationData.pageIndex === i;
|
||||
});
|
||||
|
||||
if (shapesInBounds.length > 0) {
|
||||
// Export and draw annotations
|
||||
const exportedPng = await exportToBlob({
|
||||
editor,
|
||||
ids: shapesInBounds,
|
||||
format: 'png',
|
||||
opts: {
|
||||
background: false,
|
||||
bounds: new TLBox(
|
||||
page.bounds.x,
|
||||
0,
|
||||
page.bounds.width,
|
||||
page.bounds.height
|
||||
),
|
||||
padding: 0,
|
||||
scale: 1
|
||||
},
|
||||
});
|
||||
|
||||
const pngImage = await newPdf.embedPng(await exportedPng.arrayBuffer());
|
||||
pdfPage.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pdfPage.getWidth(),
|
||||
height: pdfPage.getHeight(),
|
||||
});
|
||||
}
|
||||
|
||||
currentProgress++;
|
||||
setExportProgress(currentProgress / (totalStudents * pagesPerStudent));
|
||||
}
|
||||
}
|
||||
|
||||
// Save the combined PDF
|
||||
const pdfBytes = await newPdf.save();
|
||||
const url = URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = currentPdf.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
// For exam/mark scheme view, use the original export logic
|
||||
await exportPdf(editor, currentPdf, setExportProgress);
|
||||
}
|
||||
} finally {
|
||||
setExportProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCurrentStudent = async () => {
|
||||
if (!editor || currentView !== 'student-responses') return;
|
||||
const currentPdf = getCurrentPdf();
|
||||
if (!currentPdf) return;
|
||||
|
||||
const pagesPerStudent = currentPdf.pages.length / totalStudents;
|
||||
const startPage = currentStudentIndex * pagesPerStudent;
|
||||
const endPage = startPage + pagesPerStudent;
|
||||
|
||||
setExportProgress(0);
|
||||
try {
|
||||
await exportPdf(
|
||||
editor,
|
||||
{
|
||||
...currentPdf,
|
||||
name: `Student_${currentStudentIndex + 1}_Response.pdf`,
|
||||
},
|
||||
setExportProgress,
|
||||
Math.floor(startPage),
|
||||
Math.floor(endPage),
|
||||
currentStudentIndex
|
||||
);
|
||||
} finally {
|
||||
setExportProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchExport = async () => {
|
||||
if (!editor || currentView !== 'student-responses') return;
|
||||
const currentPdf = getCurrentPdf();
|
||||
if (!currentPdf) return;
|
||||
|
||||
setExportProgress(0);
|
||||
try {
|
||||
const pagesPerStudent = currentPdf.pages.length / totalStudents;
|
||||
let currentProgress = 0;
|
||||
|
||||
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
|
||||
const startPage = studentIndex * pagesPerStudent;
|
||||
const endPage = startPage + pagesPerStudent;
|
||||
|
||||
await exportPdf(
|
||||
editor,
|
||||
{
|
||||
...currentPdf,
|
||||
name: `Student_${studentIndex + 1}_Response.pdf`,
|
||||
},
|
||||
setExportProgress,
|
||||
Math.floor(startPage),
|
||||
Math.floor(endPage),
|
||||
studentIndex
|
||||
);
|
||||
|
||||
currentProgress++;
|
||||
setExportProgress(currentProgress / totalStudents);
|
||||
}
|
||||
} finally {
|
||||
setExportProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Exam Marker
|
||||
</Typography>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
View Mode
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant={currentView === 'exam-and-markscheme' ? 'contained' : 'outlined'}
|
||||
onClick={() => onViewChange('exam-and-markscheme')}
|
||||
>
|
||||
Exam & Mark Scheme
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant={currentView === 'student-responses' ? 'contained' : 'outlined'}
|
||||
onClick={() => onViewChange('student-responses')}
|
||||
>
|
||||
Student Responses
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{currentView === 'student-responses' && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Student Navigation
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={onPreviousStudent}
|
||||
disabled={currentStudentIndex === 0}
|
||||
>
|
||||
Previous Student
|
||||
</Button>
|
||||
<Typography variant="body2" align="center">
|
||||
Student {currentStudentIndex + 1} of {totalStudents}
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={onNextStudent}
|
||||
disabled={currentStudentIndex === totalStudents - 1}
|
||||
>
|
||||
Next Student
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Actions
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
|
||||
onClick={handleExportCurrentView}
|
||||
>
|
||||
{exportProgress !== null
|
||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
||||
: `Export ${currentView === 'exam-and-markscheme' ? 'All' : 'Combined Responses'}`}
|
||||
</Button>
|
||||
|
||||
{currentView === 'student-responses' && (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
|
||||
onClick={handleExportCurrentStudent}
|
||||
>
|
||||
{exportProgress !== null
|
||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
||||
: 'Export Current Student'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
|
||||
onClick={handleBatchExport}
|
||||
>
|
||||
{exportProgress !== null
|
||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
||||
: 'Export All as Separate Files'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 'auto' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Statistics
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Total Pages: {getCurrentPdf()?.pages.length || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -141,7 +141,7 @@ export const CCFilesPanel: React.FC = () => {
|
||||
type RequestInitLike = { method?: string; body?: FormData | string | Blob | null; headers?: Record<string, string> } | undefined;
|
||||
type HeadersInitLike = Record<string, string>;
|
||||
|
||||
const API_BASE: string = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
||||
const API_BASE: string = import.meta.env.VITE_API_BASE || '/api';
|
||||
|
||||
const apiFetch = useCallback(async (url: string, init?: RequestInitLike) => {
|
||||
const headers: HeadersInitLike = {
|
||||
|
||||
@@ -106,7 +106,7 @@ export const CCFilesPanelEnhanced: React.FC = () => {
|
||||
type RequestInitLike = { method?: string; body?: FormData | string | Blob | null; headers?: Record<string, string> } | undefined;
|
||||
type HeadersInitLike = Record<string, string>;
|
||||
|
||||
const API_BASE: string = (import.meta as unknown as { env?: { VITE_API_BASE?: string } })?.env?.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
|
||||
const API_BASE: string = import.meta.env.VITE_API_BASE || '/api';
|
||||
|
||||
const apiFetch = async (url: string, init?: RequestInitLike) => {
|
||||
const headers: HeadersInitLike = {
|
||||
@@ -346,9 +346,10 @@ export const CCFilesPanelEnhanced: React.FC = () => {
|
||||
ref={dirInputRef}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
multiple
|
||||
onChange={handleFallbackDirectorySelect}
|
||||
// @ts-ignore WebKit directory picker attribute for folder upload
|
||||
webkitdirectory=""
|
||||
/>
|
||||
|
||||
<Tooltip title={isDirectoryPickerSupported() ? "Uses modern directory picker" : "Uses fallback method"}>
|
||||
|
||||
@@ -119,7 +119,7 @@ export const CCTranscriptionPanel: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const detectTimetable = async () => {
|
||||
try {
|
||||
const apiBase = import.meta.env.VITE_API_URL || 'https://api.classroomcopilot.ai';
|
||||
const apiBase = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||
const response = await fetch(`${apiBase}/database/timetables/current-period`);
|
||||
const data = await response.json();
|
||||
if (data.period_id) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user