From 3571d5e23bd7c781f253d5144a908bd3a2994a27 Mon Sep 17 00:00:00 2001 From: Hrishikesh Uchake Date: Sat, 24 Jan 2026 23:00:46 -0600 Subject: [PATCH 1/2] Migrate to google-genai SDK and fix missing logger in app.py --- analyzer/interview_generator.py | 22 +- analyzer/job_analyzer.py | 20 +- app.py | 200 ++++- app_production.py | 138 +++- database/mongodb.py | 17 +- database/repositories.py | 2 + frontend/frontend/src/context/AuthContext.tsx | 0 frontend/package-lock.json | 60 +- frontend/package.json | 3 +- frontend/src/App.tsx | 708 ++---------------- frontend/src/components/Dashboard.tsx | 318 ++++++++ frontend/src/components/InterviewPrepPlan.tsx | 183 +++++ frontend/src/components/LoadingProgress.tsx | 20 + frontend/src/components/LoginForm.tsx | 113 +++ frontend/src/components/Navbar.tsx | 58 ++ frontend/src/components/QuestionCard.tsx | 61 ++ frontend/src/components/RegisterForm.tsx | 173 +++++ frontend/src/components/ResumeForm.tsx | 345 +++++++++ frontend/src/context/AuthContext.tsx | 55 ++ generator/resume_generator.py | 49 +- requirements.txt | 21 +- scrapers/github_scraper.py | 14 + 22 files changed, 1882 insertions(+), 698 deletions(-) create mode 100644 frontend/frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/components/Dashboard.tsx create mode 100644 frontend/src/components/InterviewPrepPlan.tsx create mode 100644 frontend/src/components/LoadingProgress.tsx create mode 100644 frontend/src/components/LoginForm.tsx create mode 100644 frontend/src/components/Navbar.tsx create mode 100644 frontend/src/components/QuestionCard.tsx create mode 100644 frontend/src/components/RegisterForm.tsx create mode 100644 frontend/src/components/ResumeForm.tsx create mode 100644 frontend/src/context/AuthContext.tsx diff --git a/analyzer/interview_generator.py b/analyzer/interview_generator.py index 76197b3..6314e43 100644 --- a/analyzer/interview_generator.py +++ b/analyzer/interview_generator.py @@ -5,7 +5,7 @@ tailored interview preparation tips and sample questions. """ -import google.generativeai as genai +from google import genai import os import json from typing import Dict, List @@ -15,17 +15,18 @@ class InterviewGenerator: def __init__(self): """Initialize interview generator with Gemini API""" - config = get_config() + self.config = get_config() api_key = os.getenv("GEMINI_API_KEY") if not api_key: print("Warning: GEMINI_API_KEY not found in environment variables") - self.model = None + self.client = None else: - genai.configure(api_key=api_key) try: - self.model = genai.GenerativeModel(config.GEMINI_MODEL) + self.client = genai.Client(api_key=api_key) + self.model_name = self.config.GEMINI_MODEL except Exception: - self.model = genai.GenerativeModel("gemini-pro") + self.client = genai.Client(api_key=api_key) + self.model_name = "gemini-2.0-flash" def generate(self, job_requirements: Dict) -> Dict: """ @@ -37,8 +38,8 @@ def generate(self, job_requirements: Dict) -> Dict: Returns: Dictionary containing structured interview prep content """ - if not self.model: - print("InterviewGenerator: No model initialized, using basic prep") + if not self.client: + print("InterviewGenerator: No client initialized, using basic prep") return self._generate_basic_prep(job_requirements) try: @@ -47,7 +48,10 @@ def generate(self, job_requirements: Dict) -> Dict: print(f"InterviewGenerator: Generating content with Gemini...") # Generate content using Gemini - response = self.model.generate_content(prompt) + response = self.client.models.generate_content( + model=self.model_name, + contents=prompt + ) if not response or not response.text: print("InterviewGenerator: Empty response from Gemini") diff --git a/analyzer/job_analyzer.py b/analyzer/job_analyzer.py index 2f29c35..559efa5 100644 --- a/analyzer/job_analyzer.py +++ b/analyzer/job_analyzer.py @@ -11,6 +11,8 @@ import re from typing import Dict, List, Set +from database.repositories import CacheRepository + class JobAnalyzer: def __init__(self): @@ -83,6 +85,14 @@ def analyze(self, job_description: str) -> Dict: Returns: Dictionary containing analyzed requirements """ + # Check cache first + try: + cached_result = CacheRepository.get_cached_job_analysis(job_description) + if cached_result: + return cached_result + except Exception: + pass # Fallback to re-analyzing if cache fails + job_desc_lower = job_description.lower() # Extract skills @@ -97,7 +107,7 @@ def analyze(self, job_description: str) -> Dict: # Identify key sections sections = self._identify_sections(job_description) - return { + result = { "original_description": job_description, "skills": skills, "experience_required": experience, @@ -106,6 +116,14 @@ def analyze(self, job_description: str) -> Dict: "keywords": self._extract_keywords(job_desc_lower), } + # Cache the result (using default 48 hours TTL) + try: + CacheRepository.cache_job_analysis(job_description, result) + except Exception: + pass + + return result + def _extract_skills(self, job_desc_lower: str) -> Dict[str, List[str]]: """Extract technical skills from job description""" found_skills = {} diff --git a/app.py b/app.py index df5eb26..c9dcc73 100644 --- a/app.py +++ b/app.py @@ -13,12 +13,15 @@ from dotenv import load_dotenv # Import our modules +from database import init_db +from database.repositories import UserRepository, ResumeRepository, JobApplicationRepository from scrapers.github_scraper import GitHubScraper from scrapers.linkedin_scraper import LinkedInScraper from analyzer.job_analyzer import JobAnalyzer from analyzer.interview_generator import InterviewGenerator from generator.resume_generator import ResumeGenerator from generator.latex_compiler import LaTeXCompiler +from config.logging_config import setup_logging # Load environment variables load_dotenv() @@ -26,6 +29,12 @@ app = Flask(__name__) CORS(app) +# Setup logging +logger = setup_logging(__name__) + +# Initialize database +init_db() + # Initialize components github_scraper = GitHubScraper() linkedin_scraper = LinkedInScraper() @@ -264,14 +273,17 @@ def generate_resume(): """ try: linkedin_file = None + user_id = None if request.content_type and "multipart/form-data" in request.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") + user_id = request.form.get("user_id") else: data = request.json github_username = data.get("github_username", "").strip() job_description = data.get("job_description", "").strip() + user_id = data.get("user_id") if not job_description: return jsonify({"error": "Job description is required"}), 400 @@ -317,11 +329,50 @@ def generate_resume(): profile_data["github"]["top_projects"] = relevant_projects # Step 4: Generate tailored resume content - resume_content = resume_generator.generate(profile_data, job_requirements) + resume_content = resume_generator.generate(profile_data, job_requirements, user_id=user_id) - # Step 4: Compile to PDF + # Step 5: Compile 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 + if user_id: + try: + # 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(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 + ) + + # 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) + + JobApplicationRepository.create_application( + user_id=user_id, + job_title=resume_content.get("basics", {}).get("label", "Software Engineer"), + company=company, + resume_id=str(resume_doc.id), + job_url=job_url, + job_description=job_description + ) + logger.info(f"Stored resume and application for user {user_id}") + except Exception as e: + logger.error(f"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") abs_pdf_path = os.path.abspath(pdf_path) @@ -332,8 +383,7 @@ def generate_resume(): return send_file( pdf_path, mimetype="application/pdf", - as_attachment=True, - download_name="tailored_resume.pdf", + as_attachment=False, ) except Exception as e: @@ -365,7 +415,147 @@ def interview_prep(): return jsonify({"error": str(e)}), 500 -@app.route("/health") +# --- Authentication & User Endpoints --- + +@app.route("/api/auth/register", methods=["POST"]) +@app.route("/api/v1/auth/register", methods=["POST"]) +def register(): + """Register a new user""" + try: + data = request.json + email = data.get("email") + username = data.get("username") + password = data.get("password") + first_name = data.get("first_name", "") + last_name = data.get("last_name", "") + + if not email or not username or not password: + return jsonify({"error": "Email, username, and password are required"}), 400 + + if UserRepository.get_user_by_email(email): + return jsonify({"error": "Email already registered"}), 400 + + if UserRepository.get_user_by_username(username): + return jsonify({"error": "Username already taken"}), 400 + + user = UserRepository.create_user(email, username, password, first_name, last_name) + return jsonify({ + "message": "User registered successfully", + "user": { + "id": str(user.id), + "username": user.username, + "email": user.email + } + }), 201 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/auth/login", methods=["POST"]) +@app.route("/api/v1/auth/login", methods=["POST"]) +def login(): + """Authenticate user""" + try: + data = request.json + username = data.get("username") + password = data.get("password") + + if not username or not password: + return jsonify({"error": "Username and password are required"}), 400 + + user = UserRepository.authenticate(username, password) + if not user: + return jsonify({"error": "Invalid username or password"}), 401 + + return jsonify({ + "message": "Login successful", + "user": { + "id": str(user.id), + "username": user.username, + "email": user.email + } + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/user/resumes/", methods=["GET"]) +@app.route("/api/v1/user/resumes/", methods=["GET"]) +def get_user_resumes(user_id): + """Get all resumes for a user""" + try: + resumes = ResumeRepository.get_user_resumes(user_id) + return jsonify([{ + "id": str(r.id), + "job_title": r.job_title, + "github_username": r.github_username, + "created_at": r.created_at.isoformat(), + "is_archived": r.is_archived + } for r in resumes]) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/v1/user/resumes/download/", methods=["GET"]) +def download_resume(resume_id): + """Download a previously generated resume PDF""" + try: + resume = ResumeRepository.get_resume(resume_id) + if not resume or not resume.pdf_path: + return jsonify({"error": "Resume not found"}), 404 + + if not os.path.exists(resume.pdf_path): + return jsonify({"error": "PDF file not found on server"}), 404 + + return send_file( + resume.pdf_path, + mimetype="application/pdf", + as_attachment=True, + download_name=f"resume_{resume.job_title.replace(' ', '_')}.pdf" + ) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/v1/user/resumes/preview/", methods=["GET"]) +def preview_resume(resume_id): + """Preview a previously generated resume PDF inline""" + try: + resume = ResumeRepository.get_resume(resume_id) + if not resume or not resume.pdf_path: + return jsonify({"error": "Resume not found"}), 404 + + if not os.path.exists(resume.pdf_path): + return jsonify({"error": "PDF file not found on server"}), 404 + + return send_file( + resume.pdf_path, + mimetype="application/pdf", + as_attachment=False + ) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/user/applications/", methods=["GET"]) +@app.route("/api/v1/user/applications/", methods=["GET"]) +def get_user_applications(user_id): + """Get all job applications for a user""" + try: + applications = JobApplicationRepository.get_user_applications(user_id) + return jsonify([{ + "id": str(a.id), + "job_title": a.job_title, + "company": a.company, + "status": a.status, + "applied_date": a.applied_date.isoformat() + } for a in applications]) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/health", methods=["GET"]) +@app.route("/api/v1/health", methods=["GET"]) def health(): """Health check endpoint""" return jsonify({"status": "healthy"}) diff --git a/app_production.py b/app_production.py index cdc5602..c2b92cc 100644 --- a/app_production.py +++ b/app_production.py @@ -39,6 +39,9 @@ ) from utils.validators import InputValidator +# Import database +from database import init_db + # Import our modules from scrapers.github_scraper import GitHubScraper from scrapers.linkedin_scraper import LinkedInScraper @@ -54,6 +57,9 @@ # Create Flask app app = Flask(__name__) +# Initialize database +init_db() + # Configure CORS with security cors_config = { "origins": config.CORS_ORIGINS, @@ -385,11 +391,13 @@ def generate_resume(): try: # Parse and validate request linkedin_file = None + user_id = None if request.content_type and "multipart/form-data" in request.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") + user_id = request.form.get("user_id") else: data = request.get_json(force=True) if not request.json else request.json if not data: @@ -397,6 +405,7 @@ def generate_resume(): github_username = data.get("github_username", "").strip() job_description = data.get("job_description", "").strip() + user_id = data.get("user_id") logger.info(f"[{request.request_id}] Resume generation request") @@ -493,7 +502,7 @@ def generate_resume(): # Step 4: Generate tailored resume content try: logger.debug(f"[{request.request_id}] Generating resume content") - resume_content = resume_generator.generate(profile_data, job_requirements) + resume_content = resume_generator.generate(profile_data, job_requirements, user_id=user_id) logger.debug(f"[{request.request_id}] Resume content generated") except Exception as e: logger.error( @@ -527,6 +536,27 @@ def generate_resume(): logger.error(f"[{request.request_id}] 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 + if user_id: + try: + from database.repositories import JobApplicationRepository + 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") + + 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 + job_url=job_url, + job_description=job_description + ) + logger.debug(f"[{request.request_id}] Job application record created") + except Exception as e: + logger.error(f"[{request.request_id}] Failed to create job application record: {e}") + logger.info(f"[{request.request_id}] Resume generated successfully") # Return the PDF file @@ -594,6 +624,112 @@ def interview_prep(): ) +@app.route("/api/v1/auth/register", methods=["POST"]) +def register(): + """Register a new user""" + try: + from database.repositories import UserRepository + data = request.json + if not data: + return jsonify({"error": "VALIDATION_ERROR", "message": "Request body is required"}), 400 + + email = data.get("email") + username = data.get("username") + password = data.get("password") + first_name = data.get("first_name", "") + last_name = data.get("last_name", "") + + if not email or not username or not password: + return jsonify({"error": "VALIDATION_ERROR", "message": "Email, username, and password are required"}), 400 + + if UserRepository.get_user_by_email(email): + return jsonify({"error": "CONFLICT", "message": "Email already registered"}), 409 + + if UserRepository.get_user_by_username(username): + return jsonify({"error": "CONFLICT", "message": "Username already taken"}), 409 + + user = UserRepository.create_user(email, username, password, first_name, last_name) + return jsonify({ + "message": "User registered successfully", + "user": { + "id": str(user.id), + "username": user.username, + "email": user.email + } + }), 201 + except Exception as e: + logger.error(f"Registration failed: {e}", exc_info=True) + return jsonify({"error": "INTERNAL_ERROR", "message": str(e)}), 500 + + +@app.route("/api/v1/auth/login", methods=["POST"]) +def login(): + """Authenticate user""" + try: + from database.repositories import UserRepository + data = request.json + if not data: + return jsonify({"error": "VALIDATION_ERROR", "message": "Request body is required"}), 400 + + username = data.get("username") + password = data.get("password") + + if not username or not password: + return jsonify({"error": "VALIDATION_ERROR", "message": "Username and password are required"}), 400 + + user = UserRepository.authenticate(username, password) + if not user: + return jsonify({"error": "UNAUTHORIZED", "message": "Invalid username or password"}), 401 + + return jsonify({ + "message": "Login successful", + "user": { + "id": str(user.id), + "username": user.username, + "email": user.email + } + }) + except Exception as e: + logger.error(f"Login failed: {e}", exc_info=True) + return jsonify({"error": "INTERNAL_ERROR", "message": str(e)}), 500 + + +@app.route("/api/v1/user/resumes/", methods=["GET"]) +def get_user_resumes(user_id): + """Get all resumes for a user""" + try: + from database.repositories import ResumeRepository + resumes = ResumeRepository.get_user_resumes(user_id) + return jsonify([{ + "id": str(r.id), + "job_title": r.job_title, + "github_username": r.github_username, + "created_at": r.created_at.isoformat(), + "is_archived": r.is_archived + } for r in resumes]) + except Exception as e: + logger.error(f"Failed to fetch user resumes: {e}", exc_info=True) + return jsonify({"error": "INTERNAL_ERROR", "message": str(e)}), 500 + + +@app.route("/api/v1/user/applications/", methods=["GET"]) +def get_user_applications(user_id): + """Get all job applications for a user""" + try: + from database.repositories import JobApplicationRepository + applications = JobApplicationRepository.get_user_applications(user_id) + return jsonify([{ + "id": str(a.id), + "job_title": a.job_title, + "company": a.company, + "status": a.status, + "applied_date": a.applied_date.isoformat() + } for a in applications]) + except Exception as e: + logger.error(f"Failed to fetch user applications: {e}", exc_info=True) + return jsonify({"error": "INTERNAL_ERROR", "message": str(e)}), 500 + + @app.route("/health", methods=["GET"]) def health(): """Health check endpoint""" diff --git a/database/mongodb.py b/database/mongodb.py index 848a122..e0f269b 100644 --- a/database/mongodb.py +++ b/database/mongodb.py @@ -48,14 +48,16 @@ def connect(self): try: logger.info( - f"Connecting to MongoDB at {config.DATABASE_HOST}:{config.DATABASE_PORT}" + f"Connecting to MongoDB..." ) # Build connection string - if config.DATABASE_USERNAME and config.DATABASE_PASSWORD: + if config.DATABASE_URL and (config.DATABASE_URL.startswith("mongodb://") or config.DATABASE_URL.startswith("mongodb+srv://")): + connection_string = config.DATABASE_URL + elif config.DATABASE_USERNAME and config.DATABASE_PASSWORD: connection_string = ( f"mongodb://{config.DATABASE_USERNAME}:{config.DATABASE_PASSWORD}" - f"@{config.DATABASE_HOST}:{config.DATABASE_PORT}/{config.DATABASE_NAME}" + f"@{config.DATABASE_HOST}:{config.DATABASE_PORT}/{config.DATABASE_NAME}?authSource=admin" ) else: connection_string = f"mongodb://{config.DATABASE_HOST}:{config.DATABASE_PORT}/{config.DATABASE_NAME}" @@ -216,7 +218,7 @@ class User(Document): "indexes": [ "email", "username", - ("created_at", "-1"), + "-created_at", ], } @@ -270,7 +272,7 @@ class JobCache(Document): meta = { "collection": "job_cache", "indexes": [ - ("expires_at", 1), # MongoDB will auto-delete after this date + {"fields": ["expires_at"], "expireAfterSeconds": 0}, ], } @@ -286,7 +288,7 @@ class GitHubProfileCache(Document): meta = { "collection": "github_cache", "indexes": [ - ("expires_at", 1), # MongoDB will auto-delete after this date + {"fields": ["expires_at"], "expireAfterSeconds": 0}, ], } @@ -310,8 +312,7 @@ class AuditLog(Document): "indexes": [ "user_id", "action", - "created_at", - ("created_at", -1), + "-created_at", ], } diff --git a/database/repositories.py b/database/repositories.py index c6478fd..a27fdbb 100644 --- a/database/repositories.py +++ b/database/repositories.py @@ -86,6 +86,7 @@ def create_resume( job_title: str, job_description: str, tailored_content: Dict, + pdf_path: str = None, ) -> Resume: """Create a new resume""" resume = Resume( @@ -94,6 +95,7 @@ def create_resume( job_title=job_title, job_description=job_description, tailored_content=tailored_content, + pdf_path=pdf_path, ) resume.save() logger.info(f"Resume created for user {user_id}: {resume.id}") diff --git a/frontend/frontend/src/context/AuthContext.tsx b/frontend/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2d6e87b..38b295d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,7 +13,8 @@ "framer-motion": "^12.29.0", "lucide-react": "^0.563.0", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -2238,6 +2239,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3733,6 +3747,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3803,6 +3855,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 426349b..35c4ccb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,8 @@ "framer-motion": "^12.29.0", "lucide-react": "^0.563.0", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 83b4aeb..79a442d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,664 +1,76 @@ -import { useState, useActionState, useTransition, useCallback, useRef, useEffect } from 'react' -import { Rocket, Github, Linkedin, Briefcase, Download, Loader2, AlertCircle, CheckCircle2, Info, Lightbulb, MessageSquare, Brain, Trophy, ChevronRight, Copy, Check, ChevronDown, ChevronUp } from 'lucide-react' -import { motion, AnimatePresence } from 'framer-motion' -import axios from 'axios' - -// API Base URL - adjust if backend is on different port -const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5001' - -interface ActionState { - type: 'idle' | 'loading' | 'success' | 'error' - message: string - downloadUrl?: string - interviewPrep?: { - tips: string[]; - technical_questions: { question: string; context: string }[]; - behavioral_questions: { question: string; context: string }[]; - situational_questions: { question: string; context: string }[]; - winning_strategy: string; - } -} - -async function generateResumeAction(prevState: ActionState, formData: FormData): Promise { - const github = formData.get('github_username') as string - const linkedin = formData.get('linkedin_data') as File - const jobDescription = formData.get('job_description') as string - - if (!github && (!linkedin || linkedin.size === 0)) { - return { type: 'error', message: 'Please provide at least one profile (GitHub username or LinkedIn Data Export)' } - } - - if (!jobDescription) { - return { type: 'error', message: 'Please provide a job description' } - } - - try { - // Stage 1: Generate Resume (Main task) - let response; - try { - response = await axios.post(`${API_BASE_URL}/api/v1/generate-resume`, formData, { - responseType: 'blob', - headers: { 'Content-Type': 'multipart/form-data' }, - timeout: 60000 // 60s timeout - }) - } catch (e: any) { - if (e.response?.status === 404) { - response = await axios.post(`${API_BASE_URL}/api/generate-resume`, formData, { - responseType: 'blob', - headers: { 'Content-Type': 'multipart/form-data' }, - timeout: 60000 - }) - } else { - throw e; - } - } - - const blob = new Blob([response.data], { type: 'application/pdf' }) - const url = window.URL.createObjectURL(blob) - - // Auto-download logic - const link = document.createElement('a') - link.href = url - link.setAttribute('download', 'tailored_resume.pdf') - document.body.appendChild(link) - link.click() - link.parentNode?.removeChild(link) - - // Stage 2: Fetch Interview Prep (Sequential to avoid race conditions/timeouts) - let interviewPrep = null; - try { - console.log('Fetching interview prep...'); - let prepRes; - try { - prepRes = await axios.post(`${API_BASE_URL}/api/v1/interview-prep`, - { job_description: jobDescription }, - { timeout: 35000 } - ); - } catch (e: any) { - console.warn('V1 interview prep failed, trying fallback...', e.message); - // Fallback if 404 OR if it's a network/CORS error (which might happen if preflight fails on V1) - if (!e.response || e.response.status === 404) { - prepRes = await axios.post(`${API_BASE_URL}/api/interview-prep`, - { job_description: jobDescription }, - { timeout: 35000 } - ); - } else { - throw e; - } - } - interviewPrep = prepRes.data; - console.log('Interview prep loaded successfully'); - } catch (err: any) { - console.error('Interview prep fetch failed:', err.message || err); - // We don't throw here so the user still sees the success for the resume - } - - return { - type: 'success', - message: 'Resume generated! Your download should start automatically.', - downloadUrl: url, - interviewPrep - } - } catch (error: any) { - console.error('Error generating resume:', error) - - let errorMessage = 'Failed to generate resume. Please try again.' - - if (error.response?.data instanceof Blob) { - // Convert Blob error back to JSON - const text = await error.response.data.text() - try { - const json = JSON.parse(text) - errorMessage = json.error || errorMessage - } catch (e) { - errorMessage = text || errorMessage - } - } else if (error.response?.data?.error) { - errorMessage = error.response.data.error - } else if (error.message) { - errorMessage = error.message - } - - return { type: 'error', message: errorMessage } - } -} - -function QuestionCard({ question, context, index }: { question: string; context: string; index: number }) { - const [isOpen, setIsOpen] = useState(false) - - return ( - setIsOpen(!isOpen)} - > -
-
- - {index + 1} - -

