Spaces:
Running
Running
| 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>© 2026 ClassLens • AI-Powered Teaching Assistant</p> | |
| </footer> | |
| </div> | |
| </div> | |
| ); | |
| } | |