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 (