332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
import { useState, useRef } from 'react';
|
||
import { Link, useNavigate } from 'react-router-dom';
|
||
import type { ResumeAnalysisResult } from '../types/resumeAnalysis';
|
||
import Modal from '../components/Modal';
|
||
import { API_ENDPOINTS } from '../config/api';
|
||
import '../assets/css/upload.css';
|
||
|
||
/**
|
||
* Upload page component for resume and job description input
|
||
* Trace: SDD_LLD_0020 - Provides responsive file-picker and text input area
|
||
* Trace: SDD_LLD_0021 - Accept textual job description input
|
||
* Trace: SDD_LLD_0022 - Display resume preview
|
||
* Trace: SDD_LLD_0027 - Manages global "loading" state with progress spinner
|
||
* Trace: SDD_HLD_0001 - Accept PDF resume input
|
||
* Trace: SDD_HLD_0004 - Read string of text from textbox
|
||
*/
|
||
export default function UploadPage() {
|
||
const [resumeFile, setResumeFile] = useState<File | null>(null);
|
||
const [resumePreview, setResumePreview] = useState<string | null>(null);
|
||
const [jobDescription, setJobDescription] = useState('');
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||
const [modalState, setModalState] = useState<{
|
||
isOpen: boolean;
|
||
title: string;
|
||
message: string;
|
||
type: 'error' | 'warning' | 'info';
|
||
}>({
|
||
isOpen: false,
|
||
title: '',
|
||
message: '',
|
||
type: 'error'
|
||
});
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const navigate = useNavigate();
|
||
|
||
// Helper to show modal
|
||
const showModal = (title: string, message: string, type: 'error' | 'warning' | 'info' = 'error') => {
|
||
setModalState({ isOpen: true, title, message, type });
|
||
};
|
||
|
||
const closeModal = () => {
|
||
setModalState(prev => ({ ...prev, isOpen: false }));
|
||
};
|
||
|
||
// Trace: SDD_LLD_0001 - Accept only PDF uploads (validation)
|
||
// Trace: SDD_HLD_0001 - Accept only PDF formatting for input resumes
|
||
const handleFileSelect = (file: File) => {
|
||
if (file && (file.type === 'application/pdf' || file.type.startsWith('image/'))) {
|
||
setResumeFile(file);
|
||
|
||
// Trace: SDD_LLD_0022 - Display resume preview
|
||
// Create preview URL
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => {
|
||
setResumePreview(reader.result as string);
|
||
};
|
||
|
||
if (file.type === 'application/pdf') {
|
||
// For PDF, we'll show a placeholder or use an iframe
|
||
reader.readAsDataURL(file);
|
||
} else {
|
||
reader.readAsDataURL(file);
|
||
}
|
||
} else {
|
||
// Trace: SDD_LLD_0002 - Reject non-PDF uploads
|
||
// Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner
|
||
showModal(
|
||
'Invalid File Type',
|
||
'Please upload a PDF or image file. Other file formats are not supported.',
|
||
'warning'
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
setIsDragging(false);
|
||
|
||
const file = e.dataTransfer.files[0];
|
||
if (file) {
|
||
handleFileSelect(file);
|
||
}
|
||
};
|
||
|
||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
setIsDragging(true);
|
||
};
|
||
|
||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
setIsDragging(false);
|
||
};
|
||
|
||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) {
|
||
handleFileSelect(file);
|
||
}
|
||
};
|
||
|
||
const handleRemoveFile = () => {
|
||
setResumeFile(null);
|
||
setResumePreview(null);
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = '';
|
||
}
|
||
};
|
||
|
||
// Trace: SDD_LLD_0005 - Integrate with upload interface to send data to backend
|
||
// Trace: SDD_LLD_0027 - Show loading spinner during active API calls
|
||
const handleAnalyze = async () => {
|
||
if (!resumeFile) {
|
||
showModal(
|
||
'Resume Required',
|
||
'Please upload a resume before analyzing.',
|
||
'warning'
|
||
);
|
||
return;
|
||
}
|
||
if (!jobDescription.trim()) {
|
||
showModal(
|
||
'Job Description Required',
|
||
'Please enter a job description to compare your resume against.',
|
||
'warning'
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Trace: SDD_LLD_0027 - Manages loading state and disables submission buttons
|
||
setIsAnalyzing(true);
|
||
|
||
try {
|
||
// Trace: SDD_LLD_0005 - Send multipart/form data to backend endpoint
|
||
const formData = new FormData();
|
||
formData.append('resume', resumeFile);
|
||
formData.append('job_description', jobDescription);
|
||
|
||
// Trace: SDD_HLD_0005 - Provide structured inputs to AI grader
|
||
const response = await fetch(API_ENDPOINTS.analyze, { method: 'POST', body: formData });
|
||
|
||
// Trace: SRD_UseCase_0006 - Display error message when rate limit reached
|
||
// Trace: SRD_QualAssurReq_0006 - Handle rate limit error appropriately
|
||
// Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner
|
||
if (response.status === 429) {
|
||
const errorData = await response.json();
|
||
showModal(
|
||
'Rate Limit Exceeded',
|
||
errorData.error || 'You can make up to 10 requests per hour. Please try again later.',
|
||
'warning'
|
||
);
|
||
setIsAnalyzing(false);
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) throw new Error(`Server error: ${response.status}`);
|
||
|
||
// Trace: SDD_LLD_0019 - Receive JSON output from backend
|
||
const analysisData: ResumeAnalysisResult = await response.json();
|
||
|
||
// Trace: SDD_LLD_0028 - Navigate to results page
|
||
navigate('/results', { state: analysisData });
|
||
} catch (error) {
|
||
// Trace: SDD_LLD_0030 - Display persistent error messages
|
||
// Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner
|
||
console.error('Error analyzing resume:', error);
|
||
showModal(
|
||
'Analysis Failed',
|
||
'Failed to analyze resume. Please check your connection and try again.',
|
||
'error'
|
||
);
|
||
setIsAnalyzing(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="upload-page">
|
||
<div className="upload-container">
|
||
{/* Header */}
|
||
<div className="upload-header">
|
||
<Link to="/" className="back-link">
|
||
← Back to Home
|
||
</Link>
|
||
<h1 className="upload-title">
|
||
Upload Your <span className="gradient-text">Resume</span>
|
||
</h1>
|
||
<p className="upload-subtitle">
|
||
Upload your resume and add a job description to get personalized feedback
|
||
</p>
|
||
</div>
|
||
|
||
<div className="upload-content">
|
||
{/* Left Column - Upload Area */}
|
||
<div className="upload-section">
|
||
<div className="section-header">
|
||
<h2 className="section-title">Resume Upload</h2>
|
||
{resumeFile && (
|
||
<button onClick={handleRemoveFile} className="remove-btn">
|
||
Remove File
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{!resumeFile ? (
|
||
<div
|
||
className={`upload-area ${isDragging ? 'dragging' : ''}`}
|
||
onDrop={handleDrop}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
<div className="upload-icon">📄</div>
|
||
<p className="upload-text">
|
||
<span className="upload-text-bold">Click to upload</span> or drag and drop
|
||
</p>
|
||
<p className="upload-text-small">
|
||
PDF (MAX. 10MB)
|
||
</p>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".pdf"
|
||
onChange={handleFileInputChange}
|
||
className="file-input"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="file-info">
|
||
<div className="file-info-header">
|
||
<div className="file-icon">📄</div>
|
||
<div className="file-details">
|
||
<p className="file-name">{resumeFile.name}</p>
|
||
<p className="file-size">
|
||
{(resumeFile.size / 1024 / 1024).toFixed(2)} MB
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Resume Preview */}
|
||
{/* Trace: SDD_LLD_0022 - Display resume preview */}
|
||
{resumePreview && (
|
||
<div className="preview-section">
|
||
<h3 className="preview-title">Resume Preview</h3>
|
||
<div className="preview-container">
|
||
{resumeFile?.type === 'application/pdf' ? (
|
||
<iframe
|
||
src={`${resumePreview}#toolbar=0&navpanes=0&scrollbar=0`}
|
||
className="preview-pdf"
|
||
title="Resume Preview"
|
||
/>
|
||
) : (
|
||
<img
|
||
src={resumePreview}
|
||
alt="Resume Preview"
|
||
className="preview-image"
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Right Column - Job Description */}
|
||
{/* Trace: SDD_LLD_0021 - Accept textual job description input */}
|
||
{/* Trace: SDD_HLD_0004 - Read string of text from textbox */}
|
||
<div className="job-description-section">
|
||
<div className="section-header">
|
||
<h2 className="section-title">Job Description</h2>
|
||
</div>
|
||
<p className="section-description">
|
||
Paste the job description to get targeted feedback on how well your resume matches the requirements.
|
||
</p>
|
||
<textarea
|
||
className="job-description-input"
|
||
placeholder="Paste the job description here... Example: We are looking for a software engineer with 3+ years of experience in React and TypeScript..."
|
||
value={jobDescription}
|
||
onChange={(e) => setJobDescription(e.target.value)}
|
||
rows={15}
|
||
/>
|
||
<div className="char-count">
|
||
{jobDescription.length} characters
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Buttons */}
|
||
<div className="upload-actions">
|
||
<Link to="/" className="btn btn-secondary">
|
||
Cancel
|
||
</Link>
|
||
<button
|
||
onClick={handleAnalyze}
|
||
className="btn btn-primary btn-large"
|
||
disabled={!resumeFile || !jobDescription.trim() || isAnalyzing}
|
||
>
|
||
Analyze Resume
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Loading Overlay */}
|
||
{/* Trace: SDD_LLD_0027 - Display loading indicator during processing */}
|
||
{isAnalyzing && (
|
||
<div className="loading-overlay">
|
||
<div className="loading-modal">
|
||
<div className="loading-spinner" />
|
||
<h2 className="loading-title">Analyzing Resume</h2>
|
||
<p className="loading-message">
|
||
Our AI is reviewing your resume against the job description.
|
||
This usually takes 15–30 seconds.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Error/Warning Modal */}
|
||
{/* Trace: SDD_LLD_0030 - Display persistent error messages */}
|
||
{/* Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner (not toasts or popups) */}
|
||
<Modal
|
||
isOpen={modalState.isOpen}
|
||
onClose={closeModal}
|
||
title={modalState.title}
|
||
message={modalState.message}
|
||
type={modalState.type}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|