Spaces:
Paused
Paused
| 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 { 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; | |
| } | |
| 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 [promptContent, setPromptContent] = useState(""); | |
| const [reportHtml, setReportHtml] = useState(""); | |
| 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); | |
| } | |
| }, []); | |
| // Load default prompt | |
| useEffect(() => { | |
| if (user && !promptContent) { | |
| apiGet<{ content: string }>("/api/prompts/default") | |
| .then((res) => setPromptContent(res.content)) | |
| .catch(() => {}); | |
| } | |
| }, [user]); | |
| // 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]); | |
| const handleLogout = () => { | |
| setUser(null); | |
| setSessionId(null); | |
| setParsedData({}); | |
| setPromptContent(""); | |
| setReportHtml(""); | |
| setCurrentStep(1); | |
| }; | |
| const handleParsedDataUpdate = useCallback((dataType: string, data: unknown) => { | |
| setParsedData((prev) => ({ ...prev, [dataType]: data })); | |
| }, []); | |
| const handleGoToStep2 = useCallback(() => { | |
| setCurrentStep(2); | |
| }, []); | |
| const handleRunSummary = useCallback(async (model: string) => { | |
| if (!sessionId || !promptContent.trim()) return; | |
| setIsGenerating(true); | |
| try { | |
| const res = await apiPost<{ report_id: number; html_content: string }>( | |
| `/api/sessions/${sessionId}/generate-report`, | |
| { prompt_content: promptContent, model } | |
| ); | |
| setReportHtml(res.html_content); | |
| setCurrentStep(3); | |
| } catch (err) { | |
| alert(err instanceof Error ? err.message : "Report generation failed"); | |
| } finally { | |
| setIsGenerating(false); | |
| } | |
| }, [sessionId, promptContent]); | |
| const handleExportPdf = useCallback(async () => { | |
| // Dynamic import to avoid loading html2pdf.js unless needed | |
| const html2pdf = (await import("html2pdf.js")).default; | |
| const container = document.createElement("div"); | |
| container.innerHTML = reportHtml; | |
| document.body.appendChild(container); | |
| await html2pdf() | |
| .set({ margin: 10, filename: "classlens-report.pdf", html2canvas: { scale: 2 } }) | |
| .from(container) | |
| .save(); | |
| document.body.removeChild(container); | |
| }, [reportHtml]); | |
| const handleExportPng = useCallback(async () => { | |
| const html2canvas = (await import("html2canvas")).default; | |
| const container = document.createElement("div"); | |
| container.innerHTML = reportHtml; | |
| container.style.width = "1200px"; | |
| container.style.position = "absolute"; | |
| container.style.left = "-9999px"; | |
| document.body.appendChild(container); | |
| const canvas = await html2canvas(container, { scale: 2 }); | |
| document.body.removeChild(container); | |
| const link = document.createElement("a"); | |
| link.download = "classlens-report.png"; | |
| link.href = canvas.toDataURL("image/png"); | |
| link.click(); | |
| }, [reportHtml]); | |
| const handleStepClick = (step: 1 | 2 | 3) => { | |
| if (step <= currentStep) setCurrentStep(step); | |
| }; | |
| // Loading state | |
| 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> | |
| ); | |
| } | |
| // Invite code gate | |
| if (!invited) { | |
| return ( | |
| <InviteGate | |
| onSuccess={() => { | |
| localStorage.setItem("invite_code", INVITE_CODE); | |
| setInvited(true); | |
| }} | |
| correctCode={INVITE_CODE} | |
| /> | |
| ); | |
| } | |
| // Login gate | |
| 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} /> | |
| {sessionId && ( | |
| <PromptEditor | |
| promptContent={promptContent} | |
| onPromptChange={setPromptContent} | |
| sessionId={sessionId} | |
| onRunSummary={handleRunSummary} | |
| isGenerating={isGenerating} | |
| /> | |
| )} | |
| </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 | |
| reportHtml={reportHtml} | |
| onBack={() => setCurrentStep(2)} | |
| onExportPdf={handleExportPdf} | |
| onExportPng={handleExportPng} | |
| /> | |
| </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> | |
| ); | |
| } | |