- {question} -

-
-
- {isOpen ? ( - - ) : ( - - )} -
-
- - - {isOpen && ( - -
-
-
- -
-
-

Coach Context

-

- {context} -

-
-
-
-
- )} -
-
- ) +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider, useAuth } from './context/AuthContext'; +import Navbar from './components/Navbar'; +import ResumeForm from './components/ResumeForm'; +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'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated } = useAuth(); + return isAuthenticated ? <>{children} : ; } -function App() { - const [state, formAction, isPending] = useActionState(generateResumeAction, { - type: 'idle', - message: '', - }) - - // Local state for UI feedback - const [fileName, setFileName] = useState('') - - // Dynamic loading message - const [loadingStep, setLoadingStep] = useState(0) - - useEffect(() => { - if (isPending) { - const interval = setInterval(() => { - setLoadingStep(s => (s + 1) % 4) - }, 3000) - return () => clearInterval(interval) - } else { - setLoadingStep(0) - } - }, [isPending]) - - const loadingMessages = [ - "Analyzing your technical DNA...", - "Matching professional path to job requirements...", - "Scanning project impact and metrics...", - "Preparing your personalized interview coach..." - ] - +function Home() { return ( -
-
- {/* Header */} +
+ - - - -

- Mployable. -

-

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

+
- -
- {/* Main Form Area */} - -
-
-
-
- {/* GitHub Input */} -
- - -
- - {/* LinkedIn File Input */} -
- -
- setFileName(e.target.files?.[0]?.name || '')} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" - /> -
- - {fileName || 'Upload .zip export'} - - -
-
-
-
- - {/* Job Description */} -
- -