feat(lessons): add lessons from client db
This commit is contained in:
255
src/components/lessons/EvidenceHunterWidget.tsx
Normal file
255
src/components/lessons/EvidenceHunterWidget.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user