256 lines
8.6 KiB
TypeScript
256 lines
8.6 KiB
TypeScript
import React, { useState } from "react";
|
|
import {
|
|
CheckCircle2,
|
|
XCircle,
|
|
RotateCcw,
|
|
ChevronRight,
|
|
MousePointerClick,
|
|
} from "lucide-react";
|
|
|
|
export interface EvidenceExercise {
|
|
question: string;
|
|
passage: string[]; // array of sentences rendered as a flowing paragraph
|
|
evidenceIndex: number; // 0-based index of the correct sentence
|
|
explanation: string;
|
|
}
|
|
|
|
interface EvidenceHunterWidgetProps {
|
|
exercises: EvidenceExercise[];
|
|
accentColor?: string;
|
|
}
|
|
|
|
// Tailwind needs complete class strings — map accent to concrete classes
|
|
const ACCENT: Record<
|
|
string,
|
|
{
|
|
tab: string;
|
|
header: string;
|
|
label: string;
|
|
hover: string;
|
|
selected: string;
|
|
btn: string;
|
|
next: string;
|
|
}
|
|
> = {
|
|
teal: {
|
|
tab: "border-b-2 border-teal-600 text-teal-700",
|
|
header: "bg-teal-50",
|
|
label: "text-teal-500",
|
|
hover: "hover:bg-teal-50 hover:border-teal-400",
|
|
selected: "bg-teal-100 border-teal-500",
|
|
btn: "bg-teal-600 hover:bg-teal-700",
|
|
next: "text-teal-700 hover:text-teal-900",
|
|
},
|
|
fuchsia: {
|
|
tab: "border-b-2 border-fuchsia-600 text-fuchsia-700",
|
|
header: "bg-fuchsia-50",
|
|
label: "text-fuchsia-500",
|
|
hover: "hover:bg-fuchsia-50 hover:border-fuchsia-400",
|
|
selected: "bg-fuchsia-100 border-fuchsia-500",
|
|
btn: "bg-fuchsia-600 hover:bg-fuchsia-700",
|
|
next: "text-fuchsia-700 hover:text-fuchsia-900",
|
|
},
|
|
purple: {
|
|
tab: "border-b-2 border-purple-600 text-purple-700",
|
|
header: "bg-purple-50",
|
|
label: "text-purple-500",
|
|
hover: "hover:bg-purple-50 hover:border-purple-400",
|
|
selected: "bg-purple-100 border-purple-500",
|
|
btn: "bg-purple-600 hover:bg-purple-700",
|
|
next: "text-purple-700 hover:text-purple-900",
|
|
},
|
|
amber: {
|
|
tab: "border-b-2 border-amber-600 text-amber-700",
|
|
header: "bg-amber-50",
|
|
label: "text-amber-500",
|
|
hover: "hover:bg-amber-50 hover:border-amber-400",
|
|
selected: "bg-amber-100 border-amber-500",
|
|
btn: "bg-amber-600 hover:bg-amber-700",
|
|
next: "text-amber-700 hover:text-amber-900",
|
|
},
|
|
};
|
|
|
|
export default function EvidenceHunterWidget({
|
|
exercises,
|
|
accentColor = "teal",
|
|
}: EvidenceHunterWidgetProps) {
|
|
const [activeEx, setActiveEx] = useState(0);
|
|
const [selected, setSelected] = useState<number | null>(null);
|
|
const [submitted, setSubmitted] = useState(false);
|
|
|
|
const exercise = exercises[activeEx];
|
|
const isCorrect = submitted && selected === exercise.evidenceIndex;
|
|
const c = ACCENT[accentColor] ?? ACCENT.teal;
|
|
|
|
const reset = () => {
|
|
setSelected(null);
|
|
setSubmitted(false);
|
|
};
|
|
const switchEx = (i: number) => {
|
|
setActiveEx(i);
|
|
setSelected(null);
|
|
setSubmitted(false);
|
|
};
|
|
|
|
return (
|
|
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
|
|
{/* Tab strip */}
|
|
{exercises.length > 1 && (
|
|
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
|
|
{exercises.map((_, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => switchEx(i)}
|
|
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors bg-white ${
|
|
i === activeEx ? c.tab : "text-gray-500 hover:text-gray-700"
|
|
}`}
|
|
>
|
|
Passage {i + 1}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Question */}
|
|
<div className={`px-5 py-4 border-b border-gray-200 ${c.header}`}>
|
|
<p
|
|
className={`text-xs font-bold uppercase tracking-wider mb-1.5 ${c.label}`}
|
|
>
|
|
Question
|
|
</p>
|
|
<p className="text-gray-800 font-semibold leading-snug text-base">
|
|
{exercise.question}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Passage — flowing text with inline clickable sentences */}
|
|
<div className="px-5 pt-5 pb-3">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
|
Passage
|
|
</p>
|
|
{!submitted && (
|
|
<span className="flex items-center gap-1 text-xs text-gray-400 italic">
|
|
<MousePointerClick className="w-3 h-3" /> click the sentence that
|
|
answers the question
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Render as a flowing paragraph with clickable sentence spans */}
|
|
<div className="text-sm text-gray-700 leading-8 bg-gray-50 rounded-xl border border-gray-200 px-5 py-4 select-none">
|
|
{exercise.passage.map((sentence, i) => {
|
|
// Determine highlight class for this sentence
|
|
let spanCls = `inline cursor-pointer rounded px-0.5 py-0.5 border border-transparent transition-all ${c.hover}`;
|
|
if (submitted) {
|
|
if (i === exercise.evidenceIndex) {
|
|
spanCls =
|
|
"inline rounded px-0.5 py-0.5 border bg-green-100 border-green-400 text-green-800 font-medium cursor-default";
|
|
} else if (i === selected) {
|
|
spanCls =
|
|
"inline rounded px-0.5 py-0.5 border bg-red-100 border-red-300 text-red-600 cursor-default line-through";
|
|
} else {
|
|
spanCls =
|
|
"inline rounded px-0.5 py-0.5 border border-transparent text-gray-400 cursor-default";
|
|
}
|
|
} else if (selected === i) {
|
|
spanCls = `inline rounded px-0.5 py-0.5 border cursor-pointer ${c.selected} font-medium`;
|
|
}
|
|
|
|
return (
|
|
<React.Fragment key={i}>
|
|
<span
|
|
onClick={() => {
|
|
if (!submitted) setSelected(i);
|
|
}}
|
|
className={spanCls}
|
|
title={
|
|
submitted ? undefined : `Click to select sentence ${i + 1}`
|
|
}
|
|
>
|
|
{sentence}
|
|
</span>
|
|
{i < exercise.passage.length - 1 ? " " : ""}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Selection indicator */}
|
|
{!submitted && selected !== null && (
|
|
<p className="mt-2 text-xs text-gray-500 italic">
|
|
Selected:{" "}
|
|
<span className="font-semibold text-gray-700">
|
|
"{exercise.passage[selected].slice(0, 60)}
|
|
{exercise.passage[selected].length > 60 ? "…" : ""}"
|
|
</span>
|
|
</p>
|
|
)}
|
|
{!submitted && selected === null && (
|
|
<p className="mt-2 text-xs text-gray-400 italic">
|
|
No sentence selected yet.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Submit / result */}
|
|
<div className="px-5 pb-5">
|
|
{!submitted ? (
|
|
<button
|
|
disabled={selected === null}
|
|
onClick={() => setSubmitted(true)}
|
|
className={`mt-2 px-5 py-2.5 rounded-full text-sm font-bold text-white transition-colors ${
|
|
selected !== null
|
|
? c.btn
|
|
: "bg-gray-200 text-gray-400 cursor-not-allowed"
|
|
}`}
|
|
>
|
|
Check my answer
|
|
</button>
|
|
) : (
|
|
<div
|
|
className={`mt-3 rounded-xl border p-4 ${isCorrect ? "bg-green-50 border-green-300" : "bg-amber-50 border-amber-300"}`}
|
|
>
|
|
<div className="flex gap-2 mb-2">
|
|
{isCorrect ? (
|
|
<CheckCircle2 className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
|
|
) : (
|
|
<XCircle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
|
)}
|
|
<p
|
|
className={`font-semibold text-sm ${isCorrect ? "text-green-800" : "text-amber-800"}`}
|
|
>
|
|
{isCorrect
|
|
? "Correct — that's the key sentence."
|
|
: `Not quite. The highlighted sentence above is the correct one.`}
|
|
</p>
|
|
</div>
|
|
<p className="text-sm text-gray-700 leading-relaxed">
|
|
{exercise.explanation}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 mt-3">
|
|
{submitted && (
|
|
<button
|
|
onClick={reset}
|
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
|
>
|
|
<RotateCcw className="w-3.5 h-3.5" /> Try again
|
|
</button>
|
|
)}
|
|
{submitted && activeEx < exercises.length - 1 && (
|
|
<button
|
|
onClick={() => switchEx(activeEx + 1)}
|
|
className={`ml-auto flex items-center gap-1.5 text-sm font-semibold transition-colors ${c.next}`}
|
|
>
|
|
Next passage <ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|