| import { useState } from "react"; |
| import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; |
| import { Button } from "@/components/ui/button"; |
| import { Input } from "@/components/ui/input"; |
| import { Label } from "@/components/ui/label"; |
| import { Textarea } from "@/components/ui/textarea"; |
| import { useToast } from "@/hooks/use-toast"; |
| import { useAuth } from "@/hooks/useAuth"; |
| import { supabase } from "@/integrations/supabase/client"; |
| import { useNavigate } from "react-router-dom"; |
| import { Loader2, Upload, FileText, Youtube, Sparkles } from "lucide-react"; |
| import { useDropzone } from "react-dropzone"; |
| import { triggerIngest } from "@/lib/pipeline"; |
| import { extractFileText } from "@/lib/extract"; |
|
|
| const MAX_FILE_BYTES = 50 * 1024 * 1024; |
| const AUDIO_EXTS = ["mp3", "wav", "m4a", "ogg", "flac", "webm"]; |
| const VIDEO_EXTS = ["mp4", "mov", "mkv"]; |
| const IMAGE_EXTS = ["png", "jpg", "jpeg", "webp", "bmp"]; |
|
|
| export default function UploadDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) { |
| const { user } = useAuth(); |
| const { toast } = useToast(); |
| const navigate = useNavigate(); |
| const [submitting, setSubmitting] = useState(false); |
| const [extractionProgress, setExtractionProgress] = useState(0); |
|
|
| |
| const [textTitle, setTextTitle] = useState(""); |
| const [textContent, setTextContent] = useState(""); |
| |
| const [ytUrl, setYtUrl] = useState(""); |
| |
| const [file, setFile] = useState<File | null>(null); |
|
|
| const { getRootProps, getInputProps, isDragActive } = useDropzone({ |
| multiple: false, |
| onDrop: (files) => setFile(files[0] ?? null), |
| }); |
|
|
| const reset = () => { |
| setTextTitle(""); setTextContent(""); setYtUrl(""); setFile(null); |
| setExtractionProgress(0); |
| }; |
|
|
| const createTextDoc = async () => { |
| if (!user || !textContent.trim()) return; |
| setSubmitting(true); |
| try { |
| |
| const EXPERT_PREFIX = `[EXPERT ADVISORY: ACT AS AN EXPERT ACADEMIC DATA EXTRACTION AGENT. |
| STYLE: PROFESSIONAL, CLEAN MARKDOWN. NO EMOJIS ALLOWED. |
| DIRECTIVE: LOSSLESS EXTRACTION. ZERO OMISSION. |
| TEMPLATE: Overview -> Key Points -> Tables for Frameworks -> Sub-headings for Detailed Terms.]\n\n`; |
| |
| const finalContent = EXPERT_PREFIX + textContent; |
|
|
| const { data, error } = await supabase |
| .from("documents") |
| .insert({ |
| user_id: user.id, |
| title: textTitle.trim() || "Untitled note", |
| source_type: "text", |
| raw_text: finalContent, |
| status: "ready", |
| }) |
| .select("id") |
| .single(); |
| if (error) throw error; |
| toast({ title: "Document created", description: "Expert agent has saved your notes with lossless directives." }); |
| reset(); onOpenChange(false); |
| navigate(`/app/doc/${data!.id}`); |
| } catch (e: any) { |
| toast({ title: "Failed", description: e.message, variant: "destructive" }); |
| } finally { |
| setSubmitting(false); |
| } |
| }; |
|
|
| const createYoutubeDoc = async () => { |
| if (!user || !ytUrl.trim()) return; |
| setSubmitting(true); |
| try { |
| const { data, error } = await supabase |
| .from("documents") |
| .insert({ |
| user_id: user.id, |
| title: ytUrl, |
| source_type: "youtube", |
| source_url: ytUrl, |
| status: "pending", |
| }) |
| .select("id") |
| .single(); |
| if (error) throw error; |
| toast({ title: "Source Queued", description: "Expert Agent is fetching the transcript..." }); |
| reset(); onOpenChange(false); |
| navigate(`/app/doc/${data!.id}`); |
| triggerIngest(data!.id).catch((e) => |
| toast({ title: "Extraction failed", description: e.message, variant: "destructive" }), |
| ); |
| } catch (e: any) { |
| toast({ title: "Failed", description: e.message, variant: "destructive" }); |
| } finally { |
| setSubmitting(false); |
| } |
| }; |
|
|
| const createFileDoc = async () => { |
| if (!user || !file) return; |
| if (file.size > MAX_FILE_BYTES) { |
| toast({ title: "File too large", description: "Expert Agent requires files under 50MB.", variant: "destructive" }); |
| return; |
| } |
| setSubmitting(true); |
| try { |
| const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; |
| const isPdf = ext === "pdf"; |
| const isDocx = ext === "docx" || ext === "doc"; |
| const isImage = IMAGE_EXTS.includes(ext); |
| const isAudio = AUDIO_EXTS.includes(ext); |
| const isVideo = VIDEO_EXTS.includes(ext); |
|
|
| |
| if (isPdf || isDocx || isImage) { |
| toast({ title: "Expert Extraction Initialized", description: "Running parallel OCR and hierarchy mapping..." }); |
| const { text: extractedText, sourceType } = await extractFileText(file, (p) => setExtractionProgress(p)); |
| if (!extractedText || extractedText.trim().length < 5) { |
| throw new Error("Expert Agent could not extract readable text from this file."); |
| } |
|
|
| |
| const EXPERT_PREFIX = `[EXPERT ADVISORY: ACT AS AN EXPERT ACADEMIC DATA EXTRACTION AGENT. |
| STYLE: PROFESSIONAL, CLEAN MARKDOWN. NO EMOJIS ALLOWED. |
| DIRECTIVE: LOSSLESS EXTRACTION. ZERO OMISSION. |
| TEMPLATE: Overview -> Key Points -> Tables for Frameworks -> Sub-headings for Detailed Terms.]\n\n`; |
| |
| const finalRawText = EXPERT_PREFIX + extractedText; |
|
|
| const { data, error } = await supabase |
| .from("documents") |
| .insert({ |
| user_id: user.id, |
| title: file.name, |
| source_type: sourceType, |
| raw_text: finalRawText, |
| status: "pending", |
| }) |
| .select("id") |
| .single(); |
| if (error) throw error; |
| toast({ title: "Extraction Complete", description: "Expert Agent is finalizing the document..." }); |
| reset(); onOpenChange(false); |
| navigate(`/app/doc/${data!.id}`); |
| triggerIngest(data!.id).catch((e) => |
| toast({ title: "Ingest failed", description: e.message, variant: "destructive" }), |
| ); |
| return; |
| } |
|
|
| |
| if (!isAudio && !isVideo) { |
| throw new Error("Unsupported file type. Use PDF, DOCX, Image, audio or video."); |
| } |
| const sourceType = isAudio ? "audio" : "video"; |
| const path = `${user.id}/${crypto.randomUUID()}-${file.name}`; |
| const { error: upErr } = await supabase.storage.from("uploads").upload(path, file); |
| if (upErr) throw upErr; |
|
|
| const { data, error } = await supabase |
| .from("documents") |
| .insert({ |
| user_id: user.id, |
| title: file.name, |
| source_type: sourceType, |
| source_url: path, |
| status: "pending", |
| }) |
| .select("id") |
| .single(); |
| if (error) throw error; |
| toast({ title: "Source Uploaded", description: "Expert Agent is initiating transcription..." }); |
| reset(); onOpenChange(false); |
| navigate(`/app/doc/${data!.id}`); |
| triggerIngest(data!.id).catch((e) => |
| toast({ title: "Ingest failed", description: e.message, variant: "destructive" }), |
| ); |
| } catch (e: any) { |
| toast({ title: "Failed", description: e.message, variant: "destructive" }); |
| } finally { |
| setSubmitting(false); |
| } |
| }; |
|
|
| return ( |
| <Dialog open={open} onOpenChange={onOpenChange}> |
| <DialogContent className="sm:max-w-lg bg-slate-950 border-slate-800 text-white"> |
| <DialogHeader> |
| <DialogTitle className="flex items-center gap-2"> |
| <Sparkles className="h-5 w-5 text-primary" /> |
| Add Academic Source |
| </DialogTitle> |
| </DialogHeader> |
| |
| <Tabs defaultValue="file" className="w-full"> |
| <TabsList className="grid grid-cols-3 w-full bg-slate-900"> |
| <TabsTrigger value="file" className="data-[state=active]:bg-slate-800 text-xs"> |
| <Upload className="h-3.5 w-3.5 mr-1.5" /> File |
| </TabsTrigger> |
| <TabsTrigger value="youtube" className="data-[state=active]:bg-slate-800 text-xs"> |
| <Youtube className="h-3.5 w-3.5 mr-1.5" /> YouTube |
| </TabsTrigger> |
| <TabsTrigger value="text" className="data-[state=active]:bg-slate-800 text-xs"> |
| <FileText className="h-3.5 w-3.5 mr-1.5" /> Text |
| </TabsTrigger> |
| </TabsList> |
| |
| <TabsContent value="file" className="space-y-4 pt-4"> |
| <div |
| {...getRootProps()} |
| className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-all duration-300 ${ |
| isDragActive ? "border-primary bg-primary/10" : "border-slate-800 hover:border-primary/50 hover:bg-slate-900/50" |
| }`} |
| > |
| <input {...getInputProps()} /> |
| <Upload className="h-8 w-8 mx-auto mb-3 text-slate-500" /> |
| {file ? ( |
| <p className="text-sm font-medium">{file.name}</p> |
| ) : ( |
| <div className="space-y-1"> |
| <p className="text-sm text-slate-300"> |
| {isDragActive ? "Drop here…" : "Drag your academic document here"} |
| </p> |
| <p className="text-[11px] text-slate-500 uppercase tracking-tighter"> |
| PDF, DOCX, Images, MP3, MP4 Supported |
| </p> |
| </div> |
| )} |
| </div> |
| |
| {submitting && extractionProgress > 0 && ( |
| <div className="space-y-2"> |
| <div className="flex justify-between text-[10px] uppercase tracking-widest text-slate-500 font-bold"> |
| <span>Extraction Progress</span> |
| <span>{extractionProgress}%</span> |
| </div> |
| <div className="h-1.5 w-full bg-slate-900 rounded-full overflow-hidden"> |
| <div |
| className="h-full bg-primary transition-all duration-300" |
| style={{ width: `${extractionProgress}%` }} |
| /> |
| </div> |
| </div> |
| )} |
| |
| <Button |
| onClick={createFileDoc} |
| disabled={!file || submitting} |
| className="w-full bg-primary hover:bg-primary/90 text-white font-semibold" |
| > |
| {submitting ? ( |
| <> |
| <Loader2 className="h-4 w-4 mr-2 animate-spin" /> |
| {extractionProgress > 0 ? "Expert Scanning..." : "Processing..."} |
| </> |
| ) : ( |
| "Initialize Extraction" |
| )} |
| </Button> |
| </TabsContent> |
| |
| <TabsContent value="youtube" className="space-y-4 pt-4"> |
| <div className="space-y-1.5"> |
| <Label htmlFor="yt" className="text-xs text-slate-400">YouTube URL</Label> |
| <Input |
| id="yt" |
| value={ytUrl} |
| onChange={(e) => setYtUrl(e.target.value)} |
| placeholder="https://youtube.com/watch?v=..." |
| className="bg-slate-900 border-slate-800 text-white placeholder:text-slate-600" |
| /> |
| </div> |
| <Button onClick={createYoutubeDoc} disabled={!ytUrl.trim() || submitting} className="w-full bg-primary hover:bg-primary/90"> |
| {submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} Analyze Link |
| </Button> |
| </TabsContent> |
| |
| <TabsContent value="text" className="space-y-4 pt-4"> |
| <div className="space-y-1.5"> |
| <Label htmlFor="title" className="text-xs text-slate-400">Note Title</Label> |
| <Input |
| id="title" |
| value={textTitle} |
| onChange={(e) => setTextTitle(e.target.value)} |
| placeholder="Untitled Lecture Notes" |
| className="bg-slate-900 border-slate-800 text-white placeholder:text-slate-600" |
| /> |
| </div> |
| <div className="space-y-1.5"> |
| <Label htmlFor="content" className="text-xs text-slate-400">Content</Label> |
| <Textarea |
| id="content" |
| value={textContent} |
| onChange={(e) => setTextContent(e.target.value)} |
| rows={8} |
| placeholder="Paste your text here…" |
| className="bg-slate-900 border-slate-800 text-white placeholder:text-slate-600 resize-none" |
| /> |
| </div> |
| <Button onClick={createTextDoc} disabled={!textContent.trim() || submitting} className="w-full bg-primary hover:bg-primary/90"> |
| {submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} Create Expert Note |
| </Button> |
| </TabsContent> |
| </Tabs> |
| </DialogContent> |
| </Dialog> |
| ); |
| } |
|
|