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 { AdminDashboard } from "./components/admin/AdminDashboard"; import { TeacherDashboard } from "./components/TeacherDashboard"; import { StudentQuizPage } from "./components/StudentQuizPage"; import { StudentLoginPage } from "./components/StudentLoginPage"; import { StudentDashboardPage } from "./components/StudentDashboardPage"; import { apiGet, apiPost, getToken, getRole, clearToken } from "./lib/api"; import type { User } from "./types"; interface StudentSelf { id: number; name: string; username: string; } const INVITE_CODE = "astraea"; interface StudentInfo { index: number; name: string; id: string; } export interface StudentReport { studentName: string; studentIndex: number; html: string; status: "pending" | "generating" | "done" | "error"; error?: string; } // Student quiz route — checked before any of App's hooks run at all // (separate component), so the teacher-only auth/invite flow is never // touched for this path. const QUIZ_PATH_RE = /^\/quiz\/([^/]+)\/?$/; export default function App() { const path = window.location.pathname; const quizMatch = path.match(QUIZ_PATH_RE); if (quizMatch) { return ; } return ; } // A shared quiz link is student-only and requires the exact student it was // assigned to be logged in first, so the result actually reflects who took // it — a bare share_token used to be the sole credential, but now it's // combined with the student's own login (see the backend's // _check_quiz_owner). Any existing teacher session is ignored here since // this route always needs a student identity. function StudentQuizGate({ shareToken }: { shareToken: string }) { const [student, setStudent] = useState(null); const [checked, setChecked] = useState(false); useEffect(() => { if (getToken() && getRole() === "student") { apiGet("/api/student/me") .then(setStudent) .catch(() => clearToken()) .finally(() => setChecked(true)); } else { setChecked(true); } }, []); if (!checked) { return (
); } if (!student) { return ( { window.location.href = "/"; }} /> ); } return ; } // Student and teacher logins share the root page via a toggle, rather than // separate paths — so a logged-out visitor's mode is just local UI state, // while a logged-in one is pinned to whichever role their stored token // belongs to (checked before their token is verified, so we mount the right // "am I logged in" check on the first render instead of guessing wrong and // bouncing them to a login screen). function RootApp() { const [mode, setMode] = useState<"student" | "teacher">( () => (getToken() ? getRole() ?? "teacher" : "teacher") ); if (mode === "teacher") { return setMode("student")} />; } return setMode("teacher")} />; } function StudentApp({ onSwitchToTeacher }: { onSwitchToTeacher: () => void }) { const [student, setStudent] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { const token = getToken(); if (token) { apiGet("/api/student/me") .then(setStudent) .catch(() => clearToken()) .finally(() => setLoading(false)); } else { setLoading(false); } }, []); if (loading) { return (
); } if (!student) { return ; } return setStudent(null)} />; } function TeacherApp({ onSwitchToStudent }: { onSwitchToStudent: () => void }) { const [invited, setInvited] = useState(() => localStorage.getItem("invite_code") === INVITE_CODE); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1); const [maxStep, setMaxStep] = useState<1 | 2 | 3>(1); const [sessionId, setSessionId] = useState(null); const [parsedData, setParsedData] = useState>({}); const [students, setStudents] = useState([]); const [selectedIndices, setSelectedIndices] = useState([]); const [reports, setReports] = useState([]); const [isGenerating, setIsGenerating] = useState(false); // Tracks whether the current `reports` batch has been persisted via // save-to-db — reset whenever a new generation run starts, flipped once // the save succeeds. Lets confirmLeaveStep3 warn about completed-but- // unsaved reports, not just an in-progress generation. const [reportsSaved, setReportsSaved] = useState(false); const [showAdmin, setShowAdmin] = useState(false); const [showDashboard, setShowDashboard] = useState(true); // TeacherDashboard/AdminDashboard own a lot of internal fetch state (student // lists, sub-tab data, etc). Toggling showDashboard/showAdmin used to // early-return a completely different JSX tree, which unmounted whichever // view you left — so switching back to it always refetched from scratch. // Keep each view mounted (just hidden) once it's been opened. const currentView: "main" | "dashboard" | "admin" = showDashboard ? "dashboard" : showAdmin ? "admin" : "main"; const [visitedViews, setVisitedViews] = useState>( () => new Set(showDashboard ? (["dashboard"] as const) : []) ); useEffect(() => { if (currentView === "dashboard" || currentView === "admin") { setVisitedViews((prev) => (prev.has(currentView) ? prev : new Set(prev).add(currentView))); } }, [currentView]); // Check for saved JWT on mount useEffect(() => { const token = getToken(); if (token) { apiGet("/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", {}) .then((session) => setSessionId(session.id)) .catch(() => {}); } // Intentionally omits sessionId — this effect is what sets sessionId, so // including it would risk re-running as soon as it's created. // eslint-disable-next-line react-hooks/exhaustive-deps }, [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); setMaxStep(1); }; const handleParsedDataUpdate = useCallback((dataType: string, data: unknown) => { setParsedData((prev) => ({ ...prev, [dataType]: data })); }, []); // If the current session was deleted server-side (e.g. a bulk wipe from the // dashboard) mid-upload, create a fresh one so the user can just retry // instead of hitting a raw "Session not found" error. const handleSessionExpired = useCallback(async () => { const session = await apiPost<{ id: number }>("/api/sessions", {}); setSessionId(session.id); setParsedData({}); setStudents([]); return session.id; }, []); const handleGoToStep2 = useCallback(() => { setCurrentStep(2); setMaxStep((prev) => (prev < 2 ? 2 : prev)); }, []); // 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); setReportsSaved(false); setCurrentStep(3); setMaxStep(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 = ` ClassLens - Student Reports ${doneReports .map( (r, i) => `
${r.html}
` ) .join("\n")} `; 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]); // Warn before leaving step 3 while reports are either still generating, or // done but not yet saved to the database — either way, leaving silently // abandons work: mid-generation reports are lost outright, and completed- // but-unsaved reports never make it into the database or teacher dashboard. const confirmLeaveStep3 = (): boolean => { if (currentStep !== 3) return true; if (isGenerating) { return window.confirm( "報告尚未生成完成,離開此頁面將會中斷生成,尚未完成的報告會遺失且不會進入資料庫。\n\n確定要離開嗎?" ); } const hasDoneReports = reports.some((r) => r.status === "done"); if (hasDoneReports && !reportsSaved) { return window.confirm( "報告尚未儲存至資料庫,離開此頁面將會遺失這些報告。\n\n確定要離開嗎?" ); } return true; }; // Warn on tab close/refresh for the same reasons. useEffect(() => { const handler = (e: BeforeUnloadEvent) => { if (currentStep !== 3) return; const hasDoneReports = reports.some((r) => r.status === "done"); if (isGenerating || (hasDoneReports && !reportsSaved)) { e.preventDefault(); e.returnValue = ""; } }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [currentStep, isGenerating, reports, reportsSaved]); const handleStepClick = (step: 1 | 2 | 3) => { if (step > maxStep) return; if (currentStep === 3 && step !== 3) { if (!confirmLeaveStep3()) return; if (step === 1) { const ok = window.confirm( "回到步驟一將會清除目前的報告資料。\n\n未儲存的報告不會進入資料庫或教師儀表板。\n\n確定要繼續嗎?" ); if (!ok) return; setReports([]); setMaxStep(1); } } setCurrentStep(step); }; if (loading) { return (

