DocDoeAI / src /components /workspace /StudentWorkspace.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
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&apos;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>
);
}