diff --git a/app.py b/app.py index c9dcc73..fcd6aaa 100644 --- a/app.py +++ b/app.py @@ -31,6 +31,7 @@ # Setup logging logger = setup_logging(__name__) +api_logger = setup_logging("api") # Initialize database init_db() @@ -43,6 +44,48 @@ resume_generator = ResumeGenerator() latex_compiler = LaTeXCompiler() + +# Middleware for request tracking +@app.before_request +def before_request(): + """Track request metadata""" + # Use str(uuid.uuid4())[:8] as a simple request ID + try: + request.request_id = str(uuid.uuid4())[:8] + except Exception: + pass + + +@app.after_request +def after_request(response): + """Log response details""" + rid = "unknown" + try: + rid = getattr(request, "request_id", "unknown") + except Exception: + pass + + api_logger.info(f"[{rid}] {request.method} {request.path} {response.status_code}") + return response + + +# Error handlers +@app.errorhandler(Exception) +def handle_exception(e): + """Handle all unhandled exceptions""" + rid = "unknown" + try: + rid = getattr(request, "request_id", "unknown") + except Exception: + pass + + logger.error(f"[{rid}] Unhandled exception: {str(e)}", exc_info=True) + return jsonify({ + "error": "Internal Server Error", + "message": str(e) + }), 500 + + # Simple HTML interface HTML_TEMPLATE = """ @@ -271,10 +314,13 @@ def generate_resume(): - JSON: {"github_username": "...", "job_description": "..."} - OR Multipart: form fields github_username, job_description AND optional file linkedin_data """ + rid = getattr(request, "request_id", "unknown") try: linkedin_file = None user_id = None - if request.content_type and "multipart/form-data" in request.content_type: + content_type = request.content_type or "" + + if "multipart/form-data" in content_type: github_username = request.form.get("github_username", "").strip() job_description = request.form.get("job_description", "").strip() linkedin_file = request.files.get("linkedin_data") @@ -285,6 +331,8 @@ def generate_resume(): job_description = data.get("job_description", "").strip() user_id = data.get("user_id") + logger.info(f"[{rid}] Resume generation request for user: {user_id}") + if not job_description: return jsonify({"error": "Job description is required"}), 400 @@ -298,11 +346,13 @@ def generate_resume(): profile_data = {} if github_username: + logger.debug(f"[{rid}] Scraping GitHub: {github_username}") github_data = github_scraper.scrape_profile(github_username) profile_data["github"] = github_data # LinkedIn Data Handling (File Export or URL) if linkedin_file and linkedin_file.filename: + logger.debug(f"[{rid}] Processing LinkedIn file") # Save file temporarily upload_dir = "uploads" os.makedirs(upload_dir, exist_ok=True) @@ -318,6 +368,7 @@ def generate_resume(): os.remove(file_path) # Step 2: Analyze job description + logger.debug(f"[{rid}] Analyzing job description") job_requirements = job_analyzer.analyze(job_description) # Step 3: Refine GitHub projects based on job requirements @@ -329,9 +380,11 @@ def generate_resume(): profile_data["github"]["top_projects"] = relevant_projects # Step 4: Generate tailored resume content + logger.debug(f"[{rid}] Generating tailored content") resume_content = resume_generator.generate(profile_data, job_requirements, user_id=user_id) # Step 5: Compile to PDF + logger.debug(f"[{rid}] Compiling LaTeX to PDF") pdf_path = latex_compiler.compile(resume_content) # Step 6: Move PDF to permanent storage and save to database if user is logged in @@ -359,7 +412,8 @@ def generate_resume(): # Create Job Application record company = resume_content.get("basics", {}).get("company", "Target Company") - job_url = request.form.get("job_url") if (request.content_type and "multipart/form-data" in request.content_type) else (data.get("job_url") if data else None) + is_multipart = "multipart/form-data" in content_type + job_url = request.form.get("job_url") if is_multipart else (data.get("job_url") if 'data' in locals() else None) JobApplicationRepository.create_application( user_id=user_id, @@ -369,9 +423,9 @@ def generate_resume(): job_url=job_url, job_description=job_description ) - logger.info(f"Stored resume and application for user {user_id}") + logger.info(f"[{rid}] Stored resume and application for user {user_id}") except Exception as e: - logger.error(f"Failed to store resume/application: {e}") + logger.error(f"[{rid}] Failed to store resume/application: {e}") # Validate that the PDF path is within the temp directory (security check) abs_temp_dir = os.path.abspath("temp") @@ -380,6 +434,7 @@ def generate_resume(): return jsonify({"error": "Invalid file path"}), 500 # Return the PDF file + logger.info(f"[{rid}] Resume generation successful") return send_file( pdf_path, mimetype="application/pdf", @@ -387,6 +442,7 @@ def generate_resume(): ) except Exception as e: + logger.error(f"[{rid}] Generation failed: {e}", exc_info=True) return jsonify({"error": str(e)}), 500 @@ -396,6 +452,7 @@ def interview_prep(): """ Endpoint to generate interview preparation tips and questions """ + rid = getattr(request, "request_id", "unknown") try: data = request.get_json(silent=True) or {} job_description = data.get("job_description", "").strip() @@ -403,6 +460,8 @@ def interview_prep(): if not job_description: return jsonify({"error": "Job description is required"}), 400 + logger.info(f"[{rid}] Interview prep request") + # Analyze job description job_requirements = job_analyzer.analyze(job_description) @@ -412,6 +471,7 @@ def interview_prep(): return jsonify(prep_data) except Exception as e: + logger.error(f"[{rid}] Interview prep failed: {e}", exc_info=True) return jsonify({"error": str(e)}), 500 @@ -488,6 +548,7 @@ def get_user_resumes(user_id): return jsonify([{ "id": str(r.id), "job_title": r.job_title, + "job_description": r.job_description or "", "github_username": r.github_username, "created_at": r.created_at.isoformat(), "is_archived": r.is_archived @@ -548,7 +609,8 @@ def get_user_applications(user_id): "job_title": a.job_title, "company": a.company, "status": a.status, - "applied_date": a.applied_date.isoformat() + "applied_date": a.applied_date.isoformat(), + "job_description": a.job_description or "" } for a in applications]) except Exception as e: return jsonify({"error": str(e)}), 500 diff --git a/app_production.py b/app_production.py index c2b92cc..f259576 100644 --- a/app_production.py +++ b/app_production.py @@ -314,10 +314,11 @@ def before_request(): @app.after_request def after_request(response): """Log response details""" + rid = getattr(request, "request_id", "unknown") if hasattr(request, "start_time"): duration = (datetime.utcnow() - request.start_time).total_seconds() api_logger.info( - f"[{request.request_id}] {request.method} {request.path} " + f"[{rid}] {request.method} {request.path} " f"{response.status_code} {duration:.3f}s" ) return response @@ -327,7 +328,8 @@ def after_request(response): @app.errorhandler(MployableException) def handle_mployable_exception(error): """Handle application-specific exceptions""" - error_logger.warning(f"[{request.request_id}] {error.error_code}: {error.message}") + rid = getattr(request, "request_id", "unknown") + error_logger.warning(f"[{rid}] {error.error_code}: {error.message}") return jsonify(error.to_dict()), error.status_code @@ -354,8 +356,9 @@ def not_found(error): @app.errorhandler(500) def internal_error(error): """Handle internal server errors""" + rid = getattr(request, "request_id", "unknown") error_logger.error( - f"[{request.request_id}] Unhandled exception: {error}", exc_info=True + f"[{rid}] Unhandled exception: {error}", exc_info=True ) return ( jsonify( @@ -388,12 +391,14 @@ def generate_resume(): Returns: PDF file: application/pdf """ + rid = getattr(request, "request_id", "unknown") try: # Parse and validate request linkedin_file = None user_id = None - if request.content_type and "multipart/form-data" in request.content_type: + content_type = request.content_type or "" + if "multipart/form-data" in content_type: github_username = request.form.get("github_username", "").strip() job_description = request.form.get("job_description", "").strip() linkedin_file = request.files.get("linkedin_data") @@ -407,7 +412,7 @@ def generate_resume(): job_description = data.get("job_description", "").strip() user_id = data.get("user_id") - logger.info(f"[{request.request_id}] Resume generation request") + logger.info(f"[{rid}] Resume generation request") # Validate inputs ( @@ -425,7 +430,7 @@ def generate_resume(): "Please provide at least one profile source (GitHub Username or LinkedIn Data Export zip file)" ) - logger.info(f"[{request.request_id}] Input validation passed") + logger.info(f"[{rid}] Input validation passed") # Step 1: Scrape profiles profile_data = {} @@ -433,23 +438,23 @@ def generate_resume(): if github_username: try: logger.debug( - f"[{request.request_id}] Scraping GitHub profile: {github_username}" + f"[{rid}] Scraping GitHub profile: {github_username}" ) github_data = github_scraper.scrape_profile(github_username) profile_data["github"] = github_data logger.debug( - f"[{request.request_id}] GitHub profile scraped successfully" + f"[{rid}] GitHub profile scraped successfully" ) except GitHubUserNotFound as e: raise InvalidGitHubUsername(github_username) except GitHubAPIError as e: - logger.error(f"[{request.request_id}] GitHub API error: {e}") + logger.error(f"[{rid}] GitHub API error: {e}") raise # LinkedIn Data Handling (File Export or URL) if linkedin_file and linkedin_file.filename: try: - logger.debug(f"[{request.request_id}] Parsing LinkedIn data export") + logger.debug(f"[{rid}] Parsing LinkedIn data export") # Save file temporarily temp_filename = f"{uuid.uuid4()}_{linkedin_file.filename}" file_path = os.path.join(config.UPLOAD_DIR, temp_filename) @@ -459,7 +464,7 @@ def generate_resume(): linkedin_data = linkedin_scraper.parse_export(file_path) profile_data["linkedin"] = linkedin_data logger.debug( - f"[{request.request_id}] LinkedIn export parsed successfully" + f"[{rid}] LinkedIn export parsed successfully" ) # Cleanup @@ -467,23 +472,23 @@ def generate_resume(): os.remove(file_path) except Exception as e: logger.warning( - f"[{request.request_id}] LinkedIn export parsing failed: {e}" + f"[{rid}] LinkedIn export parsing failed: {e}" ) # Step 2: Analyze job description try: - logger.debug(f"[{request.request_id}] Analyzing job description") + logger.debug(f"[{rid}] Analyzing job description") job_requirements = job_analyzer.analyze(job_description) - logger.debug(f"[{request.request_id}] Job analysis completed") + logger.debug(f"[{rid}] Job analysis completed") except Exception as e: - logger.error(f"[{request.request_id}] Job analysis failed: {e}") + logger.error(f"[{rid}] Job analysis failed: {e}") raise ResumeGenerationError("Failed to analyze job description") # Step 3: Refine GitHub projects based on job requirements if "github" in profile_data and "repositories" in profile_data["github"]: try: logger.debug( - f"[{request.request_id}] Refining GitHub projects by relevance" + f"[{rid}] Refining GitHub projects by relevance" ) job_skills = job_requirements.get("skills", {}) relevant_projects = github_scraper.select_relevant_projects( @@ -491,36 +496,36 @@ def generate_resume(): ) profile_data["github"]["top_projects"] = relevant_projects logger.debug( - f"[{request.request_id}] Refined to {len(relevant_projects)} relevant projects" + f"[{rid}] Refined to {len(relevant_projects)} relevant projects" ) except Exception as e: logger.warning( - f"[{request.request_id}] GitHub project refinement failed: {e}" + f"[{rid}] GitHub project refinement failed: {e}" ) # Continue with default projects if refinement fails # Step 4: Generate tailored resume content try: - logger.debug(f"[{request.request_id}] Generating resume content") + logger.debug(f"[{rid}] Generating resume content") resume_content = resume_generator.generate(profile_data, job_requirements, user_id=user_id) - logger.debug(f"[{request.request_id}] Resume content generated") + logger.debug(f"[{rid}] Resume content generated") except Exception as e: logger.error( - f"[{request.request_id}] Resume generation failed: {e}", exc_info=True + f"[{rid}] Resume generation failed: {e}", exc_info=True ) raise ResumeGenerationError(f"Failed to generate resume: {str(e)}") # Step 4: Compile to PDF try: - logger.debug(f"[{request.request_id}] Compiling LaTeX to PDF") + logger.debug(f"[{rid}] Compiling LaTeX to PDF") pdf_path = latex_compiler.compile(resume_content) - logger.debug(f"[{request.request_id}] LaTeX compilation completed") + logger.debug(f"[{rid}] LaTeX compilation completed") except LaTeXCompilationError as e: - logger.error(f"[{request.request_id}] LaTeX compilation failed: {e}") + logger.error(f"[{rid}] LaTeX compilation failed: {e}") raise except Exception as e: logger.error( - f"[{request.request_id}] PDF compilation failed: {e}", exc_info=True + f"[{rid}] PDF compilation failed: {e}", exc_info=True ) raise LaTeXCompilationError("Failed to compile resume to PDF") @@ -528,36 +533,59 @@ def generate_resume(): abs_temp_dir = os.path.abspath(config.TEMP_DIR) abs_pdf_path = os.path.abspath(pdf_path) if not abs_pdf_path.startswith(abs_temp_dir): - logger.error(f"[{request.request_id}] Invalid file path: {abs_pdf_path}") + logger.error(f"[{rid}] Invalid file path: {abs_pdf_path}") raise ValueError("Invalid file path") # Verify file exists if not os.path.exists(abs_pdf_path): - logger.error(f"[{request.request_id}] PDF file not found: {abs_pdf_path}") + logger.error(f"[{rid}] PDF file not found: {abs_pdf_path}") raise ValueError("Generated PDF file not found") - # Step 6: Create job application record if user is logged in + # Step 6: Create resume and job application records if user is logged in if user_id: try: - from database.repositories import JobApplicationRepository + from database.repositories import ResumeRepository, JobApplicationRepository + + # Move PDF to a more permanent 'uploads/resumes' folder + resumes_dir = os.path.join("uploads", "resumes") + os.makedirs(resumes_dir, exist_ok=True) + + permanent_pdf_name = f"resume_{user_id}_{uuid.uuid4().hex[:8]}.pdf" + permanent_pdf_path = os.path.join(resumes_dir, permanent_pdf_name) + + import shutil + shutil.copy2(abs_pdf_path, permanent_pdf_path) + + # Create Resume record + resume_doc = ResumeRepository.create_resume( + user_id=user_id, + github_username=github_username, + job_title=resume_content.get("basics", {}).get("label", "Software Engineer"), + job_description=job_description, + tailored_content=resume_content, + pdf_path=permanent_pdf_path + ) + company = resume_content.get("basics", {}).get("company", "Target Company") job_title = resume_content.get("basics", {}).get("label", "Software Engineer") - job_url = request.form.get("job_url") if linkedin_file else data.get("job_url") + # Safe access to job_url based on content type + is_multipart = "multipart/form-data" in content_type + job_url = request.form.get("job_url") if is_multipart else (locals().get('data', {}).get("job_url")) JobApplicationRepository.create_application( user_id=user_id, job_title=job_title, company=company, - resume_id=str(getattr(resume_content, 'id', '')), # Or however we get it + resume_id=str(resume_doc.id), job_url=job_url, job_description=job_description ) - logger.debug(f"[{request.request_id}] Job application record created") + logger.debug(f"[{rid}] Resume and application records created") except Exception as e: - logger.error(f"[{request.request_id}] Failed to create job application record: {e}") + logger.error(f"[{rid}] Failed to create records: {e}", exc_info=True) - logger.info(f"[{request.request_id}] Resume generated successfully") + logger.info(f"[{rid}] Resume generated successfully") # Return the PDF file return send_file( @@ -571,7 +599,7 @@ def generate_resume(): # Re-raise application exceptions raise except Exception as e: - logger.error(f"[{request.request_id}] Unexpected error: {e}", exc_info=True) + logger.error(f"[{rid}] Unexpected error: {e}", exc_info=True) return ( jsonify( { @@ -589,8 +617,9 @@ def interview_prep(): """ Endpoint to generate interview preparation tips and questions """ + rid = getattr(request, "request_id", "unknown") try: - logger.info(f"[{request.request_id}] Interview prep request") + logger.info(f"[{rid}] Interview prep request") data = request.json if not data: raise ValidationError("Request body is required") @@ -611,7 +640,7 @@ def interview_prep(): except ValidationError as e: return jsonify({"error": "VALIDATION_ERROR", "message": str(e)}), 400 except Exception as e: - logger.error(f"Interview prep generation failed: {e}", exc_info=True) + logger.error(f"[{rid}] Interview prep generation failed: {e}", exc_info=True) return ( jsonify( { @@ -703,6 +732,7 @@ def get_user_resumes(user_id): return jsonify([{ "id": str(r.id), "job_title": r.job_title, + "job_description": r.job_description or "", "github_username": r.github_username, "created_at": r.created_at.isoformat(), "is_archived": r.is_archived @@ -723,7 +753,8 @@ def get_user_applications(user_id): "job_title": a.job_title, "company": a.company, "status": a.status, - "applied_date": a.applied_date.isoformat() + "applied_date": a.applied_date.isoformat(), + "job_description": a.job_description or "" } for a in applications]) except Exception as e: logger.error(f"Failed to fetch user applications: {e}", exc_info=True) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 79a442d..8ea62a1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,7 +6,7 @@ import LoginForm from './components/LoginForm'; import RegisterForm from './components/RegisterForm'; import Dashboard from './components/Dashboard'; import { motion } from 'framer-motion'; -import { Rocket } from 'lucide-react'; +import { Rocket, Sparkles, Shield, Zap } from 'lucide-react'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated } = useAuth(); @@ -15,31 +15,67 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { function Home() { return ( -
- +
+
+ {/* Decorative Background Elements */} +
+ - + + + Next-Gen Career Intelligence + + +

