|
|
import React, { useState, useRef } from "react"; |
|
|
import { |
|
|
FileSpreadsheet, |
|
|
ListChecks, |
|
|
Upload, |
|
|
X, |
|
|
Loader2, |
|
|
Clock, |
|
|
FileText, |
|
|
ChevronRight, |
|
|
Brain, |
|
|
} from "lucide-react"; |
|
|
import MCQQuizPage from "../components/quize/mcq"; |
|
|
import API from "../api/api"; |
|
|
import * as pdfjsLib from "pdfjs-dist"; |
|
|
|
|
|
|
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`; |
|
|
|
|
|
const ResumeGeneratedQuize: React.FC = () => { |
|
|
const [showQuiz, setShowQuiz] = useState(false); |
|
|
const [quizType, setQuizType] = useState<"mcq" | null>("mcq"); |
|
|
const [uploadType, setUploadType] = useState<"resume" | "notes" | null>(null); |
|
|
const [uploadedFile, setUploadedFile] = useState<string | null>(null); |
|
|
const [fileError, setFileError] = useState<string | null>(null); |
|
|
const [fileObject, setFileObject] = useState<File | null>(null); |
|
|
const [isProcessing, setIsProcessing] = useState(false); |
|
|
|
|
|
|
|
|
const [customPrompt, setCustomPrompt] = useState(""); |
|
|
const [duration, setDuration] = useState(15); |
|
|
const [quizData, setQuizData] = useState(null); |
|
|
|
|
|
const resumeInputRef = useRef<HTMLInputElement>(null); |
|
|
const notesInputRef = useRef<HTMLInputElement>(null); |
|
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; |
|
|
|
|
|
const handleFileUpload = ( |
|
|
event: React.ChangeEvent<HTMLInputElement>, |
|
|
type: "resume" | "notes" |
|
|
) => { |
|
|
const file = event.target.files?.[0]; |
|
|
if (!file) return; |
|
|
|
|
|
if (file.type !== "application/pdf") { |
|
|
setFileError("Please upload a PDF file"); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (file.size > MAX_FILE_SIZE) { |
|
|
setFileError( |
|
|
`File size exceeds 10MB. Your file is ${( |
|
|
file.size / |
|
|
(1024 * 1024) |
|
|
).toFixed(2)}MB` |
|
|
); |
|
|
return; |
|
|
} |
|
|
|
|
|
setFileError(null); |
|
|
setUploadType(type); |
|
|
setUploadedFile(file.name); |
|
|
setFileObject(file); |
|
|
}; |
|
|
|
|
|
const handleResumeClick = () => resumeInputRef.current?.click(); |
|
|
const handleNotesClick = () => notesInputRef.current?.click(); |
|
|
|
|
|
const clearUpload = () => { |
|
|
setUploadedFile(null); |
|
|
setUploadType(null); |
|
|
setFileError(null); |
|
|
setFileObject(null); |
|
|
}; |
|
|
|
|
|
const extractTextFromPDF = async (file: File): Promise<string> => { |
|
|
const arrayBuffer = await file.arrayBuffer(); |
|
|
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; |
|
|
let fullText = ""; |
|
|
for (let i = 1; i <= pdf.numPages; i++) { |
|
|
const page = await pdf.getPage(i); |
|
|
const textContent = await page.getTextContent(); |
|
|
const pageText = textContent.items.map((item: any) => item.str).join(" "); |
|
|
fullText += pageText + "\n"; |
|
|
} |
|
|
return fullText; |
|
|
}; |
|
|
|
|
|
const generateQuiz = async () => { |
|
|
if (!fileObject || !quizType || !uploadType) return; |
|
|
|
|
|
setIsProcessing(true); |
|
|
setFileError(null); |
|
|
|
|
|
try { |
|
|
console.log("Extracting text from PDF..."); |
|
|
const extractedText = await extractTextFromPDF(fileObject); |
|
|
|
|
|
if (!extractedText.trim()) { |
|
|
throw new Error( |
|
|
"Could not extract text from the PDF. It might be an image-only PDF." |
|
|
); |
|
|
} |
|
|
|
|
|
const payload = { |
|
|
parsed_doc: extractedText.trim(), |
|
|
user_prompt: |
|
|
customPrompt.trim() || "Generate a quiz based on this content.", |
|
|
}; |
|
|
|
|
|
const endpoint = uploadType === "notes" ? "/quiz/notes" : "/quiz/resume"; |
|
|
|
|
|
console.log(`Sending to ${endpoint}...`); |
|
|
|
|
|
const response = await API.post(endpoint, payload); |
|
|
setQuizData(response.data); |
|
|
setShowQuiz(true); |
|
|
} catch (error: any) { |
|
|
console.error("Quiz Generation Error:", error); |
|
|
let errorMessage = "Failed to generate quiz. Check login status."; |
|
|
if (error.response?.data?.detail) { |
|
|
if (typeof error.response.data.detail === "string") { |
|
|
errorMessage = error.response.data.detail; |
|
|
} else if ( |
|
|
Array.isArray(error.response.data.detail) && |
|
|
error.response.data.detail.length > 0 |
|
|
) { |
|
|
const firstError = error.response.data.detail[0]; |
|
|
errorMessage = `Validation Error: Field '${firstError.loc.join( |
|
|
" -> " |
|
|
)}' ${firstError.msg}`; |
|
|
} |
|
|
} else if (error.code === "ERR_BAD_REQUEST") { |
|
|
errorMessage = "Server rejected the data. Are you logged in?"; |
|
|
} |
|
|
setFileError(errorMessage); |
|
|
} finally { |
|
|
setIsProcessing(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
if (showQuiz) { |
|
|
return ( |
|
|
<MCQQuizPage |
|
|
data={quizData} |
|
|
onBack={() => setShowQuiz(false)} |
|
|
totalTimeSeconds={duration * 60} |
|
|
/> |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const OutputTypeOption = ({ icon, title, desc, value }: any) => ( |
|
|
<div |
|
|
onClick={() => setQuizType(value)} |
|
|
className={`p-3 rounded-xl border-2 transition-all cursor-pointer flex flex-col gap-2 group h-full |
|
|
${ |
|
|
quizType === value |
|
|
? "bg-[#607B8F] border-[#F7E396] shadow-lg transform scale-[1.01]" |
|
|
: "bg-[#607B8F]/50 border-transparent hover:bg-[#607B8F] hover:border-[#E97F4A]/50" |
|
|
} |
|
|
`} |
|
|
> |
|
|
<div className="flex items-center gap-3"> |
|
|
<div |
|
|
className={`p-1.5 rounded-lg ${ |
|
|
quizType === value |
|
|
? "bg-[#F7E396] text-[#434E78]" |
|
|
: "bg-[#434E78]/50 text-[#F7E396]" |
|
|
}`} |
|
|
> |
|
|
{icon} |
|
|
</div> |
|
|
<h4 |
|
|
className={`text-sm font-bold ${ |
|
|
quizType === value |
|
|
? "text-[#F7E396]" |
|
|
: "text-white group-hover:text-[#F7E396]" |
|
|
}`} |
|
|
> |
|
|
{title} |
|
|
</h4> |
|
|
</div> |
|
|
<p className="text-gray-300 text-xs leading-relaxed">{desc}</p> |
|
|
</div> |
|
|
); |
|
|
|
|
|
return ( |
|
|
|
|
|
<div className="w-full h-screen bg-[#434E78] text-white relative font-sans overflow-hidden flex flex-col"> |
|
|
{/* Background Blobs */} |
|
|
<div className="absolute top-[-10%] right-[-5%] w-96 h-96 bg-[#F7E396] rounded-full mix-blend-overlay filter blur-3xl opacity-10 animate-blob pointer-events-none"></div> |
|
|
<div className="absolute bottom-[-10%] left-[-10%] w-96 h-96 bg-[#607B8F] rounded-full mix-blend-overlay filter blur-3xl opacity-10 animate-blob animation-delay-2000 pointer-events-none"></div> |
|
|
|
|
|
{/* Main Content Wrapper - Added 'pt-6' to fix header cut-off */} |
|
|
<div className="flex-1 overflow-y-auto p-4 lg:p-6 pt-8 lg:pt-12 w-full z-10 flex flex-col items-center"> |
|
|
<div className="max-w-6xl w-full flex flex-col h-full"> |
|
|
{/* Header - Compacted margins */} |
|
|
<div className="text-center mb-6 shrink-0 mt-2"> |
|
|
<h1 className="text-3xl font-bold mb-2 font-handwriting drop-shadow-md"> |
|
|
Smart AI-Powered Quiz Generator |
|
|
</h1> |
|
|
<div className="flex items-center justify-center gap-2 text-[#F7E396] text-sm"> |
|
|
<Brain className="w-5 h-5" /> |
|
|
<span className="font-semibold tracking-wide"> |
|
|
Generate Quizzes from Resumes or Notes |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Content Grid - Compacted spacing */} |
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 items-start flex-1"> |
|
|
{/* LEFT COLUMN */} |
|
|
<div className="lg:col-span-7 flex flex-col gap-5"> |
|
|
{/* 1. Source Card - Compact Padding (p-5) */} |
|
|
<div className="bg-[#607B8F] rounded-2xl shadow-xl border border-white/10 p-5"> |
|
|
<div className="flex items-center gap-3 mb-3 border-b border-white/10 pb-3"> |
|
|
<FileSpreadsheet className="w-5 h-5 text-[#F7E396]" /> |
|
|
<h3 className="text-lg font-bold text-white"> |
|
|
1. Select Quiz Source |
|
|
</h3> |
|
|
</div> |
|
|
|
|
|
<div className="space-y-3"> |
|
|
<p className="text-gray-200 text-xs"> |
|
|
Upload your material (Max 10MB PDF). |
|
|
</p> |
|
|
|
|
|
<div className="flex gap-3"> |
|
|
<button |
|
|
onClick={handleResumeClick} |
|
|
className={`flex-1 py-2.5 px-4 rounded-xl font-bold transition flex items-center justify-center gap-2 shadow-md text-sm |
|
|
${ |
|
|
uploadType === "resume" |
|
|
? "bg-[#F7E396] text-[#434E78] ring-2 ring-white" |
|
|
: "bg-[#434E78] text-white hover:bg-[#E97F4A]" |
|
|
}`} |
|
|
> |
|
|
<Upload className="w-4 h-4" /> |
|
|
Resume |
|
|
</button> |
|
|
|
|
|
<button |
|
|
onClick={handleNotesClick} |
|
|
className={`flex-1 py-2.5 px-4 rounded-xl font-bold transition flex items-center justify-center gap-2 shadow-md text-sm |
|
|
${ |
|
|
uploadType === "notes" |
|
|
? "bg-[#F7E396] text-[#434E78] ring-2 ring-white" |
|
|
: "bg-[#434E78] text-white hover:bg-[#E97F4A]" |
|
|
}`} |
|
|
> |
|
|
<Upload className="w-4 h-4" /> |
|
|
Notes |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<input |
|
|
ref={resumeInputRef} |
|
|
type="file" |
|
|
accept=".pdf" |
|
|
onChange={(e) => handleFileUpload(e, "resume")} |
|
|
className="hidden" |
|
|
/> |
|
|
<input |
|
|
ref={notesInputRef} |
|
|
type="file" |
|
|
accept=".pdf" |
|
|
onChange={(e) => handleFileUpload(e, "notes")} |
|
|
className="hidden" |
|
|
/> |
|
|
|
|
|
{fileError && ( |
|
|
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-200 text-xs"> |
|
|
{fileError} |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{uploadedFile && ( |
|
|
<div className="p-3 bg-[#434E78]/50 border border-[#F7E396]/50 rounded-lg flex items-center justify-between"> |
|
|
<div className="overflow-hidden"> |
|
|
<p className="text-[#F7E396] font-semibold text-xs"> |
|
|
File Uploaded |
|
|
</p> |
|
|
<div className="flex items-center gap-2 mt-1"> |
|
|
<FileText className="w-3 h-3 text-gray-300" /> |
|
|
<span className="text-white text-xs truncate block"> |
|
|
{uploadedFile} |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
<button |
|
|
onClick={clearUpload} |
|
|
className="text-gray-400 hover:text-white p-1 shrink-0" |
|
|
> |
|
|
<X className="w-4 h-4" /> |
|
|
</button> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* 2. Output Type Selection */} |
|
|
<div className="bg-[#607B8F] rounded-2xl shadow-xl border border-white/10 p-5"> |
|
|
<div className="flex items-center gap-3 mb-3 border-b border-white/10 pb-3"> |
|
|
<ListChecks className="w-5 h-5 text-[#F7E396]" /> |
|
|
<h3 className="text-lg font-bold text-white"> |
|
|
2. Choose Output Type |
|
|
</h3> |
|
|
</div> |
|
|
|
|
|
<div className="grid grid-cols-1"> |
|
|
<OutputTypeOption |
|
|
value="mcq" |
|
|
icon={<ListChecks className="w-5 h-5" />} |
|
|
title="Multiple Choice Quiz (MCQ)" |
|
|
desc="Generate a standardized multiple-choice assessment." |
|
|
/> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* RIGHT COLUMN: Configuration */} |
|
|
<div className="lg:col-span-5 flex flex-col h-full"> |
|
|
<div className="bg-[#607B8F] rounded-2xl shadow-xl border border-white/10 p-5 flex flex-col h-full"> |
|
|
<div className="flex items-center gap-3 mb-3 border-b border-white/10 pb-3 shrink-0"> |
|
|
<Clock className="w-5 h-5 text-[#F7E396]" /> |
|
|
<h3 className="text-lg font-bold text-white">3. Configure</h3> |
|
|
</div> |
|
|
|
|
|
<div className="flex-1 flex flex-col gap-4"> |
|
|
{/* Custom Prompt - Reduced height */} |
|
|
<div className="flex-1 flex flex-col min-h-0"> |
|
|
<label className="text-[#F7E396] font-bold block mb-2 text-xs tracking-wide"> |
|
|
Custom Instructions (Optional) |
|
|
</label> |
|
|
<textarea |
|
|
value={customPrompt} |
|
|
onChange={(e) => setCustomPrompt(e.target.value)} |
|
|
placeholder="e.g., 'Focus on React hooks...'" |
|
|
// Reduced min-height to 100px for compactness |
|
|
className="w-full flex-1 p-3 rounded-lg bg-[#434E78]/50 border-2 border-transparent focus:border-[#F7E396] focus:ring-0 outline-none transition text-white placeholder-gray-400 resize-none shadow-inner text-sm min-h-[100px]" |
|
|
></textarea> |
|
|
</div> |
|
|
|
|
|
{/* Duration Slider */} |
|
|
<div className="shrink-0 pt-2"> |
|
|
<div className="flex justify-between items-center mb-2"> |
|
|
<label className="text-[#F7E396] font-bold text-xs tracking-wide"> |
|
|
Quiz Duration |
|
|
</label> |
|
|
<span className="bg-[#434E78] px-2 py-1 rounded-full text-white text-xs font-bold border border-white/10"> |
|
|
{duration} min |
|
|
</span> |
|
|
</div> |
|
|
<input |
|
|
type="range" |
|
|
min="1" |
|
|
max="60" |
|
|
step="1" |
|
|
value={duration} |
|
|
onChange={(e) => setDuration(parseInt(e.target.value))} |
|
|
className="w-full h-1.5 bg-[#434E78] rounded-lg appearance-none cursor-pointer accent-[#F7E396]" |
|
|
/> |
|
|
<div className="flex justify-between text-xs text-gray-300 mt-1 font-medium"> |
|
|
<span>5m</span> |
|
|
<span>30m</span> |
|
|
<span>60m</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Action Button - Reduced height */} |
|
|
<button |
|
|
onClick={generateQuiz} |
|
|
disabled={!quizType || !fileObject || isProcessing} |
|
|
className={`w-full py-3.5 rounded-xl font-bold text-base flex items-center justify-center gap-3 transition-all transform hover:-translate-y-1 shadow-lg shrink-0 mt-2 |
|
|
${ |
|
|
!quizType || !fileObject || isProcessing |
|
|
? "bg-gray-500 text-gray-300 cursor-not-allowed opacity-50" |
|
|
: "bg-[#F7E396] text-[#434E78] hover:bg-[#E97F4A] hover:text-white" |
|
|
} |
|
|
`} |
|
|
> |
|
|
{isProcessing ? ( |
|
|
<> |
|
|
<Loader2 className="animate-spin w-5 h-5" />{" "} |
|
|
Generating... |
|
|
</> |
|
|
) : ( |
|
|
<> |
|
|
Generate Quiz <ChevronRight className="w-5 h-5" /> |
|
|
</> |
|
|
)} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default ResumeGeneratedQuize; |
|
|
|