376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Button } from "@/components/ui/button";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
|
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { CheckCircle2, XCircle } from "lucide-react";
|
|
|
|
const CATEGORIES = [
|
|
"Board Governance",
|
|
"Management Role",
|
|
"Risk Management Process",
|
|
"Third-Party Risk",
|
|
"Incident Disclosure",
|
|
"Strategy Integration",
|
|
"None/Other",
|
|
] as const;
|
|
|
|
const SPECIFICITY_LABELS = [
|
|
"Generic Boilerplate",
|
|
"Sector-Adapted",
|
|
"Firm-Specific",
|
|
"Quantified-Verifiable",
|
|
] as const;
|
|
|
|
interface WarmupParagraph {
|
|
id: string;
|
|
text: string;
|
|
index: number;
|
|
total: number;
|
|
}
|
|
|
|
interface FeedbackData {
|
|
categoryCorrect: boolean;
|
|
specificityCorrect: boolean;
|
|
goldCategory: string;
|
|
goldSpecificity: number;
|
|
explanation: string;
|
|
warmupCompleted: number;
|
|
done: boolean;
|
|
}
|
|
|
|
export default function WarmupPage() {
|
|
const router = useRouter();
|
|
const [paragraph, setParagraph] = useState<WarmupParagraph | null>(null);
|
|
const [warmupCompleted, setWarmupCompleted] = useState(0);
|
|
const [category, setCategory] = useState<string>("");
|
|
const [specificity, setSpecificity] = useState<number>(0);
|
|
const [feedback, setFeedback] = useState<FeedbackData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const formRef = useRef<HTMLDivElement>(null);
|
|
|
|
const fetchNext = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch("/api/warmup");
|
|
const data = await res.json();
|
|
|
|
if (!res.ok) {
|
|
setError(data.error ?? "Failed to load warmup");
|
|
return;
|
|
}
|
|
|
|
if (data.done) {
|
|
router.push("/label");
|
|
return;
|
|
}
|
|
|
|
setParagraph(data.paragraph);
|
|
setWarmupCompleted(data.warmupCompleted);
|
|
setCategory("");
|
|
setSpecificity(0);
|
|
setFeedback(null);
|
|
} catch {
|
|
setError("Network error");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [router]);
|
|
|
|
useEffect(() => {
|
|
fetchNext();
|
|
}, [fetchNext]);
|
|
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!category || !specificity || !paragraph || feedback || submitting) return;
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
const res = await fetch("/api/warmup", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
category,
|
|
specificity,
|
|
warmupIndex: paragraph.index,
|
|
}),
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setError(data.error ?? "Failed to submit");
|
|
return;
|
|
}
|
|
|
|
setFeedback(data);
|
|
setWarmupCompleted(data.warmupCompleted);
|
|
} catch {
|
|
setError("Network error");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}, [category, specificity, paragraph, feedback, submitting]);
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
if (e.target instanceof HTMLTextAreaElement) return;
|
|
|
|
if (feedback) {
|
|
// After feedback, Enter or Space advances to next
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
if (feedback.done) {
|
|
router.push("/label");
|
|
} else {
|
|
fetchNext();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.shiftKey && e.key >= "1" && e.key <= "4") {
|
|
e.preventDefault();
|
|
setSpecificity(parseInt(e.key));
|
|
} else if (!e.shiftKey && e.key >= "1" && e.key <= "7") {
|
|
e.preventDefault();
|
|
setCategory(CATEGORIES[parseInt(e.key) - 1]);
|
|
} else if (e.key === "Enter" && category && specificity) {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
}
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [category, specificity, feedback, handleSubmit, fetchNext, router]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center bg-background">
|
|
<p className="text-muted-foreground">Loading warmup...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center bg-background">
|
|
<Alert variant="destructive" className="max-w-md">
|
|
<AlertTitle>Error</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!paragraph) return null;
|
|
|
|
const allCorrect = feedback?.categoryCorrect && feedback?.specificityCorrect;
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col bg-background">
|
|
{/* Header */}
|
|
<header className="border-b bg-white px-6 py-3 dark:bg-zinc-950">
|
|
<div className="mx-auto flex max-w-4xl items-center justify-between">
|
|
<h1 className="text-lg font-semibold">Warm-up Practice</h1>
|
|
<Progress
|
|
value={warmupCompleted}
|
|
max={paragraph.total}
|
|
className="w-48"
|
|
>
|
|
<ProgressLabel>Progress</ProgressLabel>
|
|
<ProgressValue>
|
|
{() => `${warmupCompleted} / ${paragraph.total}`}
|
|
</ProgressValue>
|
|
</Progress>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main content */}
|
|
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 p-6">
|
|
{/* Paragraph display */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm text-muted-foreground">
|
|
Warm-up {warmupCompleted + (feedback ? 0 : 1)} of{" "}
|
|
{paragraph.total}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="rounded-lg bg-amber-50 p-6 text-base leading-relaxed dark:bg-amber-950/30">
|
|
{paragraph.text}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Labeling form */}
|
|
<div ref={formRef} className="grid gap-6 md:grid-cols-2">
|
|
{/* Content Category */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm">Content Category</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<RadioGroup
|
|
value={category}
|
|
onValueChange={feedback ? undefined : setCategory}
|
|
>
|
|
{CATEGORIES.map((cat, i) => (
|
|
<div key={cat} className="flex items-center gap-3">
|
|
<RadioGroupItem
|
|
value={cat}
|
|
id={`cat-${i}`}
|
|
disabled={!!feedback}
|
|
/>
|
|
<Label
|
|
htmlFor={`cat-${i}`}
|
|
className="cursor-pointer text-sm"
|
|
>
|
|
{cat}{" "}
|
|
<kbd className="ml-1 rounded border bg-muted px-1 py-0.5 text-xs text-muted-foreground">
|
|
{i + 1}
|
|
</kbd>
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Specificity Level */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm">Specificity Level</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<RadioGroup
|
|
value={specificity ? String(specificity) : ""}
|
|
onValueChange={
|
|
feedback
|
|
? undefined
|
|
: (v: string) => setSpecificity(parseInt(v))
|
|
}
|
|
>
|
|
{SPECIFICITY_LABELS.map((label, i) => (
|
|
<div key={label} className="flex items-center gap-3">
|
|
<RadioGroupItem
|
|
value={String(i + 1)}
|
|
id={`spec-${i}`}
|
|
disabled={!!feedback}
|
|
/>
|
|
<Label
|
|
htmlFor={`spec-${i}`}
|
|
className="cursor-pointer text-sm"
|
|
>
|
|
{i + 1}. {label}{" "}
|
|
<kbd className="ml-1 rounded border bg-muted px-1 py-0.5 text-xs text-muted-foreground">
|
|
Shift+{i + 1}
|
|
</kbd>
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Submit or feedback */}
|
|
{!feedback ? (
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={!category || !specificity || submitting}
|
|
className="w-full"
|
|
size="lg"
|
|
>
|
|
{submitting ? "Submitting..." : "Submit"}{" "}
|
|
<kbd className="ml-2 rounded border bg-primary-foreground/20 px-1.5 py-0.5 text-xs">
|
|
Enter
|
|
</kbd>
|
|
</Button>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<Alert
|
|
className={
|
|
allCorrect
|
|
? "border-green-300 bg-green-50 dark:border-green-800 dark:bg-green-950/30"
|
|
: "border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950/30"
|
|
}
|
|
>
|
|
{allCorrect ? (
|
|
<CheckCircle2 className="text-green-600 dark:text-green-400" />
|
|
) : (
|
|
<XCircle className="text-red-600 dark:text-red-400" />
|
|
)}
|
|
<AlertTitle>
|
|
{allCorrect
|
|
? "Correct!"
|
|
: feedback.categoryCorrect
|
|
? "Specificity incorrect"
|
|
: feedback.specificityCorrect
|
|
? "Category incorrect"
|
|
: "Both incorrect"}
|
|
</AlertTitle>
|
|
<AlertDescription className="mt-2 space-y-2">
|
|
<p>
|
|
<span className="font-medium">Gold category:</span>{" "}
|
|
{feedback.goldCategory}
|
|
{!feedback.categoryCorrect && (
|
|
<span className="ml-2 text-red-600 dark:text-red-400">
|
|
(you chose: {category})
|
|
</span>
|
|
)}
|
|
</p>
|
|
<p>
|
|
<span className="font-medium">Gold specificity:</span>{" "}
|
|
{feedback.goldSpecificity} -{" "}
|
|
{SPECIFICITY_LABELS[feedback.goldSpecificity - 1]}
|
|
{!feedback.specificityCorrect && (
|
|
<span className="ml-2 text-red-600 dark:text-red-400">
|
|
(you chose: {specificity} -{" "}
|
|
{SPECIFICITY_LABELS[specificity - 1]})
|
|
</span>
|
|
)}
|
|
</p>
|
|
<p className="mt-3 text-sm leading-relaxed">
|
|
{feedback.explanation}
|
|
</p>
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
<Button
|
|
onClick={() => {
|
|
if (feedback.done) {
|
|
router.push("/label");
|
|
} else {
|
|
fetchNext();
|
|
}
|
|
}}
|
|
className="w-full"
|
|
size="lg"
|
|
>
|
|
{feedback.done ? "Begin Labeling" : "Next"}{" "}
|
|
<kbd className="ml-2 rounded border bg-primary-foreground/20 px-1.5 py-0.5 text-xs">
|
|
Enter
|
|
</kbd>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|