SOURCE.IO / src /components /UploadDialog.tsx
Adeen
feat: adopt Clean Professional (Turbo AI) style for notes
1974805
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; // 50MB
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);
// Text mode
const [textTitle, setTextTitle] = useState("");
const [textContent, setTextContent] = useState("");
// YouTube mode
const [ytUrl, setYtUrl] = useState("");
// File mode
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 {
// EXPERT OVERRIDE
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);
// PDF/DOCX/Image: extract text in the browser, no file upload needed
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.");
}
// EXPERT OVERRIDE: Prepend instructions to ensure lossless extraction even if backend is old
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;
}
// Audio/Video: upload to storage; ingest function transcribes via Groq Whisper
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>
);
}