+ Optimize.
+ Apply.
+ Succeed. +

+ +

+ Mployable uses advanced AI to bridge the gap between your technical profile and your dream job. Tailored resumes that bypass ATS and impress recruiters. +

+ +
+
+ + ATS Optimized +
+
+ + Instant Generation +
+
+ + Gemini 2.0 Powered +
+
-

- Mployable. -

-

- AI-powered resume tailoring that actually works. Leverage your GitHub & LinkedIn to match any job description. -

- - - +
+ +
+
+
+ +
+
-

- Powered by Google Gemini • Built for The Future of Work -

+
+

+ + The Future of Engineering Applications + +

+
); } @@ -48,9 +84,9 @@ function App() { return ( -
+
-
+
} /> } /> diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index ed6c0ba..56c3dd1 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,14 +1,21 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useOptimistic, Activity } from 'react'; import { useAuth } from '../context/AuthContext'; -import { FileText, Briefcase, Calendar, Download, Loader2, AlertCircle, Trash2, History, ExternalLink, Eye, X } from 'lucide-react'; +import { + FileText, Briefcase, Calendar, Download, Loader2, + AlertCircle, Trash2, History, ExternalLink, Eye, X, + Plus, Search, Filter, TrendingUp, Clock, ChevronRight, + Github +} from 'lucide-react'; import axios from 'axios'; import { motion, AnimatePresence } from 'framer-motion'; +import { Link } from 'react-router-dom'; const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5001'; interface Resume { id: string; job_title: string; + job_description: string; github_username: string; created_at: string; is_archived: boolean; @@ -20,8 +27,15 @@ interface Application { company: string; status: string; applied_date: string; + job_description: string; } +type JobDetailView = { + job_title: string; + job_description: string; + company?: string; +}; + export default function Dashboard() { const { user } = useAuth(); const [resumes, setResumes] = useState([]); @@ -31,6 +45,14 @@ export default function Dashboard() { const [activeTab, setActiveTab] = useState<'resumes' | 'applications'>('resumes'); const [previewResumeId, setPreviewResumeId] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); + const [viewingJobDetails, setViewingJobDetails] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + + // useOptimistic for immediate UI updates when archiving/deleting + const [optimisticResumes, removeOptimisticResume] = useOptimistic( + resumes, + (state, resumeId: string) => state.filter(r => r.id !== resumeId) + ); const handleDownload = async (resumeId: string, jobTitle: string) => { try { @@ -49,7 +71,6 @@ export default function Dashboard() { window.URL.revokeObjectURL(url); } catch (err: any) { console.error('Download error:', err); - alert('Failed to download resume. Please try again.'); } }; @@ -65,7 +86,6 @@ export default function Dashboard() { setPreviewUrl(url); } catch (err: any) { console.error('Preview error:', err); - alert('Failed to load preview. Please try again.'); setPreviewResumeId(null); } }; @@ -92,7 +112,7 @@ export default function Dashboard() { setResumes(resumesRes.data); setApplications(appsRes.data); } catch (err: any) { - setError('Failed to load dashboard data. Please try again later.'); + setError('Failed to load your career data. Please check your connection.'); console.error('Dashboard fetch error:', err); } finally { setLoading(false); @@ -104,192 +124,323 @@ export default function Dashboard() { if (loading) { return ( -
- -

