taboola
move account details/change-password to bottom of dashboard
2cb95fd
Raw
History Blame Contribute Delete
19.4 kB
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 <StudentQuizGate shareToken={quizMatch[1]} />;
}
return <RootApp />;
}
// 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<StudentSelf | null>(null);
const [checked, setChecked] = useState(false);
useEffect(() => {
if (getToken() && getRole() === "student") {
apiGet<StudentSelf>("/api/student/me")
.then(setStudent)
.catch(() => clearToken())
.finally(() => setChecked(true));
} else {
setChecked(true);
}
}, []);
if (!checked) {
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 (!student) {
return (
<StudentLoginPage
onLogin={setStudent}
onSwitchToTeacher={() => { window.location.href = "/"; }}
/>
);
}
return <StudentQuizPage shareToken={shareToken} />;
}
// 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 <TeacherApp onSwitchToStudent={() => setMode("student")} />;
}
return <StudentApp onSwitchToTeacher={() => setMode("teacher")} />;
}
function StudentApp({ onSwitchToTeacher }: { onSwitchToTeacher: () => void }) {
const [student, setStudent] = useState<StudentSelf | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = getToken();
if (token) {
apiGet<StudentSelf>("/api/student/me")
.then(setStudent)
.catch(() => clearToken())
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
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 (!student) {
return <StudentLoginPage onLogin={setStudent} onSwitchToTeacher={onSwitchToTeacher} />;
}
return <StudentDashboardPage student={student} onLogout={() => setStudent(null)} />;
}
function TeacherApp({ onSwitchToStudent }: { onSwitchToStudent: () => void }) {
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 [maxStep, setMaxStep] = 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);
// 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<Set<"dashboard" | "admin">>(
() => 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<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", {})
.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 = `<!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]);
// 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 (
<div className="min-h-screen flex items-center justify-center bg-[var(--color-background)]">
<div className="fixed top-0 left-0 right-0 h-[3px]" style={{ background: "var(--color-primary)" }} />
<div className="flex flex-col items-center gap-6">
<img src="/plane.svg" alt="" width="220" height="184" className="plane-float" />
<p className="text-sm text-[var(--color-text-muted)] tracking-widest" style={{ fontFamily: "Georgia, serif", letterSpacing: "0.1em" }}>
ClassLens
</p>
</div>
</div>
);
}
if (!invited) {
return (
<InviteGate
onSuccess={() => {
localStorage.setItem("invite_code", INVITE_CODE);
setInvited(true);
}}
correctCode={INVITE_CODE}
onSwitchToStudent={onSwitchToStudent}
/>
);
}
if (!user) {
return <LoginForm onLogin={setUser} onSwitchToStudent={onSwitchToStudent} />;
}
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 (
<div className="min-h-screen">
<Header {...headerProps} />
{visitedViews.has("dashboard") && (
<div className="pt-20" style={{ display: currentView === "dashboard" ? "block" : "none" }}>
<TeacherDashboard active={currentView === "dashboard"} />
</div>
)}
{visitedViews.has("admin") && (
<div className="pt-20" style={{ display: currentView === "admin" ? "block" : "none" }}>
<AdminDashboard isAdmin={!!user.is_admin} userEmail={user.email} userName={user.display_name || user.full_name || ""} />
</div>
)}
<div style={{ display: currentView === "main" ? "block" : "none" }}>
<div className="pt-20">
<StepIndicator currentStep={currentStep} maxStep={maxStep} 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>
</div>
{sessionId && (
<FileUploadPanel
sessionId={sessionId}
parsedData={parsedData}
onParsedDataUpdate={handleParsedDataUpdate}
onGoToStep2={handleGoToStep2}
onSessionExpired={handleSessionExpired}
/>
)}
</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={(model) => { void handleGenerateReports(model); }}
isGenerating={isGenerating}
selectedCount={selectedIndices.length}
selectedIndices={selectedIndices}
students={students}
/>
)}
</>
) : (
<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={() => 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
}
/>
</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>
</div>
);
}