SOURCE.IO / src /pages /DocumentWorkspace.tsx
Adeen
style: remove width constraints to allow full-width notes
db584ca
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams, useNavigate, useOutletContext } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { useWorkspace, DocumentRow, FlashcardRow, NoteRow, PodcastRow, QuizQuestionRow, QuizRow } from "@/store/workspace";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import MarkdownView from "@/components/MarkdownView";
import { FileText, Layers, ListChecks, Headphones, MessagesSquare, Loader2, Trash2, ChevronLeft, Sparkles, RefreshCw, Menu } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { streamNotes, generateDerivatives } from "@/lib/pipeline";
import { generatePodcast } from "@/lib/podcast";
import FlashcardsDeck from "@/components/FlashcardsDeck";
import QuizPlayer from "@/components/QuizPlayer";
import ChatPanel from "@/components/ChatPanel";
export default function DocumentWorkspace() {
const { docId } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const { toast } = useToast();
const outlet = useOutletContext<{ openMobileNav?: () => void }>();
const {
documents, upsertDocument, removeDocument,
notes, flashcards, quiz, podcast,
setNote, setFlashcards, setQuiz, setPodcast,
} = useWorkspace();
const doc = useMemo<DocumentRow | undefined>(
() => documents.find((d) => d.id === docId),
[documents, docId]
);
const [loading, setLoading] = useState(true);
const [streaming, setStreaming] = useState(false);
const autoStartedRef = useRef<string | null>(null);
useEffect(() => {
if (!docId || !user) return;
let mounted = true;
setLoading(true);
(async () => {
const { data: docRow } = await supabase
.from("documents")
.select("id,title,source_type,status,error_code,created_at")
.eq("id", docId)
.maybeSingle();
if (docRow && mounted) upsertDocument(docRow as DocumentRow);
const [n, f, q, p] = await Promise.all([
supabase.from("notes").select("id,document_id,markdown").eq("document_id", docId).maybeSingle(),
supabase.from("flashcards").select("id,document_id,front,back,order_index").eq("document_id", docId).order("order_index"),
supabase.from("quizzes").select("id,document_id,title").eq("document_id", docId).maybeSingle(),
supabase.from("podcasts").select("id,document_id,script,audio_url,status").eq("document_id", docId).maybeSingle(),
]);
if (!mounted) return;
setNote(docId, (n.data as NoteRow) ?? null);
setFlashcards(docId, (f.data as FlashcardRow[]) ?? []);
if (q.data) {
const qq = await supabase
.from("quiz_questions")
.select("id,quiz_id,question,type,choices,correct,explanation,order_index")
.eq("quiz_id", q.data.id)
.order("order_index");
setQuiz(docId, { ...(q.data as any), questions: (qq.data ?? []) as QuizQuestionRow[] } as QuizRow);
} else {
setQuiz(docId, null);
}
setPodcast(docId, (p.data as PodcastRow) ?? null);
setLoading(false);
})();
// Realtime: document status updates
const channel = supabase
.channel(`doc-${docId}`)
.on(
"postgres_changes",
{ event: "UPDATE", schema: "public", table: "documents", filter: `id=eq.${docId}` },
(payload) => upsertDocument(payload.new as DocumentRow),
)
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "podcasts", filter: `document_id=eq.${docId}` },
(payload) => setPodcast(docId, (payload.new as PodcastRow) ?? null),
)
.subscribe();
return () => {
mounted = false;
supabase.removeChannel(channel);
};
}, [docId, user, upsertDocument, setNote, setFlashcards, setQuiz, setPodcast]);
const noteForDoc = docId ? notes[docId] ?? null : null;
const docStatus = docId ? documents.find((d) => d.id === docId)?.status : undefined;
const generate = async () => {
if (!docId || streaming) return;
setStreaming(true);
setNote(docId, { id: "draft", document_id: docId, markdown: "" } as NoteRow);
try {
const final = await streamNotes({
documentId: docId,
onDelta: (chunk) => {
const cur = (useWorkspace.getState().notes[docId]?.markdown ?? "") + chunk;
setNote(docId, { id: "draft", document_id: docId, markdown: cur } as NoteRow);
},
});
const { data: fresh } = await supabase
.from("notes")
.select("id,document_id,markdown")
.eq("document_id", docId)
.maybeSingle();
setNote(docId, (fresh as NoteRow) ?? { id: "draft", document_id: docId, markdown: final });
} catch (e: any) {
toast({ title: "Notes generation failed", description: e.message, variant: "destructive" });
} finally {
setStreaming(false);
}
};
useEffect(() => {
if (!docId) return;
if (docStatus === "ready" && !noteForDoc?.markdown && autoStartedRef.current !== docId && !streaming) {
autoStartedRef.current = docId;
generate();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docId, docStatus, noteForDoc?.markdown]);
const [derivLoading, setDerivLoading] = useState(false);
const [podcastLoading, setPodcastLoading] = useState(false);
const runDerivatives = async () => {
if (!docId || derivLoading) return;
setDerivLoading(true);
try {
await generateDerivatives(docId);
const [{ data: f }, { data: qq }] = await Promise.all([
supabase.from("flashcards").select("id,document_id,front,back,order_index").eq("document_id", docId).order("order_index"),
supabase.from("quizzes").select("id,document_id,title").eq("document_id", docId).maybeSingle(),
]);
setFlashcards(docId, (f as FlashcardRow[]) ?? []);
if (qq) {
const { data: questions } = await supabase
.from("quiz_questions")
.select("id,quiz_id,question,type,choices,correct,explanation,order_index")
.eq("quiz_id", qq.id)
.order("order_index");
setQuiz(docId, { ...(qq as any), questions: (questions ?? []) as QuizQuestionRow[] } as QuizRow);
} else {
setQuiz(docId, null);
}
toast({ title: "Flashcards & quiz ready" });
} catch (e: any) {
toast({ title: "Generation failed", description: e.message, variant: "destructive" });
} finally {
setDerivLoading(false);
}
};
const refreshPodcast = async () => {
if (!docId) return;
const { data } = await supabase
.from("podcasts")
.select("id,document_id,script,audio_url,status")
.eq("document_id", docId)
.maybeSingle();
setPodcast(docId, (data as PodcastRow) ?? null);
};
const runPodcast = async () => {
if (!docId || podcastLoading) return;
setPodcastLoading(true);
setPodcast(docId, {
id: pod?.id ?? "draft",
document_id: docId,
script: pod?.script ?? null,
audio_url: null,
status: "generating",
});
try {
await generatePodcast(docId);
await refreshPodcast();
toast({ title: "Podcast ready", description: "Your audio recap is ready to play." });
} catch (e: any) {
await refreshPodcast();
toast({ title: "Podcast generation failed", description: e.message, variant: "destructive" });
} finally {
setPodcastLoading(false);
}
};
const handleDelete = async () => {
if (!docId) return;
if (!confirm("Delete this document and all its assets?")) return;
const { error } = await supabase.from("documents").delete().eq("id", docId);
if (error) {
toast({ title: "Delete failed", description: error.message, variant: "destructive" });
return;
}
removeDocument(docId);
navigate("/app");
};
if (!doc) {
return (
<div className="h-full flex items-center justify-center">
{loading ? <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> : (
<div className="text-center">
<p className="text-muted-foreground mb-4">Document not found.</p>
<Button variant="outline" onClick={() => navigate("/app")}>
<ChevronLeft className="h-4 w-4 mr-1" /> Back
</Button>
</div>
)}
</div>
);
}
const note = notes[doc.id] ?? null;
const cards = flashcards[doc.id] ?? [];
const qz = quiz[doc.id] ?? null;
const pod = podcast[doc.id] ?? null;
const isProcessing = doc.status === "pending" || doc.status === "processing";
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="border-b border-border px-4 sm:px-6 py-3 sm:py-4 flex items-start justify-between gap-3">
<div className="flex items-start gap-2 min-w-0 flex-1">
{outlet?.openMobileNav && (
<Button
variant="ghost"
size="icon"
className="md:hidden -ml-2 mt-0.5 shrink-0"
onClick={outlet.openMobileNav}
aria-label="Open navigation"
>
<Menu className="h-5 w-5" />
</Button>
)}
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<Badge variant="outline" className="text-[10px] uppercase tracking-wide">{doc.source_type}</Badge>
{doc.status === "ready" && <Badge variant="secondary" className="text-[10px]">Ready</Badge>}
{isProcessing && <Badge className="text-[10px] bg-primary/20 text-primary border-primary/30">Processing…</Badge>}
{doc.status === "failed" && <Badge variant="destructive" className="text-[10px]">{doc.error_code ?? "Failed"}</Badge>}
</div>
<h1 className="text-lg sm:text-xl font-semibold tracking-tight truncate">{doc.title}</h1>
</div>
</div>
<Button variant="ghost" size="icon" onClick={handleDelete} title="Delete" className="shrink-0">
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Main Content Area */}
{isProcessing ? (
<div className="flex-1 flex items-center justify-center p-6 bg-slate-950/50 relative overflow-hidden">
{/* Background scanline effect */}
<div className="absolute inset-0 pointer-events-none">
<div className="w-full h-1 bg-primary/10 absolute top-0 animate-scanline opacity-20" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(14,165,233,0.05),transparent_70%)]" />
</div>
<ExpertProcessing status={doc.status} sourceType={doc.source_type} />
</div>
) : (
<Tabs defaultValue="notes" className="flex-1 flex flex-col overflow-hidden">
<div className="border-b border-border px-2 sm:px-6 overflow-x-auto">
<TabsList className="bg-transparent h-11 p-0 gap-1">
<TabsTrigger value="notes" className="data-[state=active]:bg-muted"><FileText className="h-3.5 w-3.5 mr-1.5" /> Notes</TabsTrigger>
<TabsTrigger value="flashcards" className="data-[state=active]:bg-muted"><Layers className="h-3.5 w-3.5 mr-1.5" /> Flashcards</TabsTrigger>
<TabsTrigger value="quiz" className="data-[state=active]:bg-muted"><ListChecks className="h-3.5 w-3.5 mr-1.5" /> Quiz</TabsTrigger>
<TabsTrigger value="podcast" className="data-[state=active]:bg-muted"><Headphones className="h-3.5 w-3.5 mr-1.5" /> Podcast</TabsTrigger>
<TabsTrigger value="chat" className="data-[state=active]:bg-muted"><MessagesSquare className="h-3.5 w-3.5 mr-1.5" /> Chat</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 overflow-y-auto">
<TabsContent value="notes" className="m-0 p-4 sm:p-6 w-full animate-fade-in">
{note?.markdown ? (
<div className="space-y-4">
<MarkdownView>{note.markdown}</MarkdownView>
{streaming && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Generating…
</div>
)}
</div>
) : doc.status === "ready" ? (
<div className="border border-dashed border-border rounded-xl p-10 text-center space-y-3">
<h3 className="font-medium">Ready to generate</h3>
<p className="text-sm text-muted-foreground">
Click below to stream AI-generated study notes.
</p>
<Button onClick={generate} disabled={streaming}>
{streaming ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Sparkles className="h-4 w-4 mr-2" />}
Generate notes
</Button>
</div>
) : isProcessing ? (
<Placeholder title="Extracting content…" desc="We're parsing your source. Notes will appear here automatically." />
) : doc.status === "failed" ? (
<Placeholder title="Ingestion failed" desc={doc.error_code ?? "Something went wrong while parsing the source."} />
) : (
<Placeholder title="No notes yet" desc="Waiting for the source to be processed." />
)}
</TabsContent>
<TabsContent value="flashcards" className="m-0 p-4 sm:p-6 max-w-3xl mx-auto animate-fade-in">
{cards.length === 0 ? (
<DerivativesEmpty
kind="flashcards"
noteReady={!!note?.markdown}
loading={derivLoading}
onGenerate={runDerivatives}
/>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-muted-foreground">{cards.length} flashcards</h2>
<Button variant="ghost" size="sm" onClick={runDerivatives} disabled={derivLoading}>
{derivLoading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1.5" />}
Regenerate
</Button>
</div>
<FlashcardsDeck cards={cards} />
</div>
)}
</TabsContent>
<TabsContent value="quiz" className="m-0 p-4 sm:p-6 max-w-3xl mx-auto animate-fade-in">
{!qz || qz.questions.length === 0 ? (
<DerivativesEmpty
kind="quiz"
noteReady={!!note?.markdown}
loading={derivLoading}
onGenerate={runDerivatives}
/>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-muted-foreground">{qz.title} — {qz.questions.length} questions</h2>
<Button variant="ghost" size="sm" onClick={runDerivatives} disabled={derivLoading}>
{derivLoading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1.5" />}
Regenerate
</Button>
</div>
<QuizPlayer quiz={qz} />
</div>
)}
</TabsContent>
<TabsContent value="podcast" className="m-0 p-4 sm:p-6 max-w-3xl mx-auto animate-fade-in">
{!note?.markdown ? (
<Placeholder title="No podcast yet" desc="Generate notes first, then create a two-host audio recap here." />
) : pod?.audio_url ? (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-medium">Audio summary ready</h2>
<p className="text-sm text-muted-foreground">Listen now or regenerate it from your latest notes.</p>
</div>
<Button variant="ghost" size="sm" onClick={runPodcast} disabled={podcastLoading}>
{podcastLoading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1.5" />}
Regenerate
</Button>
</div>
<audio controls src={pod.audio_url} className="w-full" />
{pod.script ? (
<div className="border border-border rounded-xl p-4 space-y-2">
<h3 className="text-sm font-medium">Script</h3>
<pre className="whitespace-pre-wrap font-sans text-sm text-muted-foreground">{pod.script}</pre>
</div>
) : null}
</div>
) : pod?.status === "generating" || podcastLoading ? (
<div className="border border-dashed border-border rounded-xl p-10 text-center space-y-3">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Generating podcast audio…
</div>
<p className="text-sm text-muted-foreground">This can take a minute depending on the script length.</p>
</div>
) : pod?.status === "failed" ? (
<div className="border border-dashed border-border rounded-xl p-10 text-center space-y-3">
<h3 className="font-medium">Podcast generation failed</h3>
<p className="text-sm text-muted-foreground">Try again to rebuild the two-host audio version.</p>
<Button onClick={runPodcast} disabled={podcastLoading}>
{podcastLoading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Headphones className="h-4 w-4 mr-2" />}
Retry podcast
</Button>
</div>
) : (
<div className="border border-dashed border-border rounded-xl p-10 text-center space-y-3">
<h3 className="font-medium">Generate podcast</h3>
<p className="text-sm text-muted-foreground">Create a two-host audio recap from your generated notes.</p>
<Button onClick={runPodcast} disabled={podcastLoading}>
{podcastLoading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Headphones className="h-4 w-4 mr-2" />}
Generate podcast
</Button>
</div>
)}
</TabsContent>
<TabsContent value="chat" className="m-0 p-4 sm:p-6 max-w-3xl mx-auto animate-fade-in">
<ChatPanel documentId={doc.id} noteReady={!!note?.markdown} />
</TabsContent>
</div>
</Tabs>
)}
</div>
);
}
function ExpertProcessing({ status, sourceType }: { status: string; sourceType: string }) {
const [step, setStep] = useState(0);
const steps = [
"Initializing Expert Extraction Engine...",
"Analyzing Document Hierarchy...",
"Mapping Frameworks & Key Concepts...",
"Ensuring Lossless Capture of Sequential Data...",
"Finalizing Exhaustive Markdown Structure...",
];
useEffect(() => {
const interval = setInterval(() => {
setStep((s) => (s < steps.length - 1 ? s + 1 : s));
}, 3000);
return () => clearInterval(interval);
}, []);
return (
<div className="max-w-md w-full space-y-8 text-center animate-in fade-in zoom-in duration-500">
<div className="relative mx-auto w-24 h-24">
<div className="absolute inset-0 border-2 border-primary/20 rounded-full animate-ping" />
<div className="absolute inset-2 border-2 border-primary/40 rounded-full animate-pulse" />
<div className="absolute inset-0 flex items-center justify-center">
<Sparkles className="h-10 w-10 text-primary animate-bounce" />
</div>
</div>
<div className="space-y-3">
<h2 className="text-xl font-semibold tracking-tight text-white">Expert Agent at Work</h2>
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-1000 ease-out shadow-[0_0_15px_rgba(14,165,233,0.5)]"
style={{ width: `${((step + 1) / steps.length) * 100}%` }}
/>
</div>
<p className="text-sm text-slate-400 animate-pulse min-h-[1.25rem]">
{steps[step]}
</p>
</div>
<div className="grid grid-cols-2 gap-4 text-left">
<div className="p-3 rounded-lg bg-slate-900/50 border border-slate-800">
<p className="text-[10px] uppercase text-slate-500 font-bold mb-1">Source Type</p>
<p className="text-xs text-slate-200 capitalize">{sourceType}</p>
</div>
<div className="p-3 rounded-lg bg-slate-900/50 border border-slate-800">
<p className="text-[10px] uppercase text-slate-500 font-bold mb-1">Extraction Mode</p>
<p className="text-xs text-slate-200">Lossless Exhaustive</p>
</div>
</div>
<div className="text-[10px] text-slate-500 uppercase tracking-widest font-medium">
Advanced Academic Data Recovery in Progress
</div>
</div>
);
}
function Placeholder({ title, desc }: { title: string; desc: string }) {
return (
<div className="border border-dashed border-border rounded-xl p-10 text-center">
<h3 className="font-medium mb-1">{title}</h3>
<p className="text-sm text-muted-foreground">{desc}</p>
</div>
);
}
function DerivativesEmpty({
kind, noteReady, loading, onGenerate,
}: { kind: "flashcards" | "quiz"; noteReady: boolean; loading: boolean; onGenerate: () => void }) {
if (!noteReady) {
return <Placeholder title={`No ${kind} yet`} desc="Generate notes first, then come back here." />;
}
return (
<div className="border border-dashed border-border rounded-xl p-10 text-center space-y-3">
<h3 className="font-medium">Generate {kind}</h3>
<p className="text-sm text-muted-foreground">
We'll use your notes to create {kind === "flashcards" ? "study flashcards" : "a multi-format quiz"}.
</p>
<Button onClick={onGenerate} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Sparkles className="h-4 w-4 mr-2" />}
Generate
</Button>
</div>
);
}