Loading your career overview...

+
+ + + +
+

Assembling your workspace

+

This will only take a moment...

+
); } + const filteredResumes = optimisticResumes.filter(r => + (r.job_title || '').toLowerCase().includes(searchQuery.toLowerCase()) + ); + return ( -
-
-

Welcome, {user?.username}

-

Manage your tailored resumes and track your job applications

-
+
+ {/* Welcome Section */} +
+
+

+ Workspace. +

+

+ Welcome back, {user?.username} +

+
+ + + Create New Resume + +
{error && ( -
- + +

{error}

-
+ )} - {/* Stats Summary */} -
-
-
- - Total Resumes + {/* Stats Dashboard */} +
+
+
+
+ +
+ Resumes +
+
+

{resumes.length}

+
-

{resumes.length}

-
-
- - Applications + +
+
+
+ +
+ Applications +
+
+

{applications.length}

+
LIVE
-

{applications.length}

-
-

Keep applying to increase your chances!

+ +
+
+

Career Insights

+

You've generated {resumes.length} tailored resumes this month. Keep up the momentum!

+
+
- {/* Tabs */} -
- - + {/* Control Bar */} +
+
+ + +
+ +
+ + setSearchQuery(e.target.value)} + className="w-full pl-11 pr-4 py-2.5 bg-muted border-none rounded-2xl text-sm font-medium focus:ring-2 focus:ring-primary/20 transition-smooth" + /> +
- {/* Content */} - - {activeTab === 'resumes' ? ( - resumes.length > 0 ? ( - resumes.map(resume => ( -
-
-
- + {/* Content Area with React 19.2 */} +
+ {/* Resumes Tab */} + + + {filteredResumes.length > 0 ? ( + filteredResumes.map(resume => ( + +
+
- - {new Date(resume.created_at).toLocaleDateString()} - + +
+ + + {new Date(resume.created_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })} + +
+ +

+ {resume.job_title || 'Tailored Resume'} +

+ +
+ + {resume.github_username} +
+ +
+ +
+ + +
+
+
+ )) + ) : ( +
+
+
-

- {resume.job_title || 'Tailored Resume'} -

-

- - GitHub: {resume.github_username} +

Ready to launch?

+

+ {searchQuery ? `No resumes matching "${searchQuery}"` : "You haven't generated any tailored resumes yet. Let's build something great."}

-
- - - -
+ {!searchQuery && ( + + Generate now + + )}
- )) - ) : ( -
- -

No resumes yet

-

Generate your first tailored resume using the home page!

-
- ) - ) : ( - applications.length > 0 ? ( - applications.map(app => ( -
-
-
- + )} + + + + {/* Applications Tab */} + + + {applications.length > 0 ? ( + applications.map(app => ( +
+
+ + + {app.status} + +
+

{app.job_title}

+

{app.company}

+ +
+
+ + {new Date(app.applied_date).toLocaleDateString()} +
+
- - {app.status} -
-

{app.job_title}

-

{app.company}

-
- - Applied on {new Date(app.applied_date).toLocaleDateString()} + )) + ) : ( +
+
+
+

Track your journey

+

Keep track of every application and increase your chances of success.

- )) - ) : ( -
- -

No applications tracked

-

Keep track of your job search progress here.

-
- ) - )} - + )} + + +
- {/* Preview Modal */} + {/* Modern Preview Modal */} {previewUrl && ( -
+
- +
+
+

+ + Live Preview +

+
+ +
-
-