Yu Chen
Refactor to add answer grid & assoicate llm parsing
ad5d4b6
import { useState, useEffect, useCallback } from "react";
import { Header } from "./components/Header";
import { InviteGate } from "./components/InviteGate";
import { LoginForm } from "./components/LoginForm";
import { StepIndicator } from "./components/StepIndicator";
import { FileUploadPanel } from "./components/step1/FileUploadPanel";
import { ParsedDataSummary } from "./components/step2/ParsedDataSummary";
import { StudentSelector } from "./components/step2/StudentSelector";
import { PromptEditor } from "./components/step2/PromptEditor";
import { ReportViewer } from "./components/step3/ReportViewer";
import { apiGet, apiPost, getToken, clearToken } from "./lib/api";
const INVITE_CODE = "taboola-npo-cz";
interface User {
id: number;
email: string;
display_name: string;
}
interface StudentInfo {
index: number;
name: string;
id: string;
}
export interface StudentReport {
studentName: string;
studentIndex: number;
html: string;
status: "pending" | "generating" | "done" | "error";
error?: string;
}
export default function App() {
const [invited, setInvited] = useState(() => localStorage.getItem("invite_code") === INVITE_CODE);
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
const [sessionId, setSessionId] = useState<number | null>(null);
const [parsedData, setParsedData] = useState<Record<string, unknown>>({});
const [students, setStudents] = useState<StudentInfo[]>([]);
const [selectedIndices, setSelectedIndices] = useState<number[]>([]);
const [reports, setReports] = useState<StudentReport[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
// Check for saved JWT on mount
useEffect(() => {
const token = getToken();
if (token) {
apiGet<User>("/api/auth/me")
.then(setUser)
.catch(() => clearToken())
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
// Create session when user is authenticated and no session exists
useEffect(() => {
if (user && !sessionId) {
apiPost<{ id: number }>("/api/sessions", { title: "New Session" })
.then((session) => setSessionId(session.id))
.catch(() => {});
}
}, [user]);
// Extract student list from parsed data
useEffect(() => {
const sa = parsedData.student_answers as { students?: { name?: string; id?: string }[] } | undefined;
if (sa?.students) {
setStudents(
sa.students.map((s, i) => ({
index: i,
name: s.name || `Student ${i + 1}`,
id: s.id || "",
}))
);
}
}, [parsedData]);
const handleLogout = () => {
setUser(null);
setSessionId(null);
setParsedData({});
setStudents([]);
setSelectedIndices([]);
setReports([]);
setCurrentStep(1);
};
const handleParsedDataUpdate = useCallback((dataType: string, data: unknown) => {
setParsedData((prev) => ({ ...prev, [dataType]: data }));
}, []);
const handleGoToStep2 = useCallback(() => {
setCurrentStep(2);
}, []);
// Sequential per-student report generation
const handleGenerateReports = useCallback(async (model: string) => {
if (!sessionId || selectedIndices.length === 0) return;
const initial: StudentReport[] = selectedIndices.map((idx) => {
const s = students.find((s) => s.index === idx);
return {
studentName: s?.name || `Student ${idx + 1}`,
studentIndex: idx,
html: "",
status: "pending",
};
});
setReports(initial);
setIsGenerating(true);
setCurrentStep(3);
for (let i = 0; i < selectedIndices.length; i++) {
const idx = selectedIndices[i];
setReports((prev) =>
prev.map((r, j) => (j === i ? { ...r, status: "generating" } : r))
);
try {
const res = await apiPost<{
report_id: number;
html_content: string;
student_name: string;
}>(`/api/sessions/${sessionId}/generate-student-report`, {
student_index: idx,
model,
});
setReports((prev) =>
prev.map((r, j) =>
j === i ? { ...r, html: res.html_content, status: "done" } : r
)
);
} catch (err) {
const msg = err instanceof Error ? err.message : "Generation failed";
setReports((prev) =>
prev.map((r, j) =>
j === i ? { ...r, status: "error", error: msg } : r
)
);
}
}
setIsGenerating(false);
}, [sessionId, selectedIndices, students]);
const handleExportAllHtml = useCallback(() => {
const doneReports = reports.filter((r) => r.status === "done");
if (doneReports.length === 0) return;
const combined = `<!DOCTYPE html>
<html lang="zh-TW"><head><meta charset="UTF-8"><title>ClassLens - Student Reports</title>
<style>
.page-break { page-break-after: always; break-after: page; }
.report-container { margin-bottom: 2rem; }
</style></head><body>
${doneReports
.map(
(r, i) =>
`<div class="report-container${i < doneReports.length - 1 ? " page-break" : ""}">${r.html}</div>`
)
.join("\n")}
</body></html>`;
const blob = new Blob([combined], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "classlens-all-reports.html";
a.click();
URL.revokeObjectURL(url);
}, [reports]);
const handleStepClick = (step: 1 | 2 | 3) => {
if (step <= currentStep) setCurrentStep(step);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--color-background)]">
<div className="w-8 h-8 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!invited) {
return (
<InviteGate
onSuccess={() => {
localStorage.setItem("invite_code", INVITE_CODE);
setInvited(true);
}}
correctCode={INVITE_CODE}
/>
);
}
if (!user) {
return <LoginForm onLogin={setUser} />;
}
return (
<div className="min-h-screen">
<Header userEmail={user.email} onLogout={handleLogout} />
<div className="pt-20">
<StepIndicator currentStep={currentStep} onStepClick={handleStepClick} />
<main className="max-w-6xl mx-auto px-4 pb-12">
{currentStep === 1 && (
<div className="space-y-6 animate-fade-in-up">
<div className="text-center mb-8">
<h2 className="font-display text-2xl font-bold text-[var(--color-text)]">
上傳考試資料
</h2>
<p className="text-[var(--color-text-muted)] mt-2">
上傳考試題目、學生答案和標準答案,分別解析後確認資料再前往下一步
</p>
</div>
{sessionId && (
<FileUploadPanel
sessionId={sessionId}
parsedData={parsedData}
onParsedDataUpdate={handleParsedDataUpdate}
onGoToStep2={handleGoToStep2}
/>
)}
</div>
)}
{currentStep === 2 && (
<div className="space-y-6 animate-fade-in-up">
<div className="text-center mb-4">
<h2 className="font-display text-2xl font-bold text-[var(--color-text)]">
選擇學生 & 編輯提示詞
</h2>
<p className="text-[var(--color-text-muted)] mt-2">
勾選要生成報告的學生,編輯提示詞,預覽資料後生成報告
</p>
</div>
<ParsedDataSummary parsedData={parsedData} />
{students.length > 0 ? (
<>
<StudentSelector
students={students}
onSelectionChange={setSelectedIndices}
/>
{sessionId && (
<PromptEditor
sessionId={sessionId}
onGenerate={handleGenerateReports}
isGenerating={isGenerating}
selectedCount={selectedIndices.length}
selectedIndices={selectedIndices}
/>
)}
</>
) : (
<div className="card p-8 text-center">
<p className="text-[var(--color-text-muted)]">
尚未上傳學生答案資料,請先回到上一步上傳檔案
</p>
</div>
)}
</div>
)}
{currentStep === 3 && (
<div className="animate-fade-in-up">
<div className="text-center mb-4">
<h2 className="font-display text-2xl font-bold text-[var(--color-text)]">
學生分析報告
</h2>
</div>
<ReportViewer
reports={reports}
isGenerating={isGenerating}
onBack={() => setCurrentStep(2)}
onExportAllHtml={handleExportAllHtml}
/>
</div>
)}
</main>
<footer className="py-8 text-center text-sm text-[var(--color-text-muted)]">
<p>&copy; 2026 ClassLens &bull; AI-Powered Teaching Assistant</p>
</footer>
</div>
</div>
);
}