/* ==================================================================
Online Tests — teacher screen
- Type picker (Blitz / Classical)
- Paste raw MCQ text → Gemini formats → editable form
- Save as draft / Save & make live (publishes to the student portal)
================================================================== */
const OT_ACCENT = "var(--blue-solid)";
const TYPE_PRESETS = [
{
id: "blitz",
label: "Blitz",
tagline: "Per-question timer · no back-tracking",
detail: "Best for rapid practice and reflex drills. Each question gets a hard time limit; once it expires, the answer is locked.",
accent: "var(--red-solid)",
},
{
id: "classical",
label: "Classical",
tagline: "Total timer · free navigation",
detail: "Best for full-length tests. One overall timer; students can navigate freely, mark for review, and use the palette.",
accent: OT_ACCENT,
},
{
id: "descriptive",
label: "Descriptive",
tagline: "Students upload photo answers · teacher grades manually",
detail: "Long-form questions. Students attach photo(s) of their handwritten work per question. No options, no auto-grading — every mark comes from your review.",
accent: "var(--amber-solid)",
},
];
const OnlineTestsScreen = ({ setScreen }) => {
const [testType, setTestType] = React.useState("classical");
const [rawText, setRawText] = React.useState("");
const [parsed, setParsed] = React.useState(null);
const [warnings, setWarnings] = React.useState([]);
const [formatting, setFormatting] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState("");
const [successId, setSuccessId] = React.useState(null);
const handleFormat = async () => {
if (!rawText.trim()) { setErr("Paste some MCQs first."); return; }
setErr(""); setFormatting(true); setWarnings([]);
try {
const r = await window.AppApi.post("/online-tests/format", {
raw_text: rawText, type: testType,
});
setParsed(r.parsed);
setWarnings(Array.isArray(r.parsed?.warnings) ? r.parsed.warnings : []);
} catch (e) {
setErr(e.message || "Gemini failed to parse the text");
} finally {
setFormatting(false);
}
};
const handleSave = async (publish) => {
if (!parsed) { setErr("Format the test before saving."); return; }
setErr(""); setSaving(true);
try {
const r = await window.AppApi.post("/online-tests", {
parsed, type: testType, publish: !!publish,
// Multi-section: send the chip set. Backend UNIONs students from
// every section in the array when auto-enrolling.
class_section_ids: (parsed.class_section_ids && parsed.class_section_ids.length)
? parsed.class_section_ids : undefined,
subject_id: parsed.subject_id || undefined,
});
setSuccessId(r.id);
} catch (e) {
const errs = e.message?.includes("validation failed") ? e.message : (e.message || "Save failed");
setErr(errs);
} finally {
setSaving(false);
}
};
if (successId) {
return (
setScreen("library")} onNew={() => {
setSuccessId(null); setParsed(null); setRawText(""); setWarnings(""); setErr("");
}}/>
);
}
return (
{/* Left column — type picker (sticky) */}
Test type
{TYPE_PRESETS.map((t) => (
setTestType(t.id)} disabled={!!parsed}/>
))}
{parsed && (
Type is locked once formatted. Click "Reset" below the form to start over.
)}
{/* Right column — paste + form */}
{!parsed && (
)}
{warnings.length > 0 && (
Gemini warnings
{warnings.map((w, i) => (
• {w}
))}
)}
{parsed && (
{ setParsed(null); setWarnings([]); }}/>
)}
{err && (
{err}
)}
{parsed && (
handleSave(false)}>
{saving ? "Saving…" : "Save as draft"}
handleSave(true)}
style={{ background: OT_ACCENT, borderColor: OT_ACCENT }}>
{saving ? "Saving…" : "Save & make live"}
)}
);
};
/* ---------- Shell wrapping for the screen body ---------- */
const OTScreenShell = ({ children }) => (
Assessment · Online tests
Create online test
Paste raw MCQs · Gemini formats them · review · publish to the student portal.
{children}
);
const OTLabel = ({ children }) => (
{children}
);
const TypeCard = ({ preset, selected, onClick, disabled }) => (
{preset.tagline}
{preset.detail}
);
/* ---------- Paste card ---------- */
const PasteCard = ({ raw, setRaw, formatting, onFormat }) => (
Paste MCQs
Free-text — Gemini will identify questions, options, and the correct answer if marked.
);
/* ---------- Form editor ---------- */
const FormEditor = ({ parsed, setParsed, testType, onReset }) => {
const classSections = window.APP?.CLASS_SECTIONS || [];
const subjects = window.APP?.SUBJECTS || [];
const classLevels = Array.from(new Set(classSections.map(cs => cs.class_level))).sort();
const isDescriptive = testType === "descriptive";
const set = (path, value) => {
setParsed((prev) => {
const next = JSON.parse(JSON.stringify(prev));
const keys = path.split(".");
let node = next;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
node = /^\d+$/.test(k) ? node[Number(k)] : node[k];
}
const last = keys[keys.length - 1];
if (/^\d+$/.test(last)) node[Number(last)] = value;
else node[last] = value;
return next;
});
};
const setCorrect = (si, qi, oi) => {
setParsed((prev) => {
const next = JSON.parse(JSON.stringify(prev));
next.sections[si].questions[qi].options.forEach((o, idx) => o.is_correct = (idx === oi));
return next;
});
};
const addQuestion = (si) => {
setParsed((prev) => {
const next = JSON.parse(JSON.stringify(prev));
const sec = next.sections[si];
sec.questions.push({
q_no: sec.questions.length + 1, text: "",
marks: sec.questions[0]?.marks ?? (isDescriptive ? 5 : 1),
options: isDescriptive
? []
: ["A","B","C","D"].map((l, i) => ({ letter: l, text: "", is_correct: i === 0 })),
});
return next;
});
};
const delQuestion = (si, qi) => {
setParsed((prev) => {
const next = JSON.parse(JSON.stringify(prev));
next.sections[si].questions.splice(qi, 1);
// Renumber q_no.
next.sections[si].questions.forEach((q, i) => q.q_no = i + 1);
return next;
});
};
return (
{/* Meta block */}
Test details
↺ Reset
set("title", v)}/>
{
// Reset section set to the first available section so
// the chip row isn't empty after the class change.
const first = classSections.find(cs => cs.class_level === v);
setParsed(prev => ({
...prev, className: v,
class_section_ids: first ? [first.id] : [],
}));
}}/>
({ value: s.id, label: s.name }))}
onChange={(v) => {
const s = subjects.find(x => x.id === v);
setParsed(prev => ({ ...prev, subject_id: v, subject: s?.name || "" }));
}}/>
setParsed(prev => ({ ...prev, class_section_ids: ids }))}/>
{/* Chapter + topic dropdowns sourced from the admin catalog (see
screen-manual.jsx for the shared picker). Plays nicely with the
existing `parsed.topic` / `parsed.chapter` text fields — the
picker reads + writes through them. The wrapper just adapts
{meta,setMeta} → {parsed,setParsed} so the existing component
can be reused unchanged. */}
setParsed(next)}
subjects={subjects}/>
set("scheduled_date", v || null)}/>
{/* Sections */}
{(parsed.sections || []).map((sec, si) => (
))}
);
};
/* ---------- Image upload helper ---------- */
async function uploadImage(file) {
if (!file) return null;
if (!/^image\//.test(file.type)) throw new Error("Not an image file");
if (file.size > 5 * 1024 * 1024) throw new Error("Image is larger than 5 MB");
const form = new FormData();
form.append("file", file, file.name);
const r = await fetch(window.AppApi.base + "/online-tests/upload-image", { method: "POST", body: form });
if (!r.ok) throw new Error(`upload ${r.status}: ${await r.text()}`);
return (await r.json()).url;
}
/* ---------- Multi-image list (used for question + solution images) ---------- */
const ImageList = ({ label, urls, onAdd, onRemove }) => {
const [busy, setBusy] = React.useState(false);
const inputRef = React.useRef(null);
const list = Array.isArray(urls) ? urls : [];
const pick = async (file) => {
if (!file) return;
setBusy(true);
try { onAdd(await uploadImage(file)); }
catch (e) { alert("Upload failed: " + (e?.message || e)); }
finally { setBusy(false); if (inputRef.current) inputRef.current.value = ""; }
};
return (
{label}
inputRef.current?.click()}>
{busy ? "Uploading…" : "+ image"}
pick(e.target.files?.[0])}/>
{list.length > 0 && (
{list.map((src, i) => (
onRemove(i)}
style={{ position: "absolute", top: -6, right: -6, width: 18, height: 18,
borderRadius: "50%", border: "1px solid var(--line-strong)",
background: "var(--bg-2)", color: "var(--red)", fontSize: 11, lineHeight: "16px",
cursor: "pointer", padding: 0 }}>×
))}
)}
);
};
/* ---------- Single-image slot (per option) ---------- */
const OptionImage = ({ url, onChange }) => {
const [busy, setBusy] = React.useState(false);
const inputRef = React.useRef(null);
const pick = async (file) => {
if (!file) return;
setBusy(true);
try { onChange(await uploadImage(file)); }
catch (e) { alert("Upload failed: " + (e?.message || e)); }
finally { setBusy(false); if (inputRef.current) inputRef.current.value = ""; }
};
if (url) {
return (
onChange(null)}
style={{ position: "absolute", top: -5, right: -5, width: 14, height: 14, borderRadius: "50%",
border: "1px solid var(--line-strong)", background: "var(--bg-2)",
color: "var(--red)", fontSize: 9, lineHeight: "12px", padding: 0, cursor: "pointer" }}>×
);
}
return (
<>
inputRef.current?.click()}
title="Attach option image"
style={{ marginLeft: 4, padding: "2px 6px", fontSize: 10 }}>
{busy ? "…" : "+img"}
pick(e.target.files?.[0])}/>
>
);
};
const QuestionEditor = ({ q, si, qi, set, setCorrect, onDelete, isDescriptive }) => {
const imgs = q.images || { question: [], solution: [] };
const setImages = (next) => set(`sections.${si}.questions.${qi}.images`, next);
const addQImage = (url) => setImages({ ...imgs, question: [...(imgs.question || []), url] });
const rmQImage = (i) => setImages({ ...imgs, question: (imgs.question || []).filter((_, k) => k !== i) });
const addSImage = (url) => setImages({ ...imgs, solution: [...(imgs.solution || []), url] });
const rmSImage = (i) => setImages({ ...imgs, solution: (imgs.solution || []).filter((_, k) => k !== i) });
const options = Array.isArray(q.options) ? q.options : [];
return (
);
};
const Field = ({ label, required, children }) => (
{label}{required && * }
{children}
);
const FIELD_STYLE = {
background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 5,
padding: "6px 10px", color: "var(--ink-0)", fontSize: 12, width: "100%",
boxSizing: "border-box",
};
const Input = ({ value, onChange, placeholder, type = "text" }) => (
onChange(e.target.value)}
// colorScheme: dark so the native date picker chrome (calendar icon,
// popover) renders dark too instead of a white square on dark theme.
style={{ ...FIELD_STYLE, colorScheme: "dark" }}/>
);
const Dropdown = ({ value, onChange, options, placeholder }) => (
onChange(e.target.value)}
style={{ ...FIELD_STYLE, appearance: "auto", colorScheme: "dark" }}>
{placeholder && {placeholder} }
{options.map((o) => (
typeof o === "string"
? {o}
: {o.label}
))}
);
/* ---------- Success splash ---------- */
const OTSuccess = ({ id, onBack, onNew }) => (
✓
Test {id} saved
Use the ⚙ button on the test card if you want to tweak the engine config later.
← Back to library
+ Create another
);
window.OnlineTestsScreen = OnlineTestsScreen;