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-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/ { \
|
||||
proxy_pass http://192.168.0.64:18000/api/; \
|
||||
proxy_set_header Host $host; \
|
||||
|
||||
@@ -20,16 +20,21 @@ services:
|
||||
# same-origin and pass through Dockerfile's /__ccapi proxy. The proxy
|
||||
# strips that prefix before forwarding, preserving mixed backend routes
|
||||
# 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_URL: /__ccapi
|
||||
VITE_SUPABASE_URL: /__supabase
|
||||
VITE_APP_NAME: ${VITE_APP_NAME:-Classroom Copilot}
|
||||
VITE_APP_HMR_URL: ${VITE_APP_HMR_URL:-}
|
||||
VITE_DEV: ${VITE_DEV:-false}
|
||||
VITE_FRONTEND_SITE_URL: ${VITE_FRONTEND_SITE_URL:-}
|
||||
VITE_SEARCH_URL: ${VITE_SEARCH_URL:-}
|
||||
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-}
|
||||
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL:-}
|
||||
VITE_SUPER_ADMIN_EMAIL: ${VITE_SUPER_ADMIN_EMAIL:-}
|
||||
VITE_TLSYNC_URL: ${VITE_TLSYNC_URL:-}
|
||||
VITE_WHISPERLIVE_URL: ${VITE_WHISPERLIVE_URL:-}
|
||||
|
||||
@@ -102,6 +102,17 @@ const PlatformAdminPage: React.FC = () => {
|
||||
</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>
|
||||
|
||||
{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 { v5 as uuidv5 } from 'uuid';
|
||||
import {
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
|
||||
import { examRepository } from '../../services/exam/examRepository';
|
||||
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
|
||||
@@ -43,6 +44,8 @@ const ExamMarkingPage: React.FC = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = 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 () => {
|
||||
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],
|
||||
);
|
||||
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 saveSelected = async () => {
|
||||
@@ -154,7 +179,29 @@ const ExamMarkingPage: React.FC = () => {
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
||||
<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>
|
||||
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
|
||||
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
||||
|
||||
@@ -211,7 +211,9 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
setTemplate(detail)
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
loadShapes(editor, shapesFromTemplate(detail, pageGeometriesRef.current))
|
||||
const shapes = shapesFromTemplate(detail, pageGeometriesRef.current)
|
||||
loadShapes(editor, shapes)
|
||||
if (!shapes.length) seedGuide(editor)
|
||||
bringDomainShapesToFront(editor)
|
||||
}
|
||||
setDirty(false)
|
||||
@@ -417,7 +419,14 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
||||
applyDocViewConstraints(editor, [])
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
Neo4jSyncResult,
|
||||
PatchQuestionPayload,
|
||||
SpecPoint,
|
||||
StudentSubmission,
|
||||
TemplateReplacePayload,
|
||||
UpdateTemplateMetaPayload,
|
||||
} from '../../types/exam.types';
|
||||
@@ -298,6 +299,21 @@ export const examRepository = {
|
||||
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
||||
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;
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||
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;
|
||||
|
||||
// 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) {
|
||||
throw new Error('Missing Supabase configuration');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user