Self-contained Dockerized build for end users. Run via docker compose; see README.md for setup. Source-only, no sample data or build artifacts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
228 lines
12 KiB
JavaScript
228 lines
12 KiB
JavaScript
"use client";
|
|
// components/QuestionCard.jsx — edit any question inline, with the answer key
|
|
// styled in red pen and grading-stamp verification badges.
|
|
import { useState } from "react";
|
|
import { questionTypeLabel } from "@/lib/schema";
|
|
|
|
const LETTERS = "ABCDEFGHIJ";
|
|
|
|
function AutoTextarea({ value, onChange, rows = 2, ...rest }) {
|
|
return <textarea value={value || ""} rows={rows} onChange={(e) => onChange(e.target.value)} {...rest} />;
|
|
}
|
|
|
|
export default function QuestionCard({ q, index, count, onChange, onMove, onDelete, onRegenerate, busy }) {
|
|
const [regenOpen, setRegenOpen] = useState(false);
|
|
const [note, setNote] = useState("");
|
|
|
|
function set(patch) {
|
|
onChange({ ...q, ...patch, verification: patch.verification || { status: "unchecked", note: "" } });
|
|
}
|
|
// Editing content invalidates the old verification stamp (set() above resets it),
|
|
// but pure point changes shouldn't:
|
|
function setPoints(points) {
|
|
onChange({ ...q, points });
|
|
}
|
|
|
|
const v = q.verification || { status: "unchecked" };
|
|
|
|
return (
|
|
<div className="card qcard">
|
|
<div className="qcard-head">
|
|
<span className="qnum">{index + 1}.</span>
|
|
<span className="chip chip-neutral">{questionTypeLabel(q.type)}</span>
|
|
{v.status === "pass" && <span className="stamp stamp-pass" title="The accuracy check confirmed this answer against your source.">✓ Verified</span>}
|
|
{v.status === "warn" && <span className="stamp stamp-warn" title={v.note}>⚠ Check this</span>}
|
|
<span className="qcard-actions">
|
|
<label className="small muted" style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
|
|
<input className="points-input" type="number" min="0" max="100" value={q.points}
|
|
onChange={(e) => setPoints(Math.max(0, Number(e.target.value) || 0))} aria-label="Points" />
|
|
pts
|
|
</label>
|
|
<button className="icon-btn" title="Move up" disabled={index === 0 || busy} onClick={() => onMove(-1)}>↑</button>
|
|
<button className="icon-btn" title="Move down" disabled={index === count - 1 || busy} onClick={() => onMove(1)}>↓</button>
|
|
<button className="icon-btn" title="Regenerate this question" disabled={busy} onClick={() => setRegenOpen((o) => !o)}>↻</button>
|
|
<button className="icon-btn danger" title="Delete question" disabled={busy} onClick={onDelete}>✕</button>
|
|
</span>
|
|
</div>
|
|
|
|
{v.status === "warn" && v.note && (
|
|
<div className="alert alert-warn" style={{ marginTop: 0 }}><b>Accuracy reviewer:</b> {v.note}</div>
|
|
)}
|
|
|
|
{regenOpen && (
|
|
<div className="alert alert-info" style={{ marginTop: 0 }}>
|
|
<div className="field-label">Regenerate this question from your source</div>
|
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
<input type="text" placeholder="Optional note to steer it — e.g. make it harder, focus on the causes" value={note}
|
|
onChange={(e) => setNote(e.target.value)} style={{ flex: 1, minWidth: 200 }} />
|
|
<button className="btn btn-primary btn-sm" disabled={busy} onClick={() => { onRegenerate(note); setRegenOpen(false); setNote(""); }}>
|
|
{busy ? <><span className="spinner" /> Working…</> : "Regenerate"}
|
|
</button>
|
|
<button className="btn btn-sm" onClick={() => setRegenOpen(false)}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Question prompt */}
|
|
<AutoTextarea
|
|
value={q.question}
|
|
onChange={(question) => set({ question })}
|
|
rows={q.type === "essay" || q.type === "discussion" ? 3 : 2}
|
|
aria-label="Question text"
|
|
placeholder={q.type === "fill_blank" ? "Sentence with ______ (six underscores) for each blank" : "Question text…"}
|
|
/>
|
|
|
|
{/* ---- type-specific bodies ---- */}
|
|
{q.type === "multiple_choice" && (
|
|
<div style={{ marginTop: 8 }}>
|
|
{(q.options || []).map((opt, i) => (
|
|
<div className="opt-row" key={i}>
|
|
<input
|
|
type="radio" name={"correct-" + q.id} checked={q.correctIndex === i}
|
|
onChange={() => set({ correctIndex: i })}
|
|
title="Mark as the correct answer"
|
|
/>
|
|
<span className="opt-letter">{LETTERS[i]}.</span>
|
|
<input type="text" value={opt} onChange={(e) => {
|
|
const options = [...q.options]; options[i] = e.target.value; set({ options });
|
|
}} placeholder={"Option " + LETTERS[i]} />
|
|
<button className="icon-btn danger" title="Remove option" disabled={q.options.length <= 2}
|
|
onClick={() => {
|
|
const options = q.options.filter((_, j) => j !== i);
|
|
let correctIndex = q.correctIndex;
|
|
if (correctIndex === i) correctIndex = 0;
|
|
else if (correctIndex > i) correctIndex -= 1;
|
|
set({ options, correctIndex });
|
|
}}>✕</button>
|
|
</div>
|
|
))}
|
|
{(q.options || []).length < 6 && (
|
|
<button className="btn btn-sm" style={{ marginTop: 4 }} onClick={() => set({ options: [...q.options, ""] })}>+ Add option</button>
|
|
)}
|
|
<div className="field-hint">The <span className="redpen">red radio</span> marks the correct answer.</div>
|
|
</div>
|
|
)}
|
|
|
|
{q.type === "true_false" && (
|
|
<div className="answer-key">
|
|
<span className="ak-label">Answer key</span>
|
|
<label className="check" style={{ margin: "2px 0", display: "inline-flex", marginRight: 18 }}>
|
|
<input type="radio" name={"tf-" + q.id} checked={q.correctAnswer === true} onChange={() => set({ correctAnswer: true })} />
|
|
<span>True</span>
|
|
</label>
|
|
<label className="check" style={{ margin: "2px 0", display: "inline-flex" }}>
|
|
<input type="radio" name={"tf-" + q.id} checked={q.correctAnswer === false} onChange={() => set({ correctAnswer: false })} />
|
|
<span>False</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{q.type === "short_answer" && (
|
|
<div className="answer-key">
|
|
<span className="ak-label">Answer key — sample answer</span>
|
|
<AutoTextarea value={q.sampleAnswer} onChange={(sampleAnswer) => set({ sampleAnswer })} rows={2} placeholder="A model answer…" />
|
|
<span className="ak-label" style={{ marginTop: 8 }}>Must-include points (one per line)</span>
|
|
<AutoTextarea
|
|
value={(q.keyPoints || []).join("\n")}
|
|
onChange={(text) => set({ keyPoints: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
|
|
rows={2} placeholder={"point 1\npoint 2"}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{q.type === "essay" && (
|
|
<div className="answer-key">
|
|
<span className="ak-label">Answer key — sample response</span>
|
|
<AutoTextarea value={q.sampleResponse} onChange={(sampleResponse) => set({ sampleResponse })} rows={4} placeholder="A strong model response or outline…" />
|
|
<span className="ak-label" style={{ marginTop: 8 }}>Rubric</span>
|
|
{(q.rubric || []).map((r, i) => (
|
|
<div key={i} style={{ display: "flex", gap: 7, margin: "5px 0", flexWrap: "wrap" }}>
|
|
<input type="text" value={r.criterion} placeholder="Criterion" style={{ flex: 2, minWidth: 140 }}
|
|
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, criterion: e.target.value } : x); set({ rubric }); }} />
|
|
<input type="number" className="points-input" value={r.points} min="0" title="Points"
|
|
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, points: Math.max(0, Number(e.target.value) || 0) } : x); set({ rubric }); }} />
|
|
<input type="text" value={r.description} placeholder="What earns full points" style={{ flex: 3, minWidth: 160 }}
|
|
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, description: e.target.value } : x); set({ rubric }); }} />
|
|
<button className="icon-btn danger" onClick={() => set({ rubric: q.rubric.filter((_, j) => j !== i) })}>✕</button>
|
|
</div>
|
|
))}
|
|
<button className="btn btn-sm" onClick={() => set({ rubric: [...(q.rubric || []), { criterion: "", points: 0, description: "" }] })}>+ Add criterion</button>
|
|
{(q.rubric || []).length > 0 && (
|
|
<div className="field-hint">Rubric total: {(q.rubric || []).reduce((s, r) => s + (Number(r.points) || 0), 0)} / question points: {q.points}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{q.type === "fill_blank" && (
|
|
<div className="answer-key">
|
|
<span className="ak-label">Answer key — one answer per blank, in order</span>
|
|
{(q.answers || []).map((a, i) => (
|
|
<div key={i} style={{ display: "flex", gap: 7, alignItems: "center", margin: "5px 0" }}>
|
|
<span className="opt-letter">{i + 1}.</span>
|
|
<input type="text" value={a} onChange={(e) => { const answers = [...q.answers]; answers[i] = e.target.value; set({ answers }); }} />
|
|
</div>
|
|
))}
|
|
<div className="field-hint">
|
|
Blanks found in the question: {(String(q.question).match(/_{3,}/g) || []).length}.
|
|
{" "}
|
|
<button className="btn btn-sm" onClick={() => {
|
|
const blanks = (String(q.question).match(/_{3,}/g) || []).length || 1;
|
|
const answers = Array.from({ length: blanks }, (_, i) => q.answers?.[i] || "");
|
|
set({ answers });
|
|
}}>Match answer slots to blanks</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{q.type === "matching" && (
|
|
<div className="answer-key">
|
|
<span className="ak-label">Answer key — correct pairs (the student copy shuffles the right column)</span>
|
|
{(q.pairs || []).map((p, i) => (
|
|
<div key={i} style={{ display: "flex", gap: 7, margin: "5px 0", alignItems: "center", flexWrap: "wrap" }}>
|
|
<input type="text" value={p.left} placeholder="Left item" style={{ flex: 1, minWidth: 130 }}
|
|
onChange={(e) => { const pairs = q.pairs.map((x, j) => j === i ? { ...x, left: e.target.value } : x); set({ pairs }); }} />
|
|
<span className="muted">→</span>
|
|
<input type="text" value={p.right} placeholder="Matches with" style={{ flex: 1, minWidth: 130 }}
|
|
onChange={(e) => { const pairs = q.pairs.map((x, j) => j === i ? { ...x, right: e.target.value } : x); set({ pairs }); }} />
|
|
<button className="icon-btn danger" disabled={(q.pairs || []).length <= 2} onClick={() => set({ pairs: q.pairs.filter((_, j) => j !== i) })}>✕</button>
|
|
</div>
|
|
))}
|
|
{(q.pairs || []).length < 10 && (
|
|
<button className="btn btn-sm" onClick={() => set({ pairs: [...q.pairs, { left: "", right: "" }] })}>+ Add pair</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{q.type === "discussion" && (
|
|
<div className="answer-key">
|
|
<span className="ak-label">Facilitator notes — key talking points (one per line)</span>
|
|
<AutoTextarea
|
|
value={(q.talkingPoints || []).join("\n")}
|
|
onChange={(text) => set({ talkingPoints: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
|
|
rows={3}
|
|
/>
|
|
<span className="ak-label" style={{ marginTop: 8 }}>Follow-up questions (one per line)</span>
|
|
<AutoTextarea
|
|
value={(q.followUps || []).join("\n")}
|
|
onChange={(text) => set({ followUps: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Explanation + source ref, present for all types */}
|
|
{q.type !== "discussion" && (
|
|
<div className="answer-key" style={{ background: "#fff7f5" }}>
|
|
<span className="ak-label">Explanation (teacher key)</span>
|
|
<AutoTextarea value={q.explanation} onChange={(explanation) => set({ explanation })} rows={2} placeholder="Why this answer is correct…" />
|
|
</div>
|
|
)}
|
|
{q.sourceRef ? (
|
|
<p className="small muted" style={{ margin: "9px 0 0" }}>
|
|
<b>Source:</b> “{q.sourceRef}”
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|