ClassLens

); } if (!invited) { return ( { localStorage.setItem("invite_code", INVITE_CODE); setInvited(true); }} correctCode={INVITE_CODE} onSwitchToStudent={onSwitchToStudent} /> ); } if (!user) { return ; } const headerProps = { isAdmin: user.is_admin, showAdmin, onToggleAdmin: () => { if (!confirmLeaveStep3()) return; setShowAdmin((v: boolean) => !v); setShowDashboard(false); }, showDashboard, onToggleDashboard: () => { if (!confirmLeaveStep3()) return; setShowDashboard((v: boolean) => !v); setShowAdmin(false); }, onLogout: () => { if (!confirmLeaveStep3()) return; handleLogout(); }, }; return (
{visitedViews.has("dashboard") && (
)} {visitedViews.has("admin") && (
)}
{currentStep === 1 && (

上傳考試資料

{sessionId && ( )}
)} {currentStep === 2 && (

選擇學生 & 編輯提示詞

勾選要生成報告的學生,編輯提示詞,預覽資料後生成報告

{students.length > 0 ? ( <> {sessionId && ( { void handleGenerateReports(model); }} isGenerating={isGenerating} selectedCount={selectedIndices.length} selectedIndices={selectedIndices} students={students} /> )} ) : (

尚未上傳學生答案資料,請先回到上一步上傳檔案

)}
)} {currentStep === 3 && (

學生分析報告

handleStepClick(2)} onExportAllHtml={handleExportAllHtml} onSaveToDb={ sessionId ? async () => { const res = await apiPost<{ students_saved: number }>( `/api/sessions/${sessionId}/save-to-db` ); setReportsSaved(true); return res; } : undefined } />
)}
); }