Download src/components/workspace/StudentWorkspace.tsx from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 19.6 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/workspace/StudentWorkspace.tsx
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/workspace/StudentWorkspace.tsx
-
curl -L -o StudentWorkspace.tsx https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/workspace/StudentWorkspace.tsx
19.6 kB
| "use client"; | |
| import { useCallback, useEffect, useState } from "react"; | |
| import { useRouter } from "next/navigation"; | |
| import { BookOpen, Brain, Moon, Target, X } from "lucide-react"; | |
| import { cn } from "@/lib/utils"; | |
| import { useAuth } from "@/lib/auth/useAuth"; | |
| import { hasLiveSession } from "@/lib/api/mode"; | |
| import { getPublicDemoMode } from "@/lib/env"; | |
| import { deleteDocument, listDocuments, reclassifyDocument } from "@/lib/api/documents"; | |
| import { getStudentDashboard } from "@/lib/api/dashboard"; | |
| import type { StudentDashboard } from "@/lib/api/dashboard"; | |
| import type { ApiDocument } from "@/lib/api/types"; | |
| import { WorkspaceTopBar } from "./WorkspaceTopBar"; | |
| import { SourcesPanel } from "./SourcesPanel"; | |
| import type { SourceDoc } from "./SourcesPanel"; | |
| import { AskDocDoeChat } from "./AskDocDoeChat"; | |
| import { RightPanel } from "./RightPanel"; | |
| import { ExamPathModule } from "./ExamPathModule"; | |
| import { isReadyStudySource } from "./SourceTrust"; | |
| import type { MaterialType } from "./workspaceTypes"; | |
| import { StudyVideoWizard } from "@/components/dashboard/StudyVideoWizard"; | |
| import { getOnboardingProfile, isOnboardingCompleted, type StudentStudyProfile } from "@/lib/onboarding"; | |
| const isLiveFrontendMode = getPublicDemoMode() === "live"; | |
| function mapToSourceDoc(doc: ApiDocument): SourceDoc { | |
| return { | |
| id: doc.id, | |
| title: doc.title, | |
| status: doc.status, | |
| subject: doc.subject ?? "", | |
| syllabus: doc.syllabus, | |
| file_type: doc.file_type ?? "", | |
| chunk_count: doc.chunk_count, | |
| material_type: (doc.material_type ?? "unknown") as MaterialType, | |
| source_type: doc.source_type ?? null, | |
| }; | |
| } | |
| function hasSyllabusTerm(syllabus: string | null | undefined, term: string | undefined) { | |
| if (!term) return true; | |
| if (!syllabus) return false; | |
| const syllabusTokens = new Set(syllabus.toLowerCase().split(/[\s,./-]+/).filter(Boolean)); | |
| return term | |
| .toLowerCase() | |
| .split(/[\s,./-]+/) | |
| .filter(Boolean) | |
| .every((token) => syllabusTokens.has(token)); | |
| } | |
| function matchesProfileContext(doc: SourceDoc, profile: StudentStudyProfile | null) { | |
| if (!profile || profile.onboardingSkipped) return true; | |
| return hasSyllabusTerm(doc.syllabus, profile.board) && hasSyllabusTerm(doc.syllabus, profile.classLevel); | |
| } | |
| export function StudentWorkspace() { | |
| const router = useRouter(); | |
| const { user, isLoading: isAuthLoading } = useAuth(); | |
| const [subject, setSubject] = useState("All subjects"); | |
| const [documents, setDocuments] = useState<SourceDoc[]>([]); | |
| const [activeDocId, setActiveDocId] = useState<string | null>(null); | |
| const [docRefreshToken, setDocRefreshToken] = useState(0); | |
| const [docError, setDocError] = useState<string | null>(null); | |
| const [isLoadingDocs, setIsLoadingDocs] = useState(false); | |
| const [prefillPrompt, setPrefillPrompt] = useState<string | undefined>(undefined); | |
| const [focusMode, setFocusMode] = useState(false); | |
| const [dashboard, setDashboard] = useState<StudentDashboard | null>(null); | |
| const [studyProfile, setStudyProfile] = useState<StudentStudyProfile | null>(null); | |
| const [profileLoaded, setProfileLoaded] = useState(false); | |
| const [isStudyVideoWizardOpen, setIsStudyVideoWizardOpen] = useState(false); | |
| // Mobile drawer state | |
| const [mobilePanel, setMobilePanel] = useState<"sources" | "brain" | null>(null); | |
| // Auth guard | |
| useEffect(() => { | |
| if (!isAuthLoading && isLiveFrontendMode && !hasLiveSession()) { | |
| router.replace("/login?next=%2Fdashboard"); | |
| } | |
| }, [isAuthLoading, router]); | |
| useEffect(() => { | |
| const id = window.setTimeout(() => { | |
| const stored = isOnboardingCompleted() ? getOnboardingProfile() : null; | |
| setStudyProfile(stored); | |
| if (stored?.subjects?.[0]) setSubject(stored.subjects[0]); | |
| setProfileLoaded(true); | |
| }, 0); | |
| return () => window.clearTimeout(id); | |
| }, []); | |
| // Fetch documents | |
| useEffect(() => { | |
| const id = window.setTimeout(() => { | |
| setDocError(null); | |
| setIsLoadingDocs(true); | |
| listDocuments() | |
| .then((docs) => { | |
| const mapped = docs.map(mapToSourceDoc); | |
| setDocuments(mapped); | |
| setActiveDocId((prev) => { | |
| if (prev) return prev; | |
| const firstReady = mapped.find((d) => d.status === "ready"); | |
| return firstReady?.id ?? null; | |
| }); | |
| }) | |
| .catch(() => { | |
| setDocError("Your sources could not be loaded right now. Try again in a moment."); | |
| }) | |
| .finally(() => { | |
| setIsLoadingDocs(false); | |
| }); | |
| }, 0); | |
| return () => window.clearTimeout(id); | |
| }, [docRefreshToken]); | |
| // Fetch aggregated dashboard snapshot (graceful β failure leaves panel minimal) | |
| useEffect(() => { | |
| let cancelled = false; | |
| getStudentDashboard() | |
| .then((data) => { | |
| if (!cancelled) setDashboard(data); | |
| }) | |
| .catch(() => { | |
| if (!cancelled) setDashboard(null); | |
| }); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [docRefreshToken]); | |
| // Close mobile drawer on escape | |
| useEffect(() => { | |
| function onKeyDown(e: KeyboardEvent) { | |
| if (e.key === "Escape") setMobilePanel(null); | |
| } | |
| window.addEventListener("keydown", onKeyDown); | |
| return () => window.removeEventListener("keydown", onKeyDown); | |
| }, []); | |
| const activeDoc = documents.find((d) => d.id === activeDocId) ?? null; | |
| const userName = user?.name ?? ""; | |
| const hasDocuments = documents.length > 0; | |
| const examActionsDisabled = !activeDoc || !isReadyStudySource(activeDoc); | |
| const pyqPaperCount = documents.filter( | |
| (doc) => | |
| doc.material_type === "question_paper" && | |
| (!activeDoc?.subject || doc.subject === activeDoc.subject) && | |
| (doc.id === activeDoc?.id || matchesProfileContext(doc, studyProfile)), | |
| ).length; | |
| const handleDocumentSelect = useCallback((doc: SourceDoc | null) => { | |
| setActiveDocId(doc?.id ?? null); | |
| if (doc?.subject?.trim()) { | |
| setSubject(doc.subject); | |
| } | |
| setMobilePanel(null); | |
| }, []); | |
| const handleRefreshDocuments = useCallback(() => { | |
| setDocRefreshToken((t) => t + 1); | |
| }, []); | |
| const handleReclassifySource = useCallback( | |
| async (materialType: MaterialType) => { | |
| if (!activeDoc) return; | |
| setDocError(null); | |
| try { | |
| await reclassifyDocument(activeDoc.id, materialType); | |
| handleRefreshDocuments(); | |
| } catch { | |
| setDocError("Could not update this source type. Try again."); | |
| } | |
| }, | |
| [activeDoc, handleRefreshDocuments], | |
| ); | |
| const handleRemoveSource = useCallback(async () => { | |
| if (!activeDoc) return; | |
| setDocError(null); | |
| try { | |
| await deleteDocument(activeDoc.id); | |
| setActiveDocId(null); | |
| handleRefreshDocuments(); | |
| } catch { | |
| setDocError("Could not remove this source. Try again."); | |
| } | |
| }, [activeDoc, handleRefreshDocuments]); | |
| const handleCardAction = useCallback( | |
| (prompt: string) => { | |
| if (activeDoc && !isReadyStudySource(activeDoc)) { | |
| setMobilePanel(null); | |
| return; | |
| } | |
| const resolved = prompt | |
| .replace(/\{source\}/g, activeDoc?.title ?? subject) | |
| .replace(/\{subject\}/g, activeDoc?.subject ?? subject); | |
| setPrefillPrompt(resolved); | |
| setMobilePanel(null); | |
| }, | |
| [activeDoc, subject], | |
| ); | |
| const handleReviewWeakArea = useCallback( | |
| (topic: string) => { | |
| setPrefillPrompt( | |
| `I'm weak in "${topic}". Explain it simply, create a few important practice questions, and give me a quick way to master it.`, | |
| ); | |
| setMobilePanel(null); | |
| }, | |
| [], | |
| ); | |
| const handleOpenStudyVideoWizard = useCallback(() => { | |
| setMobilePanel(null); | |
| setIsStudyVideoWizardOpen(true); | |
| }, []); | |
| const handleSearch = useCallback((query: string) => { | |
| setPrefillPrompt(query); | |
| }, []); | |
| return ( | |
| <div className="flex h-[100dvh] w-full min-w-0 flex-col overflow-hidden bg-[#050505]"> | |
| {/* Top bar */} | |
| <WorkspaceTopBar | |
| subject={subject} | |
| subjects={studyProfile?.subjects ?? []} | |
| userName={userName} | |
| onSubjectChange={setSubject} | |
| onSearch={handleSearch} | |
| onToggleSources={() => setMobilePanel((p) => (p === "sources" ? null : "sources"))} | |
| onToggleStudyBrain={hasDocuments ? () => setMobilePanel((p) => (p === "brain" ? null : "brain")) : undefined} | |
| focusMode={focusMode} | |
| onToggleFocus={() => setFocusMode((v) => !v)} | |
| sourceTitle={activeDoc?.title} | |
| /> | |
| {/* Source load error */} | |
| {docError && ( | |
| <div className="mx-2 flex items-center gap-3 rounded-2xl border border-red-400/15 bg-red-500/[0.07] px-4 py-2.5 md:mx-2.5"> | |
| <span className="text-[12px] font-semibold text-red-300/80">{docError}</span> | |
| <button | |
| type="button" | |
| onClick={handleRefreshDocuments} | |
| className="shrink-0 rounded-lg border border-red-400/20 bg-red-500/[0.1] px-2.5 py-1 text-[11px] font-bold text-red-200 transition hover:bg-red-500/[0.2]" | |
| > | |
| Retry | |
| </button> | |
| </div> | |
| )} | |
| {profileLoaded ? ( | |
| <ExamPathModule | |
| profile={studyProfile} | |
| onSetup={() => router.push("/onboarding?edit=1")} | |
| onUpload={() => router.push("/upload")} | |
| onGenerate={() => | |
| handleCardAction( | |
| "Build my study path from the selected material. Rank repeated topics, weak areas, and what deserves my next hour.", | |
| ) | |
| } | |
| /> | |
| ) : null} | |
| {/* Main workspace */} | |
| <div className="relative flex min-h-0 min-w-0 flex-1 gap-0 overflow-hidden px-2 pb-1 pt-2 md:gap-2.5 md:p-2.5"> | |
| {/* Desktop Sources panel β hidden in focus mode */} | |
| {!focusMode && ( | |
| <SourcesPanel | |
| className="hidden w-[230px] shrink-0 lg:flex xl:w-[260px]" | |
| subject={activeDoc?.subject || subject} | |
| documents={documents} | |
| activeDocumentId={activeDocId} | |
| onDocumentSelect={handleDocumentSelect} | |
| onRefresh={handleRefreshDocuments} | |
| isLoading={isLoadingDocs} | |
| onUploadClick={() => router.push("/upload")} | |
| /> | |
| )} | |
| {/* Center β Chat */} | |
| <AskDocDoeChat | |
| className="h-full min-w-0 max-w-full flex-1" | |
| subject={activeDoc?.subject || subject} | |
| userName={userName} | |
| selectedSource={activeDoc} | |
| prefillPrompt={prefillPrompt} | |
| onPrefillConsumed={() => setPrefillPrompt(undefined)} | |
| dashboard={dashboard} | |
| showFirstUploadState={!isLoadingDocs && !hasDocuments} | |
| onOpenUpload={() => router.push("/upload")} | |
| onClassifySource={handleReclassifySource} | |
| onRemoveSource={handleRemoveSource} | |
| onCompleteProfile={() => router.push("/onboarding?edit=1")} | |
| onOpenStudyVideoWizard={handleOpenStudyVideoWizard} | |
| /> | |
| {/* Desktop Right panel β hidden in focus mode */} | |
| {!focusMode && hasDocuments && ( | |
| <RightPanel | |
| className="hidden w-[250px] shrink-0 lg:flex xl:w-[280px] 2xl:w-[300px]" | |
| subject={activeDoc?.subject || subject} | |
| selectedSource={activeDoc} | |
| onCardAction={handleCardAction} | |
| dashboard={dashboard} | |
| onReviewWeakArea={handleReviewWeakArea} | |
| onUploadClick={() => router.push("/upload")} | |
| pyqPaperCount={pyqPaperCount} | |
| onOpenStudyVideoWizard={handleOpenStudyVideoWizard} | |
| /> | |
| )} | |
| {/* ββ Mobile Drawer Overlay ββ */} | |
| {mobilePanel !== null && ( | |
| <div | |
| className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden" | |
| onClick={() => setMobilePanel(null)} | |
| onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") setMobilePanel(null); }} | |
| role="button" | |
| tabIndex={0} | |
| aria-label="Close panel" | |
| /> | |
| )} | |
| {/* ββ Mobile Sources Drawer (left) ββ */} | |
| <div | |
| className={cn( | |
| "fixed inset-y-0 left-0 z-50 w-[92vw] max-w-[360px] transition-transform duration-300 ease-out lg:hidden", | |
| mobilePanel === "sources" ? "translate-x-0" : "-translate-x-full pointer-events-none", | |
| )} | |
| > | |
| <div className="flex h-full min-w-0 flex-col bg-[#0B0B0C] shadow-[4px_0_40px_rgba(0,0,0,0.6)]"> | |
| <div className="flex h-12 shrink-0 items-center justify-between border-b border-white/[0.07] px-4"> | |
| <div className="flex items-center gap-2"> | |
| <BookOpen className="size-4 text-white/50" strokeWidth={2} /> | |
| <span className="text-[13px] font-bold text-white">Sources</span> | |
| {documents.length > 0 && ( | |
| <span className="rounded-full bg-white/[0.08] px-1.5 py-0.5 text-[10px] font-bold text-white/55"> | |
| {documents.length} | |
| </span> | |
| )} | |
| </div> | |
| <button | |
| type="button" | |
| onClick={() => setMobilePanel(null)} | |
| aria-label="Close sources panel" | |
| className="touch-ripple grid size-9 place-items-center rounded-lg border border-white/8 text-white/50 transition hover:text-white" | |
| > | |
| <X className="size-4" strokeWidth={2} /> | |
| </button> | |
| </div> | |
| <SourcesPanel | |
| className="min-h-0 flex-1" | |
| subject={activeDoc?.subject || subject} | |
| documents={documents} | |
| activeDocumentId={activeDocId} | |
| onDocumentSelect={handleDocumentSelect} | |
| onRefresh={handleRefreshDocuments} | |
| isLoading={isLoadingDocs} | |
| onUploadClick={() => router.push("/upload")} | |
| /> | |
| </div> | |
| </div> | |
| {/* ββ Mobile Study Brain Drawer (right) ββ */} | |
| <div | |
| className={cn( | |
| "fixed inset-y-0 right-0 z-50 w-[92vw] max-w-[380px] transition-transform duration-300 ease-out lg:hidden", | |
| mobilePanel === "brain" ? "translate-x-0" : "translate-x-full pointer-events-none", | |
| )} | |
| > | |
| <div className="flex h-full min-w-0 flex-col bg-[#0B0B0C] shadow-[-4px_0_40px_rgba(0,0,0,0.6)]"> | |
| <div className="flex h-12 shrink-0 items-center justify-between border-b border-white/[0.07] px-4"> | |
| <div className="flex items-center gap-2"> | |
| <Brain className="size-4 text-white/50" strokeWidth={2} /> | |
| <span className="text-[13px] font-bold text-white">Study actions</span> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={() => setMobilePanel(null)} | |
| aria-label="Close study brain panel" | |
| className="touch-ripple grid size-9 place-items-center rounded-lg border border-white/8 text-white/50 transition hover:text-white" | |
| > | |
| <X className="size-4" strokeWidth={2} /> | |
| </button> | |
| </div> | |
| <RightPanel | |
| className="min-h-0 flex-1" | |
| subject={activeDoc?.subject || subject} | |
| selectedSource={activeDoc} | |
| onCardAction={handleCardAction} | |
| dashboard={dashboard} | |
| onReviewWeakArea={handleReviewWeakArea} | |
| onUploadClick={() => router.push("/upload")} | |
| pyqPaperCount={pyqPaperCount} | |
| onOpenStudyVideoWizard={handleOpenStudyVideoWizard} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Mobile bottom nav β hidden on desktop */} | |
| <div className="mobile-nav flex min-h-[56px] shrink-0 border-t border-white/[0.06] bg-[#0B0B0C]/95 lg:hidden"> | |
| {hasDocuments ? ( | |
| <button | |
| type="button" | |
| onClick={() => setMobilePanel((p) => (p === "sources" ? null : "sources"))} | |
| aria-label="Toggle sources panel" | |
| className={cn( | |
| "touch-ripple flex h-[56px] flex-1 flex-col items-center justify-center gap-[3px] text-[10px] font-semibold transition", | |
| mobilePanel === "sources" ? "text-white" : "text-white/45 active:text-white/70", | |
| )} | |
| > | |
| <BookOpen | |
| className="size-[18px]" | |
| strokeWidth={mobilePanel === "sources" ? 2.2 : 1.8} | |
| /> | |
| <span>Sources</span> | |
| </button> | |
| ) : null} | |
| <button | |
| type="button" | |
| onClick={() => | |
| handleCardAction( | |
| "Create important practice questions from {source} for {subject}, based only on available source evidence. Do not claim PYQ patterns unless question papers are available.", | |
| ) | |
| } | |
| disabled={examActionsDisabled} | |
| aria-label="Generate likely exam questions" | |
| className="touch-ripple flex h-[56px] flex-1 flex-col items-center justify-center gap-[3px] text-[10px] font-semibold text-white/45 transition active:text-white/70 disabled:cursor-not-allowed disabled:opacity-30" | |
| > | |
| <Target className="size-[18px]" strokeWidth={1.8} /> | |
| <span>Likely Q's</span> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => | |
| handleCardAction( | |
| "Create a last-night study plan for {source} ({subject}). Include scoring keywords, key definitions, practice questions, memory tricks, and a timed plan for fast revision tonight.", | |
| ) | |
| } | |
| disabled={examActionsDisabled} | |
| aria-label="Generate last-night study plan" | |
| className="touch-ripple flex h-[56px] flex-1 flex-col items-center justify-center gap-[3px] text-[10px] font-semibold text-white/45 transition active:text-white/70 disabled:cursor-not-allowed disabled:opacity-30" | |
| > | |
| <Moon className="size-[18px]" strokeWidth={1.8} /> | |
| <span>Tonight</span> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => setMobilePanel((p) => (p === "brain" ? null : "brain"))} | |
| aria-label="Toggle study actions panel" | |
| className={cn( | |
| "touch-ripple flex h-[56px] flex-1 flex-col items-center justify-center gap-[3px] text-[10px] font-semibold transition", | |
| mobilePanel === "brain" ? "text-white" : "text-white/45 active:text-white/70", | |
| )} | |
| > | |
| <Brain | |
| className="size-[18px]" | |
| strokeWidth={mobilePanel === "brain" ? 2.2 : 1.8} | |
| /> | |
| <span>Actions</span> | |
| </button> | |
| </div> | |
| <StudyVideoWizard | |
| isOpen={isStudyVideoWizardOpen} | |
| source={ | |
| activeDoc ?? { | |
| id: "", | |
| title: "Choose a source", | |
| subject, | |
| status: "ready", | |
| material_type: "unknown", | |
| } | |
| } | |
| onClose={() => setIsStudyVideoWizardOpen(false)} | |
| onUploadClick={() => { | |
| setIsStudyVideoWizardOpen(false); | |
| router.push("/upload"); | |
| }} | |
| /> | |
| </div> | |
| ); | |
| } | |