Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a1dd4dccc | ||
|
|
7bd66fbaf0 | ||
|
|
9c3a5f97cc | ||
|
|
2ccfb9ccd6 |
@@ -67,6 +67,15 @@ RUN echo 'server { \
|
|||||||
proxy_set_header X-Real-IP $remote_addr; \
|
proxy_set_header X-Real-IP $remote_addr; \
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||||
} \
|
} \
|
||||||
|
location /__supabase/ { \
|
||||||
|
proxy_pass http://192.168.0.94:8000/; \
|
||||||
|
proxy_set_header Host $host; \
|
||||||
|
proxy_set_header X-Real-IP $remote_addr; \
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||||
|
proxy_set_header Upgrade $http_upgrade; \
|
||||||
|
proxy_set_header Connection "upgrade"; \
|
||||||
|
proxy_http_version 1.1; \
|
||||||
|
} \
|
||||||
location /api/ { \
|
location /api/ { \
|
||||||
proxy_pass http://192.168.0.64:18000/api/; \
|
proxy_pass http://192.168.0.64:18000/api/; \
|
||||||
proxy_set_header Host $host; \
|
proxy_set_header Host $host; \
|
||||||
|
|||||||
@@ -20,16 +20,21 @@ services:
|
|||||||
# same-origin and pass through Dockerfile's /__ccapi proxy. The proxy
|
# same-origin and pass through Dockerfile's /__ccapi proxy. The proxy
|
||||||
# strips that prefix before forwarding, preserving mixed backend routes
|
# strips that prefix before forwarding, preserving mixed backend routes
|
||||||
# such as /api/exam, /me/bootstrap, and /database/timetable.
|
# such as /api/exam, /me/bootstrap, and /database/timetable.
|
||||||
# .env.dev still points at the LAN API for local Vite/dev tooling.
|
# Supabase is likewise routed same-origin via Dockerfile's /__supabase
|
||||||
|
# proxy -> dev Supabase .94 (no CORS, browser-network-agnostic). The
|
||||||
|
# leading-slash URL is resolved against window.location.origin in
|
||||||
|
# supabaseClient.ts. Provide the matching .94 anon key as a build arg
|
||||||
|
# (VITE_SUPABASE_ANON_KEY) so it pairs with the .94 backend.
|
||||||
|
# .env.dev still points at the LAN API/Supabase for local Vite/dev tooling.
|
||||||
VITE_API_BASE: /__ccapi
|
VITE_API_BASE: /__ccapi
|
||||||
VITE_API_URL: /__ccapi
|
VITE_API_URL: /__ccapi
|
||||||
|
VITE_SUPABASE_URL: /__supabase
|
||||||
VITE_APP_NAME: ${VITE_APP_NAME:-Classroom Copilot}
|
VITE_APP_NAME: ${VITE_APP_NAME:-Classroom Copilot}
|
||||||
VITE_APP_HMR_URL: ${VITE_APP_HMR_URL:-}
|
VITE_APP_HMR_URL: ${VITE_APP_HMR_URL:-}
|
||||||
VITE_DEV: ${VITE_DEV:-false}
|
VITE_DEV: ${VITE_DEV:-false}
|
||||||
VITE_FRONTEND_SITE_URL: ${VITE_FRONTEND_SITE_URL:-}
|
VITE_FRONTEND_SITE_URL: ${VITE_FRONTEND_SITE_URL:-}
|
||||||
VITE_SEARCH_URL: ${VITE_SEARCH_URL:-}
|
VITE_SEARCH_URL: ${VITE_SEARCH_URL:-}
|
||||||
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-}
|
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-}
|
||||||
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL:-}
|
|
||||||
VITE_SUPER_ADMIN_EMAIL: ${VITE_SUPER_ADMIN_EMAIL:-}
|
VITE_SUPER_ADMIN_EMAIL: ${VITE_SUPER_ADMIN_EMAIL:-}
|
||||||
VITE_TLSYNC_URL: ${VITE_TLSYNC_URL:-}
|
VITE_TLSYNC_URL: ${VITE_TLSYNC_URL:-}
|
||||||
VITE_WHISPERLIVE_URL: ${VITE_WHISPERLIVE_URL:-}
|
VITE_WHISPERLIVE_URL: ${VITE_WHISPERLIVE_URL:-}
|
||||||
|
|||||||
@@ -102,6 +102,17 @@ const PlatformAdminPage: React.FC = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Destructive reset scope: exam-corpus</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
The <strong>exam-corpus</strong> reset is not limited to public papers. It wipes the entire
|
||||||
|
exam-marker subsystem: public corpus/eb_* data, cc.examboards storage, templates, template
|
||||||
|
layouts, questions, boundaries, response areas, marking batches, student submissions, and
|
||||||
|
mark entries. Use it only when you intend to rebuild all exam-marker data; it does not reset
|
||||||
|
schools, users, or timetable data.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { v5 as uuidv5 } from 'uuid';
|
import { v5 as uuidv5 } from 'uuid';
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import SaveIcon from '@mui/icons-material/Save';
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
import TableChartIcon from '@mui/icons-material/TableChart';
|
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||||
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
|
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
|
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
|
||||||
@@ -43,6 +44,8 @@ const ExamMarkingPage: React.FC = () => {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!batchId) return;
|
if (!batchId) return;
|
||||||
@@ -68,6 +71,28 @@ const ExamMarkingPage: React.FC = () => {
|
|||||||
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
|
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
|
||||||
[template],
|
[template],
|
||||||
);
|
);
|
||||||
|
const onScanFiles = useCallback(async (files: FileList | null) => {
|
||||||
|
if (!batchId || !files || files.length === 0) return;
|
||||||
|
setUploading(true); setError(null); setMessage(null);
|
||||||
|
let ok = 0;
|
||||||
|
const failures: string[] = [];
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
try {
|
||||||
|
// 'ordered' assigns each scan to the next not-yet-submitted roster slot; unmatched scans
|
||||||
|
// become an unmatched submission the teacher can reconcile.
|
||||||
|
await examRepository.uploadScan(batchId, file, { matchingMethod: 'ordered' });
|
||||||
|
ok += 1;
|
||||||
|
} catch (e) {
|
||||||
|
failures.push(`${file.name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
await load();
|
||||||
|
if (ok) setMessage(`Uploaded ${ok} scan${ok === 1 ? '' : 's'}${failures.length ? `, ${failures.length} failed` : ''}`);
|
||||||
|
if (failures.length) setError(failures.join(' · '));
|
||||||
|
}, [batchId, load]);
|
||||||
|
|
||||||
const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
|
const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
|
||||||
|
|
||||||
const saveSelected = async () => {
|
const saveSelected = async () => {
|
||||||
@@ -154,7 +179,29 @@ const ExamMarkingPage: React.FC = () => {
|
|||||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||||
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>Marking queue</Typography>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 1 }}>
|
||||||
|
<Typography variant="h6">Marking queue</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<UploadFileIcon fontSize="small" />}
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{uploading ? 'Uploading…' : 'Upload scans'}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => { void onScanFiles(e.target.files); }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||||
|
Scanned PDFs fill the roster in order; unmatched scans appear below to reconcile.
|
||||||
|
</Typography>
|
||||||
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
|
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
|
||||||
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
|
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
|
||||||
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
||||||
|
|||||||
@@ -211,7 +211,9 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
setTemplate(detail)
|
setTemplate(detail)
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
if (editor) {
|
if (editor) {
|
||||||
loadShapes(editor, shapesFromTemplate(detail, pageGeometriesRef.current))
|
const shapes = shapesFromTemplate(detail, pageGeometriesRef.current)
|
||||||
|
loadShapes(editor, shapes)
|
||||||
|
if (!shapes.length) seedGuide(editor)
|
||||||
bringDomainShapesToFront(editor)
|
bringDomainShapesToFront(editor)
|
||||||
}
|
}
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
@@ -417,7 +419,14 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
||||||
applyDocViewConstraints(editor, [])
|
applyDocViewConstraints(editor, [])
|
||||||
editor.resetZoom()
|
editor.resetZoom()
|
||||||
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
|
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
|
||||||
|
// (Previously `else seedGuide` fired on mount while `template` was still null during
|
||||||
|
// the async fetch, flashing placeholder shapes before the real shapes/PDF rendered.)
|
||||||
|
if (template) {
|
||||||
|
const s = shapesFromTemplate(template, pageGeometriesRef.current)
|
||||||
|
loadShapes(editor, s)
|
||||||
|
if (!s.length) seedGuide(editor)
|
||||||
|
}
|
||||||
bringDomainShapesToFront(editor)
|
bringDomainShapesToFront(editor)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
Neo4jSyncResult,
|
Neo4jSyncResult,
|
||||||
PatchQuestionPayload,
|
PatchQuestionPayload,
|
||||||
SpecPoint,
|
SpecPoint,
|
||||||
|
StudentSubmission,
|
||||||
TemplateReplacePayload,
|
TemplateReplacePayload,
|
||||||
UpdateTemplateMetaPayload,
|
UpdateTemplateMetaPayload,
|
||||||
} from '../../types/exam.types';
|
} from '../../types/exam.types';
|
||||||
@@ -298,6 +299,21 @@ export const examRepository = {
|
|||||||
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async uploadScan(
|
||||||
|
batchId: string,
|
||||||
|
file: File,
|
||||||
|
opts: { studentId?: string; matchingMethod?: 'manual' | 'ordered' } = {},
|
||||||
|
): Promise<StudentSubmission> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('matching_method', opts.matchingMethod ?? 'ordered');
|
||||||
|
if (opts.studentId) form.append('student_id', opts.studentId);
|
||||||
|
// Let axios set the multipart Content-Type + boundary (only Authorization is forwarded).
|
||||||
|
const res = await axios.post<StudentSubmission>(`${EXAM_BASE}/batches/${batchId}/scans`, form, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default examRepository;
|
export default examRepository;
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { logger } from './debugConfig';
|
import { logger } from './debugConfig';
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
const rawSupabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
// Same-origin proxy support: a leading-slash value (e.g. "/__supabase") is
|
||||||
|
// resolved against the current browser origin so supabase-js receives an
|
||||||
|
// absolute URL while every request stays same-origin (no CORS) and routes
|
||||||
|
// through the app host's /__supabase nginx proxy to the backend Supabase.
|
||||||
|
const supabaseUrl = rawSupabaseUrl?.startsWith('/')
|
||||||
|
? `${window.location.origin}${rawSupabaseUrl}`
|
||||||
|
: rawSupabaseUrl;
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
throw new Error('Missing Supabase configuration');
|
throw new Error('Missing Supabase configuration');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user