first commit

This commit is contained in:
Anurag Jangid 2026-10-08 02:50:35 +05:30
commit 7a72438fd1
74 changed files with 7548 additions and 0 deletions

298
README.md Normal file
View file

@ -0,0 +1,298 @@
# HireAI
HireAI is an AI-powered hiring workspace for preparing and managing technical
interviews. Recruiters can sign in with Google, define a role, upload and parse
candidate resumes, generate tailored interview questions with Google Gemini,
schedule interviews, and look up the pronunciation of technical terms.
The repository contains:
- **Frontend:** Vue 3 and Vite single-page application.
- **Backend:** FastAPI application with asynchronous MongoDB access.
- **File storage:** MongoDB GridFS for original resume files.
- **Authentication:** Google OAuth 2.0 with stateless JWT sessions.
- **AI features:** Google Gemini for interview-question and pronunciation
generation.
## Features
- Google sign-in and protected application routes.
- Resume upload for PDF and DOC/DOCX files up to 10 MB.
- Resume text and metadata extraction using PyMuPDF and `python-docx`.
- Job/interview requirement management, including mandatory and nice-to-have
skills.
- AI-generated interview questions based on a resume, role, skills, experience,
and projects.
- Interview scheduling with optional meeting links and email notifications.
- Technical-term pronunciation search, cached in MongoDB, plus a word of the
day.
- Swagger/OpenAPI documentation at `/docs` and ReDoc at `/redoc`.
## Technology stack
### Frontend
- Vue 3
- Vue Router 4
- Axios
- Vite
### Backend
- Python 3.10+ (Python 3.13 is supported by the current development setup)
- FastAPI
- Uvicorn
- Motor/PyMongo
- Pydantic Settings
- PyMuPDF
- `python-docx`
- `python-jose`
- `fastapi-mail`
- Google GenAI SDK
### Services
- MongoDB (required)
- Google Cloud OAuth 2.0 credentials (required for sign-in)
- Google Gemini API key (required for AI generation)
- SMTP server (required only for interview email delivery)
## Repository structure
```text
.
├── backend/
│ ├── app/
│ │ ├── models/ # Pydantic request/response models
│ │ ├── routers/ # Authentication and feature API routes
│ │ └── services/ # Email, Gemini, and resume parsing services
│ ├── main.py # FastAPI application entry point
│ └── requirements.txt
├── frontend/
│ ├── public/ # Static assets and favicon
│ ├── src/
│ │ ├── api/ # Axios client
│ │ ├── components/ # Shared navigation and footer
│ │ ├── composables/ # Shared auth and toast state
│ │ ├── router/ # Vue Router and auth guard
│ │ └── views/ # Application screens
│ ├── package.json
│ └── vite.config.js
└── README.md
```
## Prerequisites
Install the following before starting:
1. Python 3.10 or newer.
2. Node.js 18 or newer and npm.
3. A running MongoDB instance, either local or MongoDB Atlas.
4. A Google Cloud OAuth client configured as a **Web application**.
5. A Google Gemini API key.
6. An SMTP account if scheduled interview emails should be sent.
## Configuration
### Backend environment variables
Create `backend/.env` with the following values. The backend loads this file
through `pydantic-settings`; do not commit it.
```dotenv
# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
# JWT
JWT_SECRET=replace-with-a-long-random-secret
JWT_ALGORITHM=HS256
JWT_EXPIRE_HOURS=168
# MongoDB
MONGODB_URI=mongodb://localhost:27017
MONGODB_DB=hireai
# Google Gemini
GEMINI_API_KEY=your-gemini-api-key
# Application URLs
FRONTEND_URL=http://localhost:5173
BACKEND_URL=http://localhost:8000
# SMTP (optional during local development)
MAIL_USERNAME=your-smtp-username
MAIL_PASSWORD=your-smtp-password
MAIL_FROM=your-sender@example.com
MAIL_SERVER=smtp.gmail.com
MAIL_PORT=587
MAIL_TLS=true
MAIL_SSL=false
```
For local Google OAuth development, add this authorized redirect URI to the
Google Cloud OAuth client:
```text
http://localhost:8000/api/auth/google/callback
```
The frontend currently uses `http://localhost:8000` as its API origin. If the
backend runs elsewhere, update the base URL in
[`frontend/src/api/index.js`](frontend/src/api/index.js) and the Google login
redirect in [`frontend/src/composables/useAuth.js`](frontend/src/composables/useAuth.js).
## Local development
### 1. Start MongoDB
Start MongoDB locally or provide a MongoDB Atlas connection string through
`MONGODB_URI`. The backend verifies the connection during application startup.
### 2. Install and start the backend
From PowerShell:
```powershell
cd backend
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
pip install -r requirements.txt
python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
The API is available at <http://localhost:8000>. Verify it with:
- Health check: <http://localhost:8000/health>
- Swagger UI: <http://localhost:8000/docs>
- ReDoc: <http://localhost:8000/redoc>
If PowerShell execution policy prevents activation, run the Uvicorn command
with the virtual-environment interpreter instead:
```powershell
.\.venv\Scripts\python.exe -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
### 3. Install and start the frontend
In a second terminal:
```powershell
cd frontend
npm install
npm run dev
```
Open <http://localhost:5173>.
For a production-like frontend build:
```powershell
npm run build
npm run preview
```
## Typical user flow
1. Open the frontend and sign in with Google.
2. Create interview requirements for a role.
3. Upload a candidate resume from the Resume page.
4. Select the resume and requirement set on the Questions page.
5. Generate tailored interview questions with Gemini.
6. Schedule an interview and optionally provide a meeting link.
7. Use the Pronunciation page to prepare for unfamiliar technical terms.
## API overview
All protected endpoints use the JWT created by the Google OAuth callback. The
frontend stores the token in `localStorage` under `hireai_token` and sends it
as a Bearer token through the Axios client.
| Area | Method | Endpoint | Authentication |
| --- | --- | --- | --- |
| Auth | GET | `/api/auth/google/login` | Public |
| Auth | GET | `/api/auth/google/callback` | OAuth callback |
| Auth | GET | `/api/auth/me` | Required |
| Auth | POST | `/api/auth/logout` | Public/stateless |
| Resumes | POST | `/api/resume/upload` | Required |
| Resumes | GET | `/api/resume/` | Required |
| Resumes | GET | `/api/resume/{resume_id}` | Required |
| Resumes | GET | `/api/resume/{resume_id}/download` | Required |
| Resumes | DELETE | `/api/resume/{resume_id}` | Required |
| Requirements | POST | `/api/requirements/` | Required |
| Requirements | GET | `/api/requirements/` | Required |
| Requirements | GET/PUT/DELETE | `/api/requirements/{req_id}` | Required |
| Questions | POST | `/api/questions/generate` | Required |
| Questions | GET | `/api/questions/{resume_id}` | Required |
| Schedule | POST | `/api/schedule/` | Required |
| Schedule | GET | `/api/schedule/` | Required |
| Schedule | GET | `/api/schedule/{sched_id}` | Required |
| Schedule | PUT | `/api/schedule/{sched_id}/status` | Required |
| Schedule | DELETE | `/api/schedule/{sched_id}` | Required |
| Pronunciation | GET | `/api/pronunciation/tech-terms` | Public |
| Pronunciation | GET | `/api/pronunciation/word-of-the-day` | Public |
| Pronunciation | GET | `/api/pronunciation/{word}` | Public |
The interactive OpenAPI documentation at `/docs` is the authoritative source
for request and response schemas.
## Data and storage
MongoDB collections are created and used as needed by the application:
- `users` — Google profile and login timestamps.
- `resumes` — parsed resume metadata and extracted content.
- `requirements` — role requirements owned by a user.
- `questions` — generated question sets associated with a resume and role.
- `schedules` — scheduled interviews owned by a user.
- `pronunciations` — cached technical-term pronunciation results.
- `resumes.files` and `resumes.chunks` — GridFS storage for original files.
The application does not include database migrations or seed scripts. Back up
MongoDB, including the GridFS collections, before deleting or replacing data.
## Development notes
- Run the backend from the `backend` directory so `main:app` and the `app`
package resolve correctly.
- Keep Google, Gemini, JWT, SMTP, and MongoDB credentials in environment files
or a secret manager.
- The backend allows the configured `FRONTEND_URL` and
`http://localhost:5173` through CORS.
- Uploaded resumes are restricted to PDF/DOC/DOCX content types and 10 MB.
- The frontend has no automated test script configured in `package.json`.
Use `npm run build` as the available frontend verification command.
- The backend currently has no test suite or test command in the repository.
## Troubleshooting
### MongoDB connection errors
Confirm MongoDB is running and that `MONGODB_URI` points to the correct
instance. The API will not complete startup until its initial MongoDB ping
succeeds.
### Google OAuth redirect errors
Make sure the authorized redirect URI exactly matches
`BACKEND_URL/api/auth/google/callback`, and that `GOOGLE_CLIENT_ID`,
`GOOGLE_CLIENT_SECRET`, `FRONTEND_URL`, and `BACKEND_URL` agree with the URLs
used to start both applications.
### AI generation failures
Confirm `GEMINI_API_KEY` is present and available to the backend process. Check
the FastAPI terminal output and `/docs` response body for the returned error.
### Interview email failures
Verify the SMTP credentials and sender settings. Scheduling is persisted in
MongoDB before the email notification is attempted, so an email configuration
problem does not remove the scheduled interview.
## License
No license file is currently included in this repository. Add a license before
distributing the project publicly.

31
backend/.env Normal file
View file

@ -0,0 +1,31 @@
# ── Google OAuth ──────────────────────────────────────────────
# Get these from https://console.cloud.google.com/
# Authorized redirect URI must include: http://localhost:8000/api/auth/google/callback
GOOGLE_CLIENT_ID=49118838430-vf3ttj24hour4rcj48boo7n58dh7l8jo.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
# ── JWT ───────────────────────────────────────────────────────
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
JWT_SECRET=supersecretchangeme123456789abcdefghijklmnop
JWT_ALGORITHM=HS256
JWT_EXPIRE_HOURS=168
# ── MongoDB ───────────────────────────────────────────────────
MONGODB_URI=mongodb://localhost:27017
MONGODB_DB=hireai
# ── Gemini AI ─────────────────────────────────────────────────
GEMINI_API_KEY=AIzaSyDQkd0AlWlCU2mFyq99aLTXj83qBncIUgQ
# ── App URLs ──────────────────────────────────────────────────
FRONTEND_URL=http://localhost:5173
BACKEND_URL=http://localhost:8000
# ── Email (SMTP) ──────────────────────────────────────────────
MAIL_USERNAME=your-gmail@gmail.com
MAIL_PASSWORD=your-app-password
MAIL_FROM=your-gmail@gmail.com
MAIL_SERVER=smtp.gmail.com
MAIL_PORT=587
MAIL_TLS=True
MAIL_SSL=False

Binary file not shown.

1
backend/app/__init__.py Normal file
View file

@ -0,0 +1 @@
# HireAI app package

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

40
backend/app/config.py Normal file
View file

@ -0,0 +1,40 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
class Settings(BaseSettings):
# Google OAuth
google_client_id: str = ""
google_client_secret: str = ""
# JWT
jwt_secret: str = "supersecretchangeme"
jwt_algorithm: str = "HS256"
jwt_expire_hours: int = 168 # 7 days
# MongoDB
mongodb_uri: str = "mongodb://localhost:27017"
mongodb_db: str = "hireai"
# Gemini
gemini_api_key: str = ""
# URLs
frontend_url: str = "http://localhost:5173"
backend_url: str = "http://localhost:8000"
# Email
mail_username: str = ""
mail_password: str = ""
mail_from: str = ""
mail_server: str = "smtp.gmail.com"
mail_port: int = 587
mail_tls: bool = True
mail_ssl: bool = False
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@lru_cache
def get_settings() -> Settings:
return Settings()

39
backend/app/database.py Normal file
View file

@ -0,0 +1,39 @@
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorGridFSBucket
from pymongo.server_api import ServerApi
from app.config import get_settings
settings = get_settings()
client: AsyncIOMotorClient = None
db = None
gridfs_bucket: AsyncIOMotorGridFSBucket = None
async def connect_db():
global client, db, gridfs_bucket
client = AsyncIOMotorClient(
settings.mongodb_uri,
server_api=ServerApi("1"),
)
db = client[settings.mongodb_db]
gridfs_bucket = AsyncIOMotorGridFSBucket(db, bucket_name="resumes")
# Ping to verify connection
await client.admin.command("ping")
print(f"[OK] Connected to MongoDB: {settings.mongodb_db}")
async def close_db():
global client
if client:
client.close()
print("[CLOSED] MongoDB connection closed")
def get_db():
return db
def get_gridfs():
return gridfs_bucket

View file

@ -0,0 +1,58 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from app.config import get_settings
from app.database import get_db
settings = get_settings()
bearer_scheme = HTTPBearer(auto_error=False)
def create_access_token(data: dict) -> str:
payload = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expire_hours)
payload.update({"exp": expire, "iat": datetime.now(timezone.utc)})
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> Optional[dict]:
try:
return jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
except JWTError:
return None
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
db=Depends(get_db),
):
"""FastAPI dependency: validates JWT and returns the current user doc."""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(credentials.credentials)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user = await db["users"].find_one({"google_id": payload.get("sub")})
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
return user

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,13 @@
from pydantic import BaseModel
from typing import List
from datetime import datetime
class PronunciationItem(BaseModel):
word: str
phonetic: str
syllables: List[str]
definition: str
class PronunciationDB(PronunciationItem):
id: str
created_at: datetime

View file

@ -0,0 +1,22 @@
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
class QuestionItem(BaseModel):
question: str
expected_answer: str
hint: str
difficulty: Optional[str] = "Medium"
category: Optional[str] = "Technical"
class QuestionSetIn(BaseModel):
resume_id: str
requirement_id: str
class QuestionSetOut(BaseModel):
id: str
user_id: str
resume_id: str
requirement_id: str
questions: List[QuestionItem]
created_at: datetime

View file

@ -0,0 +1,18 @@
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
class RequirementsIn(BaseModel):
role_name: str
experience_years: int = 0
mandatory_skills: List[str] = []
nice_to_have_skills: List[str] = []
job_description: Optional[str] = None
class RequirementsOut(RequirementsIn):
id: str
user_id: str
created_at: datetime
updated_at: datetime

View file

@ -0,0 +1,31 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from bson import ObjectId
class ParsedResume(BaseModel):
name: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
linkedin: Optional[str] = None
github: Optional[str] = None
website: Optional[str] = None
objective: Optional[str] = None
skills: List[str] = []
languages: List[str] = []
interests: List[str] = []
projects: List[dict] = []
education: List[dict] = []
experience: List[dict] = []
raw_text: str = ""
class ResumeOut(BaseModel):
id: str
filename: str
file_type: str
uploaded_by: str
parsed: ParsedResume
uploaded_at: str
gridfs_id: Optional[str] = None

View file

@ -0,0 +1,22 @@
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from datetime import datetime
class ScheduleIn(BaseModel):
resume_id: str
candidate_email: EmailStr
candidate_name: str
interview_date: str # ISO string or YYYY-MM-DD
interview_time: str # HH:MM AM/PM
interview_type: str = "Technical" # Technical, HR, etc.
meeting_link: Optional[str] = None
notes: Optional[str] = None
class ScheduleOut(ScheduleIn):
id: str
user_id: str
status: str = "Scheduled" # Scheduled, Completed, Cancelled
created_at: datetime
updated_at: datetime

View file

@ -0,0 +1,19 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
class UserInDB(BaseModel):
google_id: str
email: EmailStr
name: str
picture: Optional[str] = None
created_at: datetime = None
last_login: datetime = None
class UserOut(BaseModel):
google_id: str
email: str
name: str
picture: Optional[str] = None

View file

@ -0,0 +1,3 @@
from app.routers import auth, resume, requirements, schedule, questions, pronunciation
__all__ = ["auth", "resume", "requirements", "schedule", "questions", "pronunciation"]

Binary file not shown.

Binary file not shown.

130
backend/app/routers/auth.py Normal file
View file

@ -0,0 +1,130 @@
"""
Google OAuth 2.0 flow:
1. GET /api/auth/google/login → redirect to Google consent screen
2. GET /api/auth/google/callback → exchange code for tokens → get user info
→ upsert user in MongoDB → issue JWT
→ redirect to frontend with token
3. GET /api/auth/me → return current user (protected)
4. POST /api/auth/logout → client-side only (stateless JWT), just 200
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse, JSONResponse
import httpx
from datetime import datetime, timezone
from app.config import get_settings
from app.database import get_db
from app.dependencies import create_access_token, get_current_user
from app.models.user import UserOut
router = APIRouter(prefix="/api/auth", tags=["auth"])
settings = get_settings()
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
SCOPES = "openid email profile"
# ── Step 1: Redirect to Google ─────────────────────────────────
@router.get("/google/login")
async def google_login():
redirect_uri = f"{settings.backend_url}/api/auth/google/callback"
params = (
f"?client_id={settings.google_client_id}"
f"&redirect_uri={redirect_uri}"
f"&response_type=code"
f"&scope={SCOPES}"
f"&access_type=offline"
f"&prompt=select_account"
)
return RedirectResponse(url=GOOGLE_AUTH_URL + params)
# ── Step 2: Callback from Google ───────────────────────────────
@router.get("/google/callback")
async def google_callback(code: str, db=Depends(get_db)):
redirect_uri = f"{settings.backend_url}/api/auth/google/callback"
# Exchange authorization code for tokens
async with httpx.AsyncClient() as client:
token_response = await client.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if token_response.status_code != 200:
error_url = f"{settings.frontend_url}/login?error=google_token_failed"
return RedirectResponse(url=error_url)
tokens = token_response.json()
access_token = tokens.get("access_token")
# Fetch user profile from Google
async with httpx.AsyncClient() as client:
userinfo_response = await client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
if userinfo_response.status_code != 200:
error_url = f"{settings.frontend_url}/login?error=google_userinfo_failed"
return RedirectResponse(url=error_url)
google_user = userinfo_response.json()
google_id = google_user.get("sub")
email = google_user.get("email", "")
name = google_user.get("name", "")
picture = google_user.get("picture", "")
# Upsert user in MongoDB
now = datetime.now(timezone.utc)
await db["users"].update_one(
{"google_id": google_id},
{
"$set": {
"email": email,
"name": name,
"picture": picture,
"last_login": now,
},
"$setOnInsert": {
"google_id": google_id,
"created_at": now,
},
},
upsert=True,
)
# Issue JWT
jwt_token = create_access_token(
data={"sub": google_id, "email": email, "name": name, "picture": picture}
)
# Redirect to frontend with token
frontend_redirect = f"{settings.frontend_url}/auth/callback?token={jwt_token}"
return RedirectResponse(url=frontend_redirect)
# ── Get current user (protected) ──────────────────────────────
@router.get("/me", response_model=UserOut)
async def get_me(current_user: dict = Depends(get_current_user)):
return UserOut(
google_id=current_user["google_id"],
email=current_user["email"],
name=current_user["name"],
picture=current_user.get("picture"),
)
# ── Logout (stateless — client drops the JWT) ─────────────────
@router.post("/logout")
async def logout():
return JSONResponse({"message": "Logged out successfully"})

View file

@ -0,0 +1,79 @@
from fastapi import APIRouter, Depends, HTTPException
from datetime import datetime, timezone
from typing import List
import random
from app.database import get_db
from app.models.pronunciation import PronunciationDB
from app.services.gemini_service import get_word_pronunciation
router = APIRouter(prefix="/api/pronunciation", tags=["pronunciation"])
TECH_TERMS = [
"Kubernetes", "PostgreSQL", "Nginx", "Polymorphism", "Encapsulation",
"Asynchronous", "Concurrency", "Django", "Vue", "TypeScript",
"GraphQL", "Docker", "Microservices", "Idempotent", "Heuristic",
"Tuple", "Boolean", "JSON", "YAML", "Ubuntu",
"Vite", "Pinia", "Axios", "FastAPI", "Uvicorn",
"OAuth", "JWT", "PyMuPDF", "GridFS", "MongoDB",
"Motor", "Asynchronous", "Middleware", "Interceptor", "Composition",
"Component", "Reactive", "Computed", "State", "Mutation",
"Action", "Getter", "Router", "Guard", "Plugin"
]
@router.get("/tech-terms", response_model=List[str])
async def get_tech_terms():
"""Returns a list of 50 common tech terms to prepopulate the UI."""
return TECH_TERMS
@router.get("/word-of-the-day")
async def get_word_of_the_day(db=Depends(get_db)):
"""Returns a random word from the DB, or a default one if DB is empty."""
count = await db["pronunciations"].count_documents({})
if count == 0:
return {"word": "Polymorphism", "phonetic": "/ˌpɒlɪˈmɔːfɪzəm/", "definition": "The provision of a single interface to entities of different types."}
# Pick a random document
import random
random_skip = random.randint(0, count - 1)
doc = await db["pronunciations"].find().skip(random_skip).limit(1).to_list(length=1)
if doc:
return {
"word": doc[0]["word"],
"phonetic": doc[0]["phonetic"],
"definition": doc[0]["definition"]
}
return {"word": "Polymorphism", "phonetic": "/ˌpɒlɪˈmɔːfɪzəm/", "definition": "The provision of a single interface to entities of different types."}
@router.get("/{word}", response_model=PronunciationDB)
async def get_pronunciation(word: str, db=Depends(get_db)):
"""Fetches pronunciation info from MongoDB or Gemini."""
word_lower = word.lower().strip()
# 1. Check DB Cache
existing = await db["pronunciations"].find_one({"word_lower": word_lower})
if existing:
existing["id"] = str(existing.pop("_id"))
return existing
# 2. Call Gemini
try:
data = await get_word_pronunciation(word)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# 3. Store in DB
now = datetime.now(timezone.utc)
doc = {
"word": data["word"],
"word_lower": word_lower,
"phonetic": data["phonetic"],
"syllables": data["syllables"],
"definition": data["definition"],
"created_at": now
}
result = await db["pronunciations"].insert_one(doc)
doc["id"] = str(result.inserted_id)
return doc

View file

@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends, HTTPException, status
from bson import ObjectId
from datetime import datetime, timezone
from typing import List
from app.database import get_db
from app.dependencies import get_current_user
from app.models.question import QuestionSetIn, QuestionSetOut, QuestionItem
from app.services.gemini_service import generate_interview_questions
router = APIRouter(prefix="/api/questions", tags=["questions"])
@router.post("/generate", response_model=QuestionSetOut, status_code=status.HTTP_201_CREATED)
async def generate_questions(
req: QuestionSetIn,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
resume_oid = ObjectId(req.resume_id)
req_oid = ObjectId(req.requirement_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID format")
# Fetch resume
resume = await db["resumes"].find_one({"_id": resume_oid, "uploaded_by": current_user["google_id"]})
if not resume:
raise HTTPException(status_code=404, detail="Resume not found")
# Fetch requirements
requirement = await db["requirements"].find_one({"_id": req_oid, "user_id": current_user["google_id"]})
if not requirement:
raise HTTPException(status_code=404, detail="Requirement not found")
# Generate questions via Gemini
try:
# Pass resume text and requirements to Gemini
generated_data = await generate_interview_questions(
resume_text=resume.get("parsed", {}).get("raw_text", ""),
role_name=requirement.get("role_name", ""),
skills=requirement.get("mandatory_skills", []) + requirement.get("nice_to_have_skills", []),
experience=requirement.get("experience_years", 0),
projects=resume.get("parsed", {}).get("projects", [])
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Save to MongoDB
now = datetime.now(timezone.utc)
doc = {
"user_id": current_user["google_id"],
"resume_id": req.resume_id,
"requirement_id": req.requirement_id,
"questions": generated_data,
"created_at": now
}
# Upsert logic to overwrite if same pair exists
result = await db["questions"].find_one_and_update(
{
"user_id": current_user["google_id"],
"resume_id": req.resume_id,
"requirement_id": req.requirement_id
},
{"$set": doc},
upsert=True,
return_document=True
)
result["id"] = str(result.pop("_id"))
return result
@router.get("/{resume_id}", response_model=List[QuestionSetOut])
async def get_questions_for_resume(
resume_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
cursor = db["questions"].find({"user_id": current_user["google_id"], "resume_id": resume_id}).sort("created_at", -1)
results = []
async for doc in cursor:
doc["id"] = str(doc.pop("_id"))
results.append(doc)
return results

View file

@ -0,0 +1,98 @@
from fastapi import APIRouter, Depends, HTTPException, status
from bson import ObjectId
from datetime import datetime, timezone
from typing import List
from app.database import get_db
from app.dependencies import get_current_user
from app.models.requirements import RequirementsIn, RequirementsOut
router = APIRouter(prefix="/api/requirements", tags=["requirements"])
@router.post("/", response_model=RequirementsOut, status_code=status.HTTP_201_CREATED)
async def create_requirements(
req: RequirementsIn,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
now = datetime.now(timezone.utc)
doc = req.model_dump()
doc["user_id"] = current_user["google_id"]
doc["created_at"] = now
doc["updated_at"] = now
result = await db["requirements"].insert_one(doc)
doc["id"] = str(result.inserted_id)
return doc
@router.get("/", response_model=List[RequirementsOut])
async def list_requirements(
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
cursor = db["requirements"].find({"user_id": current_user["google_id"]}).sort("updated_at", -1)
reqs = []
async for doc in cursor:
doc["id"] = str(doc.pop("_id"))
reqs.append(doc)
return reqs
@router.get("/{req_id}", response_model=RequirementsOut)
async def get_requirement(
req_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(req_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
doc = await db["requirements"].find_one({"_id": oid, "user_id": current_user["google_id"]})
if not doc:
raise HTTPException(status_code=404, detail="Requirement not found")
doc["id"] = str(doc.pop("_id"))
return doc
@router.put("/{req_id}", response_model=RequirementsOut)
async def update_requirement(
req_id: str,
req: RequirementsIn,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(req_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
update_data = req.model_dump()
update_data["updated_at"] = datetime.now(timezone.utc)
result = await db["requirements"].find_one_and_update(
{"_id": oid, "user_id": current_user["google_id"]},
{"$set": update_data},
return_document=True
)
if not result:
raise HTTPException(status_code=404, detail="Requirement not found")
result["id"] = str(result.pop("_id"))
return result
@router.delete("/{req_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_requirement(
req_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(req_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
result = await db["requirements"].delete_one({"_id": oid, "user_id": current_user["google_id"]})
if result.deleted_count == 0:
raise HTTPException(status_code=404, detail="Requirement not found")

View file

@ -0,0 +1,180 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from fastapi.responses import StreamingResponse
from bson import ObjectId
from datetime import datetime, timezone
import io
from app.database import get_db, get_gridfs
from app.dependencies import get_current_user
from app.services.resume_parser import parse_pdf, parse_docx
router = APIRouter(prefix="/api/resume", tags=["resume"])
ALLOWED_TYPES = {
"application/pdf": "pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/msword": "doc",
}
MAX_SIZE_MB = 10
# ── Upload & Parse ──────────────────────────────────────────────
@router.post("/upload", status_code=status.HTTP_201_CREATED)
async def upload_resume(
file: UploadFile = File(...),
db=Depends(get_db),
gridfs=Depends(get_gridfs),
current_user: dict = Depends(get_current_user),
):
# Validate content type
content_type = file.content_type or ""
if content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=400,
detail="Only PDF and DOCX files are accepted.",
)
file_bytes = await file.read()
# Validate size
if len(file_bytes) > MAX_SIZE_MB * 1024 * 1024:
raise HTTPException(
status_code=400,
detail=f"File too large. Max size is {MAX_SIZE_MB}MB.",
)
# Parse resume
file_type = ALLOWED_TYPES[content_type]
if file_type == "pdf":
parsed = parse_pdf(file_bytes)
else:
parsed = parse_docx(file_bytes)
# Store file in GridFS
gridfs_id = await gridfs.upload_from_stream(
file.filename,
io.BytesIO(file_bytes),
metadata={"content_type": content_type, "uploaded_by": current_user["google_id"]},
)
# Store metadata in MongoDB
doc = {
"filename": file.filename,
"file_type": file_type,
"content_type": content_type,
"uploaded_by": current_user["google_id"],
"gridfs_id": str(gridfs_id),
"parsed": parsed,
"uploaded_at": datetime.now(timezone.utc),
}
result = await db["resumes"].insert_one(doc)
return {
"id": str(result.inserted_id),
"filename": file.filename,
"file_type": file_type,
"parsed": parsed,
"uploaded_at": doc["uploaded_at"].isoformat(),
"message": "Resume uploaded and parsed successfully",
}
# ── List all resumes for current user ──────────────────────────
@router.get("/")
async def list_resumes(
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
cursor = db["resumes"].find(
{"uploaded_by": current_user["google_id"]},
{"parsed.raw_text": 0}, # exclude raw text from list view
).sort("uploaded_at", -1)
resumes = []
async for doc in cursor:
doc["id"] = str(doc.pop("_id"))
doc["uploaded_at"] = doc["uploaded_at"].isoformat() if doc.get("uploaded_at") else ""
resumes.append(doc)
return resumes
# ── Get single resume ──────────────────────────────────────────
@router.get("/{resume_id}")
async def get_resume(
resume_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(resume_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid resume ID")
doc = await db["resumes"].find_one(
{"_id": oid, "uploaded_by": current_user["google_id"]}
)
if not doc:
raise HTTPException(status_code=404, detail="Resume not found")
doc["id"] = str(doc.pop("_id"))
doc["uploaded_at"] = doc["uploaded_at"].isoformat() if doc.get("uploaded_at") else ""
return doc
# ── Download original file ─────────────────────────────────────
@router.get("/{resume_id}/download")
async def download_resume(
resume_id: str,
db=Depends(get_db),
gridfs=Depends(get_gridfs),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(resume_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid resume ID")
doc = await db["resumes"].find_one(
{"_id": oid, "uploaded_by": current_user["google_id"]}
)
if not doc:
raise HTTPException(status_code=404, detail="Resume not found")
gridfs_id = ObjectId(doc["gridfs_id"])
stream = await gridfs.open_download_stream(gridfs_id)
content = await stream.read()
return StreamingResponse(
io.BytesIO(content),
media_type=doc["content_type"],
headers={"Content-Disposition": f'attachment; filename="{doc["filename"]}"'},
)
# ── Delete resume ──────────────────────────────────────────────
@router.delete("/{resume_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_resume(
resume_id: str,
db=Depends(get_db),
gridfs=Depends(get_gridfs),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(resume_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid resume ID")
doc = await db["resumes"].find_one(
{"_id": oid, "uploaded_by": current_user["google_id"]}
)
if not doc:
raise HTTPException(status_code=404, detail="Resume not found")
# Delete from GridFS
try:
await gridfs.delete(ObjectId(doc["gridfs_id"]))
except Exception:
pass # GridFS file may already be gone
await db["resumes"].delete_one({"_id": oid})

View file

@ -0,0 +1,110 @@
from fastapi import APIRouter, Depends, HTTPException, status
from bson import ObjectId
from datetime import datetime, timezone
from typing import List
from app.database import get_db
from app.dependencies import get_current_user
from app.models.schedule import ScheduleIn, ScheduleOut
from app.services.email import send_interview_email
router = APIRouter(prefix="/api/schedule", tags=["schedule"])
@router.post("/", response_model=ScheduleOut, status_code=status.HTTP_201_CREATED)
async def create_schedule(
sched: ScheduleIn,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
now = datetime.now(timezone.utc)
doc = sched.model_dump()
doc["user_id"] = current_user["google_id"]
doc["status"] = "Scheduled"
doc["created_at"] = now
doc["updated_at"] = now
result = await db["schedules"].insert_one(doc)
doc["id"] = str(result.inserted_id)
# Try sending email
email_sent = await send_interview_email(
candidate_email=sched.candidate_email,
candidate_name=sched.candidate_name,
date=sched.interview_date,
time=sched.interview_time,
meeting_link=sched.meeting_link or "",
notes=sched.notes or ""
)
# Could log email_sent status if needed
return doc
@router.get("/", response_model=List[ScheduleOut])
async def list_schedules(
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
cursor = db["schedules"].find({"user_id": current_user["google_id"]}).sort("interview_date", 1)
scheds = []
async for doc in cursor:
doc["id"] = str(doc.pop("_id"))
scheds.append(doc)
return scheds
@router.get("/{sched_id}", response_model=ScheduleOut)
async def get_schedule(
sched_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(sched_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
doc = await db["schedules"].find_one({"_id": oid, "user_id": current_user["google_id"]})
if not doc:
raise HTTPException(status_code=404, detail="Schedule not found")
doc["id"] = str(doc.pop("_id"))
return doc
@router.put("/{sched_id}/status", response_model=ScheduleOut)
async def update_schedule_status(
sched_id: str,
status: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(sched_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
result = await db["schedules"].find_one_and_update(
{"_id": oid, "user_id": current_user["google_id"]},
{"$set": {"status": status, "updated_at": datetime.now(timezone.utc)}},
return_document=True
)
if not result:
raise HTTPException(status_code=404, detail="Schedule not found")
result["id"] = str(result.pop("_id"))
return result
@router.delete("/{sched_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_schedule(
sched_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(sched_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
result = await db["schedules"].delete_one({"_id": oid, "user_id": current_user["google_id"]})
if result.deleted_count == 0:
raise HTTPException(status_code=404, detail="Schedule not found")

Binary file not shown.

View file

@ -0,0 +1,51 @@
import logging
from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType
from pydantic import EmailStr
from app.config import get_settings
settings = get_settings()
conf = ConnectionConfig(
MAIL_USERNAME=settings.mail_username,
MAIL_PASSWORD=settings.mail_password,
MAIL_FROM=settings.mail_from,
MAIL_PORT=settings.mail_port,
MAIL_SERVER=settings.mail_server,
MAIL_STARTTLS=settings.mail_tls,
MAIL_SSL_TLS=settings.mail_ssl,
USE_CREDENTIALS=True,
VALIDATE_CERTS=True
)
async def send_interview_email(candidate_email: EmailStr, candidate_name: str, date: str, time: str, meeting_link: str, notes: str):
# Only try to send if credentials are provided
if not settings.mail_username or not settings.mail_password:
logging.warning("Email credentials not configured. Skipping interview email sending.")
return False
html = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; color: #333;">
<h2 style="color: #2563eb;">Interview Scheduled</h2>
<p>Hi {candidate_name},</p>
<p>Your interview has been scheduled for <strong>{date}</strong> at <strong>{time}</strong>.</p>
<p><strong>Meeting Link:</strong> <a href="{meeting_link}">{meeting_link}</a></p>
{f'<p><strong>Notes:</strong> {notes}</p>' if notes else ''}
<br>
<p>Best regards,<br>HireAI Team</p>
</div>
"""
message = MessageSchema(
subject="Interview Invitation - HireAI",
recipients=[candidate_email],
body=html,
subtype=MessageType.html
)
fm = FastMail(conf)
try:
await fm.send_message(message)
return True
except Exception as e:
logging.error(f"Failed to send email: {e}")
return False

View file

@ -0,0 +1,104 @@
import logging
import json
from google import genai
from google.genai import types
from app.config import get_settings
from app.models.question import QuestionItem
from app.models.pronunciation import PronunciationItem
settings = get_settings()
client = None
if settings.gemini_api_key:
client = genai.Client(api_key=settings.gemini_api_key)
async def generate_interview_questions(resume_text: str, role_name: str, skills: list[str], experience: int, projects: list[dict] = None) -> list[dict]:
if not client:
logging.error("Gemini API key not configured.")
raise ValueError("Gemini API key not configured")
# Format projects for the prompt
projects_section = ""
if projects:
projects_section = "\n Candidate's Projects (PRIORITIZE these in your questions):\n"
for p in projects[:5]:
tech_str = ", ".join(p.get("tech", []))
projects_section += f" - {p['name']}: {p.get('description', '')[:300]} (Tech: {tech_str})\n"
else:
projects_section = " No projects listed."
prompt = f"""
You are an expert technical interviewer.
Job Requirement:
- Role: {role_name}
- Required Experience: {experience} years
- Target Skills: {', '.join(skills)}
{projects_section}
Full Candidate Resume Text:
{resume_text}
Generate exactly 5 interview questions following this distribution:
- Questions 1, 2, 3: MUST be directly about the candidate's specific PROJECTS listed above.
Ask about technical decisions made, challenges faced, architecture, or improvements they would make.
- Questions 4, 5: Should cover general skills from the job requirement.
For each question, provide:
1. question: The question itself.
2. expected_answer: Key points an ideal answer should cover.
3. hint: A short hint if the candidate gets stuck.
4. difficulty: Easy, Medium, or Hard.
5. category: Technical, Behavioral, or System Design.
"""
try:
response = client.models.generate_content(
model='gemini-2.5-flash',
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=list[QuestionItem],
temperature=0.7,
),
)
questions = json.loads(response.text)
return questions
except Exception as e:
logging.error(f"Failed to generate questions: {e}")
raise ValueError(f"Failed to generate questions: {e}")
async def get_word_pronunciation(word: str) -> dict:
if not client:
logging.error("Gemini API key not configured.")
raise ValueError("Gemini API key not configured")
prompt = f"""
You are an expert linguist and technical dictionary.
Analyze the technical term: "{word}"
Provide the following structured information:
1. word: The correctly spelled word.
2. phonetic: The exact IPA (International Phonetic Alphabet) pronunciation (e.g., /ˌpɒlɪˈmɔːfɪzəm/).
3. syllables: An array of strings representing the syllable breakdown (e.g., ["pol", "y", "mor", "phism"]).
4. definition: A concise definition of the word, specifically in a computer science or tech context (max 2 sentences).
"""
try:
response = client.models.generate_content(
model='gemini-2.5-flash',
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=PronunciationItem,
temperature=0.1,
),
)
# Parse the JSON response
data = json.loads(response.text)
return data
except Exception as e:
logging.error(f"Failed to get pronunciation: {e}")
raise ValueError(f"Failed to get pronunciation: {e}")

View file

@ -0,0 +1,267 @@
"""
Resume parsing service — enhanced extraction.
Supports PDF (via PyMuPDF) and DOCX (via python-docx).
Extracts: name, email, phone, socials, objective, skills,
projects, education, experience, languages, interests.
"""
import re
import io
from typing import Optional
# ── Section header patterns ───────────────────────────────────
SECTION_PATTERNS = {
"objective": r"objective|summary|profile|about me|career goal",
"skills": r"skills|technical skills|competencies|technologies",
"experience": r"experience|work history|employment|professional experience|internship",
"education": r"education|academic|qualification|academics",
"projects": r"projects|portfolio|personal projects|academic projects",
"languages": r"languages|spoken languages",
"interests": r"interests|hobbies|activities|extra.?curricular",
"certifications": r"certifications?|achievements?|awards?",
}
# ── Programming / spoken language keywords ────────────────────
SKILL_KEYWORDS = {
"python", "javascript", "typescript", "java", "c++", "c#", "c", "go", "golang",
"rust", "swift", "kotlin", "scala", "ruby", "php", "r", "matlab", "bash",
"html", "css", "react", "vue", "angular", "next.js", "nuxt", "svelte",
"node.js", "express", "fastapi", "django", "flask", "spring", "laravel",
"machine learning", "deep learning", "tensorflow", "pytorch", "keras",
"scikit-learn", "pandas", "numpy", "matplotlib", "nlp", "computer vision",
"aws", "gcp", "azure", "docker", "kubernetes", "terraform", "jenkins",
"github actions", "ci/cd", "linux",
"mongodb", "postgresql", "mysql", "redis", "elasticsearch", "sqlite",
"dynamodb", "cassandra", "neo4j",
"git", "graphql", "rest api", "microservices", "kafka", "rabbitmq",
"websocket", "grpc", "firebase", "supabase", "prisma", "tailwind",
}
SPOKEN_LANGUAGE_KEYWORDS = {
"english", "hindi", "spanish", "french", "german", "chinese", "mandarin",
"japanese", "korean", "arabic", "portuguese", "italian", "russian",
"telugu", "tamil", "kannada", "bengali", "marathi", "gujarati", "malayalam",
"urdu", "punjabi",
}
# ── Low-level extractors ───────────────────────────────────────
def extract_email(text: str) -> Optional[str]:
m = re.search(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", text)
return m.group(0) if m else None
def extract_phone(text: str) -> Optional[str]:
m = re.search(r"(\+?[\d\s\-().]{10,15})", text)
return m.group(0).strip() if m else None
def extract_linkedin(text: str) -> Optional[str]:
m = re.search(r"linkedin\.com/in/[\w\-]+", text, re.IGNORECASE)
if m:
return "https://" + m.group(0)
return None
def extract_github(text: str) -> Optional[str]:
m = re.search(r"github\.com/[\w\-]+", text, re.IGNORECASE)
if m:
return "https://" + m.group(0)
return None
def extract_website(text: str) -> Optional[str]:
# Match personal URLs that are NOT linkedin/github
m = re.search(
r"https?://(?!.*(?:linkedin|github))[a-zA-Z0-9.\-/~]+",
text,
re.IGNORECASE,
)
return m.group(0) if m else None
def extract_name(text: str) -> Optional[str]:
lines = [l.strip() for l in text.split("\n") if l.strip()]
name_pattern = re.compile(r"^[A-Z][a-z]+(?:\s[A-Z][a-z]+){1,3}$")
for line in lines[:15]:
if name_pattern.match(line):
return line
return None
def extract_skills(text: str) -> list[str]:
text_lower = text.lower()
found = []
for skill in SKILL_KEYWORDS:
if re.search(r"\b" + re.escape(skill) + r"\b", text_lower):
found.append(skill.title() if len(skill) > 3 else skill.upper())
return sorted(set(found))
def extract_languages(section_text: str, full_text: str) -> list[str]:
"""Extract spoken/natural languages (not programming languages)."""
search_text = (section_text or full_text).lower()
found = []
for lang in SPOKEN_LANGUAGE_KEYWORDS:
if re.search(r"\b" + lang + r"\b", search_text):
found.append(lang.capitalize())
return sorted(set(found))
def extract_interests(section_text: str) -> list[str]:
if not section_text:
return []
items = re.split(r"[,\n•\-|/]", section_text)
interests = [i.strip().strip("•-– ") for i in items if i.strip() and len(i.strip()) > 2]
# Deduplicate & cap
seen = set()
result = []
for item in interests:
if item.lower() not in seen and len(item) < 60:
seen.add(item.lower())
result.append(item.title())
return result[:15]
def extract_projects(section_text: str) -> list[dict]:
"""Parse projects section into list of {name, description, tech}."""
if not section_text:
return []
projects = []
# Split by common project delimiters: blank line, bullet, or ALL-CAPS title line
blocks = re.split(r"\n{2,}|(?=\n[•\-★▸])", section_text)
for block in blocks:
block = block.strip()
if not block:
continue
lines = [l.strip() for l in block.split("\n") if l.strip()]
if not lines:
continue
name = lines[0].lstrip("•-★▸ ").strip()
description = " ".join(lines[1:]).strip() if len(lines) > 1 else ""
# Extract tech from description
tech_found = []
desc_lower = description.lower()
for skill in SKILL_KEYWORDS:
if re.search(r"\b" + re.escape(skill) + r"\b", desc_lower):
tech_found.append(skill.title() if len(skill) > 3 else skill.upper())
if name and len(name) < 120:
projects.append({
"name": name,
"description": description[:600] if description else "",
"tech": sorted(set(tech_found)),
})
return projects[:10]
def extract_education(section_text: str) -> list[dict]:
if not section_text:
return []
institutions = []
lines = [l.strip() for l in section_text.split("\n") if l.strip()]
i = 0
while i < len(lines):
line = lines[i]
# Detect degree lines
degree_pattern = re.search(
r"(b\.?tech|m\.?tech|b\.?e|m\.?e|bsc|msc|b\.?sc|m\.?sc|bachelor|master|phd|doctorate|diploma|high school|hsc|ssc|10th|12th)",
line, re.IGNORECASE
)
if degree_pattern:
entry = {
"degree": line,
"institution": lines[i + 1] if i + 1 < len(lines) else "",
"year": "",
}
# Look for year
for j in range(i, min(i + 4, len(lines))):
year_m = re.search(r"\b(19|20)\d{2}\b", lines[j])
if year_m:
entry["year"] = year_m.group(0)
break
institutions.append(entry)
i += 2
else:
i += 1
return institutions[:6]
# ── Section splitter ───────────────────────────────────────────
def split_sections(text: str) -> dict[str, str]:
sections: dict[str, list[str]] = {"header": []}
current = "header"
lines = text.split("\n")
for line in lines:
stripped = line.strip()
matched_section = None
for sec_key, pattern in SECTION_PATTERNS.items():
if re.match(rf"^({pattern})s?[\s:]*$", stripped, re.IGNORECASE):
matched_section = sec_key
break
if matched_section:
current = matched_section
sections.setdefault(current, [])
else:
sections.setdefault(current, []).append(stripped)
return {k: "\n".join(v) for k, v in sections.items()}
# ── Main parse function ────────────────────────────────────────
def parse_text(raw_text: str) -> dict:
sections = split_sections(raw_text)
objective_text = sections.get("objective", "").strip()
return {
"name": extract_name(raw_text),
"email": extract_email(raw_text),
"phone": extract_phone(raw_text),
"linkedin": extract_linkedin(raw_text),
"github": extract_github(raw_text),
"website": extract_website(raw_text),
"objective": objective_text[:600] if objective_text else None,
"skills": extract_skills(raw_text),
"languages": extract_languages(sections.get("languages", ""), raw_text),
"interests": extract_interests(sections.get("interests", "")),
"projects": extract_projects(sections.get("projects", "")),
"education": extract_education(sections.get("education", "")),
"experience": [], # Structured experience parsing can be added as needed
"raw_text": raw_text[:8000], # increased cap for better AI context
}
def parse_pdf(file_bytes: bytes) -> dict:
try:
import fitz # PyMuPDF
doc = fitz.open(stream=file_bytes, filetype="pdf")
text = "\n".join(page.get_text() for page in doc)
doc.close()
return parse_text(text)
except Exception as e:
return {"error": str(e), "raw_text": "", "skills": [], "projects": []}
def parse_docx(file_bytes: bytes) -> dict:
try:
from docx import Document
doc = Document(io.BytesIO(file_bytes))
text = "\n".join(p.text for p in doc.paragraphs)
return parse_text(text)
except Exception as e:
return {"error": str(e), "raw_text": "", "skills": [], "projects": []}

58
backend/main.py Normal file
View file

@ -0,0 +1,58 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.database import connect_db, close_db
from app.config import get_settings
from app.routers import auth, resume, requirements, schedule, questions, pronunciation
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
await connect_db()
yield
await close_db()
app = FastAPI(
title="HireAI API",
description="AI-powered hiring platform backend",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
# ── CORS ────────────────────────────────────────────────────────
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_url, "http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Routers ─────────────────────────────────────────────────────
app.include_router(auth.router)
app.include_router(resume.router)
app.include_router(requirements.router)
app.include_router(schedule.router)
app.include_router(questions.router)
app.include_router(pronunciation.router)
@app.get("/")
async def root():
return {
"app": "HireAI API",
"version": "1.0.0",
"status": "running",
"docs": "/docs",
}
@app.get("/health")
async def health():
return {"status": "ok"}

14
backend/requirements.txt Normal file
View file

@ -0,0 +1,14 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
motor==3.6.0
pymongo>=4.9,<4.10
python-multipart==0.0.12
pymupdf==1.24.13
python-docx==1.1.2
httpx==0.27.2
python-jose[cryptography]==3.3.0
python-dotenv==1.0.1
pydantic-settings==2.6.1
pydantic[email]==2.10.3
fastapi-mail==1.4.1
google-genai>=2.3.0

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
frontend/README.md Normal file
View file

@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

21
frontend/index.html Normal file
View file

@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="HireAI — AI-Powered Hiring Platform. Upload resumes, generate tailored interview questions with Gemini AI, schedule interviews, and get pronunciation guides for tech terms." />
<meta name="theme-color" content="#0A0D12" />
<meta property="og:title" content="HireAI — Smart Hiring Platform" />
<meta property="og:description" content="AI-powered hiring: resume parsing, question generation, interview scheduling — all in one platform." />
<meta property="og:type" content="website" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>HireAI — Smart Hiring Platform</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1449
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

20
frontend/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.16.1",
"vue": "^3.5.34",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.6",
"vite": "^8.0.12"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

85
frontend/src/App.vue Normal file
View file

@ -0,0 +1,85 @@
<template>
<div id="app-root">
<Navbar />
<main class="page-content">
<RouterView v-slot="{ Component }">
<Transition name="page" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main>
<AppFooter />
<!-- Toast Container -->
<div class="toast-container">
<TransitionGroup name="toast">
<div v-for="toast in toasts" :key="toast.id" class="toast" :class="'toast-' + toast.type">
<span class="toast-icon">
<template v-if="toast.type === 'success'">✅</template>
<template v-else-if="toast.type === 'error'">❌</template>
<template v-else>ℹ️</template>
</span>
{{ toast.message }}
</div>
</TransitionGroup>
</div>
</div>
</template>
<script setup>
import Navbar from './components/Navbar.vue'
import AppFooter from './components/AppFooter.vue'
import { useToast } from './composables/useToast.js'
const { toasts } = useToast()
</script>
<style>
.page-enter-active,
.page-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.page-enter-from {
opacity: 0;
transform: translateY(12px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-8px);
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(30px);
}
.toast-leave-to {
opacity: 0;
transform: translateY(-20px);
}
.toast-container {
position: fixed;
bottom: var(--space-6);
right: var(--space-6);
z-index: 9999;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.toast {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--clr-surface-2);
border: 1px solid var(--clr-border-2);
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
color: white;
box-shadow: var(--shadow-lg);
}
.toast-success { border-color: rgba(34, 197, 94, 0.4); }
.toast-error { border-color: rgba(239, 68, 68, 0.4); }
</style>

34
frontend/src/api/index.js Normal file
View file

@ -0,0 +1,34 @@
import axios from 'axios'
const api = axios.create({
baseURL: 'http://localhost:8000',
timeout: 30000,
})
// ── Request interceptor: attach Bearer token ─────────────────
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('hireai_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error),
)
// ── Response interceptor: handle 401 globally ────────────────
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Clear auth and redirect to login
localStorage.removeItem('hireai_token')
localStorage.removeItem('hireai_user')
window.location.href = '/login'
}
return Promise.reject(error)
},
)
export default api

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,100 @@
<template>
<footer class="footer">
<div class="container footer__inner">
<!-- Brand -->
<div class="footer__brand">
<RouterLink to="/" class="footer__logo">
<div class="logo-icon-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M12 2L2 7l10 5 10-5-10-5z" fill="currentColor"/>
<path d="M2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
<span class="logo-text-sm">Hire<span style="color: var(--clr-accent)">AI</span></span>
</RouterLink>
<p class="footer__tagline">AI-Powered Hiring for Modern Teams</p>
</div>
<!-- Links -->
<div class="footer__links">
<span class="footer-link-head">Platform</span>
<RouterLink to="/resume" class="footer-link">Resume Upload</RouterLink>
<RouterLink to="/requirements" class="footer-link">Requirements</RouterLink>
<RouterLink to="/schedule" class="footer-link">Schedule</RouterLink>
<RouterLink to="/questions" class="footer-link">AI Questions</RouterLink>
<RouterLink to="/pronunciation" class="footer-link">Pronunciation</RouterLink>
</div>
<!-- Status -->
<div class="footer__status">
<div class="status-pill">
<span class="status-dot"></span>
All systems operational
</div>
<p class="footer__copy">© {{ year }} HireAI. Built with FastAPI + Vue 3.</p>
</div>
</div>
</footer>
</template>
<script setup>
const year = new Date().getFullYear()
</script>
<style scoped>
.footer {
border-top: 1px solid var(--clr-border);
background: var(--clr-bg-2);
padding-block: var(--space-10);
margin-top: var(--space-20);
}
.footer__inner {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: var(--space-8);
align-items: start;
}
.footer__logo {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.logo-icon-sm {
width: 26px; height: 26px;
background: var(--clr-accent);
border-radius: 6px;
display: flex; align-items: center; justify-content: center;
color: #000;
}
.logo-text-sm { font-size: 1rem; font-weight: 800; letter-spacing: -0.03em; }
.footer__tagline { font-size: var(--text-sm); color: var(--clr-text-muted); }
.footer__links { display: flex; flex-direction: column; gap: var(--space-2); }
.footer-link-head { font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--clr-text-muted); margin-bottom: var(--space-1); }
.footer-link { font-size: var(--text-sm); color: var(--clr-text-secondary); transition: color var(--transition-fast); }
.footer-link:hover { color: var(--clr-accent); }
.footer__status { display: flex; flex-direction: column; gap: var(--space-3); align-items: flex-end; }
.status-pill {
display: inline-flex; align-items: center; gap: 6px;
font-size: var(--text-xs); color: var(--clr-text-secondary);
background: var(--clr-surface); border: 1px solid var(--clr-border);
padding: 4px 12px; border-radius: var(--radius-full);
}
.status-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--clr-accent);
box-shadow: 0 0 6px var(--clr-accent);
animation: pulse-glow 2s ease-in-out infinite;
}
.footer__copy { font-size: var(--text-xs); color: var(--clr-text-muted); }
@media (max-width: 768px) {
.footer__inner { grid-template-columns: 1fr 1fr; }
.footer__status { align-items: flex-start; }
}
@media (max-width: 500px) {
.footer__inner { grid-template-columns: 1fr; }
}
</style>

View file

@ -0,0 +1,442 @@
<template>
<nav class="navbar" :class="{ 'navbar--scrolled': isScrolled }">
<div class="container navbar__inner">
<!-- Logo -->
<RouterLink to="/" class="navbar__logo" id="nav-logo">
<div class="logo-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M12 2L2 7l10 5 10-5-10-5z" fill="currentColor"/>
<path d="M2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
<span class="logo-text">Hire<span class="logo-accent">AI</span></span>
</RouterLink>
<!-- Desktop Nav Links -->
<ul class="navbar__links" id="nav-links">
<li v-for="link in navLinks" :key="link.to">
<RouterLink
:to="link.to"
class="navbar__link"
:id="`nav-link-${link.id}`"
:class="{ active: $route.path === link.to }"
>
<span class="link-icon">{{ link.icon }}</span>
<span>{{ link.label }}</span>
</RouterLink>
</li>
</ul>
<!-- CTA + User Avatar + Mobile toggle -->
<div class="navbar__actions">
<!-- Not logged in -->
<template v-if="!isAuthenticated">
<RouterLink to="/login" class="btn btn-primary" id="nav-cta">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Sign In
</RouterLink>
</template>
<!-- Logged in: avatar dropdown -->
<template v-else>
<div class="user-menu" id="nav-user-menu">
<button class="avatar-btn" @click="userMenuOpen = !userMenuOpen" id="nav-avatar-btn">
<img v-if="user?.picture" :src="user.picture" :alt="user.name" class="avatar-img" referrerpolicy="no-referrer" />
<div v-else class="avatar-fallback">{{ userInitials }}</div>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" class="chevron" :class="{ open: userMenuOpen }">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<Transition name="dropdown">
<div class="user-dropdown glass-card" v-if="userMenuOpen" id="nav-user-dropdown">
<div class="dropdown-header">
<img v-if="user?.picture" :src="user.picture" :alt="user.name" class="dropdown-avatar" referrerpolicy="no-referrer" />
<div v-else class="dropdown-avatar-fb">{{ userInitials }}</div>
<div class="dropdown-info">
<div class="dropdown-name">{{ user?.name }}</div>
<div class="dropdown-email">{{ user?.email }}</div>
</div>
</div>
<div class="dropdown-divider"></div>
<button class="dropdown-item" id="nav-logout-btn" @click="handleLogout">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
Sign Out
</button>
</div>
</Transition>
</div>
</template>
<button class="mobile-toggle" id="nav-mobile-toggle" @click="mobileOpen = !mobileOpen" :aria-expanded="mobileOpen">
<span class="toggle-bar" :class="{ open: mobileOpen }"></span>
</button>
</div>
</div>
<!-- Mobile Dropdown -->
<Transition name="mobile-menu">
<div class="navbar__mobile" v-if="mobileOpen" id="nav-mobile-menu">
<ul>
<li v-for="link in navLinks" :key="link.to">
<RouterLink
:to="link.to"
class="mobile-link"
:class="{ active: $route.path === link.to }"
@click="mobileOpen = false"
>
<span class="link-icon">{{ link.icon }}</span>
{{ link.label }}
</RouterLink>
</li>
</ul>
<RouterLink to="/resume" class="btn btn-primary" style="width:100%; justify-content:center" @click="mobileOpen = false">
Start Hiring
</RouterLink>
</div>
</Transition>
</nav>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useAuth } from '../composables/useAuth.js'
const { isAuthenticated, user, logout } = useAuth()
const isScrolled = ref(false)
const mobileOpen = ref(false)
const userMenuOpen = ref(false)
const navLinks = [
{ to: '/resume', label: 'Resume Upload', icon: '📄', id: 'resume' },
{ to: '/requirements', label: 'Requirements', icon: '📋', id: 'requirements' },
{ to: '/schedule', label: 'Schedule', icon: '📅', id: 'schedule' },
{ to: '/questions', label: 'AI Questions', icon: '🤖', id: 'questions' },
{ to: '/pronunciation', label: 'Pronunciation', icon: '🔊', id: 'pronunciation' },
]
const userInitials = computed(() => {
if (!user.value?.name) return '?'
return user.value.name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()
})
async function handleLogout() {
userMenuOpen.value = false
await logout()
}
function handleScroll() {
isScrolled.value = window.scrollY > 16
}
// Close dropdown when clicking outside
function handleClickOutside(e) {
if (!e.target.closest('#nav-user-menu')) {
userMenuOpen.value = false
}
}
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
height: var(--navbar-h);
transition: background var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
border-bottom: 1px solid transparent;
}
.navbar--scrolled {
background: rgba(10, 13, 18, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom-color: var(--clr-border);
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
}
.navbar__inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
gap: var(--space-6);
}
/* Logo */
.navbar__logo {
display: flex;
align-items: center;
gap: var(--space-2);
text-decoration: none;
flex-shrink: 0;
}
.logo-icon {
width: 34px;
height: 34px;
background: var(--clr-accent);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: #000;
box-shadow: 0 0 16px var(--clr-accent-glow);
}
.logo-text {
font-size: 1.2rem;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--clr-text-primary);
}
.logo-accent { color: var(--clr-accent); }
/* Nav links */
.navbar__links {
display: flex;
align-items: center;
gap: var(--space-1);
flex: 1;
justify-content: center;
}
.navbar__link {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
color: var(--clr-text-secondary);
transition: color var(--transition-fast), background var(--transition-fast);
white-space: nowrap;
}
.navbar__link:hover {
color: var(--clr-text-primary);
background: var(--clr-surface);
}
.navbar__link.active {
color: var(--clr-accent);
background: var(--clr-accent-dim);
}
.link-icon { font-size: 0.9em; }
/* Actions */
.navbar__actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
}
/* Mobile toggle */
.mobile-toggle {
display: none;
width: 36px;
height: 36px;
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: var(--radius-md);
align-items: center;
justify-content: center;
position: relative;
}
.toggle-bar {
position: relative;
display: block;
width: 18px;
height: 2px;
background: var(--clr-text-primary);
border-radius: 2px;
transition: all var(--transition-base);
}
.toggle-bar::before, .toggle-bar::after {
content: '';
position: absolute;
left: 0;
width: 100%;
height: 2px;
background: var(--clr-text-primary);
border-radius: 2px;
transition: all var(--transition-base);
}
.toggle-bar::before { top: -6px; }
.toggle-bar::after { top: 6px; }
.toggle-bar.open { background: transparent; }
.toggle-bar.open::before { top: 0; transform: rotate(45deg); }
.toggle-bar.open::after { top: 0; transform: rotate(-45deg); }
/* Mobile menu */
.navbar__mobile {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: rgba(10, 13, 18, 0.97);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--clr-border);
padding: var(--space-4) var(--space-4) var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.mobile-link {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--text-md);
font-weight: 500;
color: var(--clr-text-secondary);
transition: all var(--transition-fast);
}
.mobile-link:hover, .mobile-link.active {
background: var(--clr-surface-2);
color: var(--clr-text-primary);
}
.mobile-link.active { color: var(--clr-accent); }
/* Mobile menu transitions */
.mobile-menu-enter-active,
.mobile-menu-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.mobile-menu-enter-from,
.mobile-menu-leave-to {
opacity: 0;
transform: translateY(-8px);
}
@media (max-width: 860px) {
.navbar__links { display: none; }
.mobile-toggle { display: flex; }
}
@media (max-width: 500px) {
.navbar__actions .btn { display: none; }
}
/* User avatar menu */
.user-menu { position: relative; }
.avatar-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: var(--radius-full);
padding: 3px 10px 3px 3px;
cursor: pointer;
transition: all var(--transition-base);
}
.avatar-btn:hover {
border-color: var(--clr-border-2);
background: var(--clr-surface-2);
}
.avatar-img {
width: 30px; height: 30px;
border-radius: 50%;
object-fit: cover;
}
.avatar-fallback {
width: 30px; height: 30px;
border-radius: 50%;
background: var(--clr-accent);
color: #000;
font-size: var(--text-xs);
font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.chevron {
transition: transform var(--transition-base);
color: var(--clr-text-muted);
}
.chevron.open { transform: rotate(180deg); }
/* Dropdown */
.user-dropdown {
position: absolute;
top: calc(100% + 10px);
right: 0;
min-width: 220px;
padding: var(--space-2);
z-index: 2000;
border-color: var(--clr-border-2);
}
.dropdown-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
}
.dropdown-avatar {
width: 36px; height: 36px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.dropdown-avatar-fb {
width: 36px; height: 36px;
border-radius: 50%;
background: var(--clr-accent);
color: #000;
font-size: var(--text-sm);
font-weight: 700;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.dropdown-name { font-size: var(--text-sm); font-weight: 600; }
.dropdown-email { font-size: var(--text-xs); color: var(--clr-text-muted); }
.dropdown-divider { height: 1px; background: var(--clr-border); margin: var(--space-1) 0; }
.dropdown-item {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--clr-text-secondary);
background: transparent;
border: none;
cursor: pointer;
transition: all var(--transition-fast);
}
.dropdown-item:hover { background: var(--clr-surface-2); color: #F87171; }
/* Dropdown transition */
.dropdown-enter-active, .dropdown-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.dropdown-enter-from, .dropdown-leave-to {
opacity: 0;
transform: translateY(-6px) scale(0.97);
}
</style>

View file

@ -0,0 +1,78 @@
/**
* useAuth — central auth composable
* Stores the JWT token and decoded user in localStorage.
* Used by router guards and all components that need user context.
*/
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import api from '../api/index.js'
const TOKEN_KEY = 'hireai_token'
const USER_KEY = 'hireai_user'
// Singleton reactive state
const token = ref(localStorage.getItem(TOKEN_KEY) || null)
const user = ref(JSON.parse(localStorage.getItem(USER_KEY) || 'null'))
export function useAuth() {
const router = useRouter()
const isAuthenticated = computed(() => !!token.value)
/** Call after receiving token from /auth/callback */
function setToken(newToken) {
token.value = newToken
localStorage.setItem(TOKEN_KEY, newToken)
// Decode payload (JWT is base64 — no sensitive data in payload)
try {
const payload = JSON.parse(atob(newToken.split('.')[1]))
const userData = {
name: payload.name || '',
email: payload.email || '',
picture: payload.picture || '',
}
user.value = userData
localStorage.setItem(USER_KEY, JSON.stringify(userData))
} catch (_) {
// ignore decode errors
}
}
/** Redirect to backend which starts Google OAuth flow */
function loginWithGoogle() {
window.location.href = 'http://localhost:8000/api/auth/google/login'
}
/** Clear token + user and redirect to login */
async function logout() {
try { await api.post('/api/auth/logout') } catch (_) {}
token.value = null
user.value = null
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
router.push('/login')
}
/** Refresh user info from /api/auth/me */
async function refreshUser() {
if (!token.value) return
try {
const { data } = await api.get('/api/auth/me')
user.value = data
localStorage.setItem(USER_KEY, JSON.stringify(data))
} catch (_) {
// Token expired or invalid — clear auth
logout()
}
}
return {
token,
user,
isAuthenticated,
setToken,
loginWithGoogle,
logout,
refreshUser,
}
}

View file

@ -0,0 +1,25 @@
import { ref } from 'vue'
const toasts = ref([])
let toastId = 0
export function useToast() {
function addToast(message, type = 'info', duration = 3000) {
const id = toastId++
toasts.value.push({ id, message, type })
setTimeout(() => {
removeToast(id)
}, duration)
}
function removeToast(id) {
toasts.value = toasts.value.filter(t => t.id !== id)
}
return {
toasts,
addToast,
removeToast
}
}

6
frontend/src/main.js Normal file
View file

@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router/index.js'
import './styles/main.css'
createApp(App).use(router).mount('#app')

View file

@ -0,0 +1,96 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const routes = [
// ── Public routes (no auth required) ─────────────────────
{
path: '/login',
name: 'Login',
component: () => import('../views/LoginView.vue'),
meta: { title: 'Sign In — HireAI', public: true },
},
{
path: '/auth/callback',
name: 'AuthCallback',
component: () => import('../views/AuthCallbackView.vue'),
meta: { title: 'Signing in… — HireAI', public: true },
},
// ── Protected routes (auth required) ─────────────────────
{
path: '/',
name: 'Home',
component: HomeView,
meta: { title: 'HireAI — Smart Hiring Platform', public: true },
},
{
path: '/resume',
name: 'ResumeUpload',
component: () => import('../views/ResumeUploadView.vue'),
meta: { title: 'Resume Upload — HireAI' },
},
{
path: '/requirements',
name: 'Requirements',
component: () => import('../views/RequirementsView.vue'),
meta: { title: 'Interview Requirements — HireAI' },
},
{
path: '/schedule',
name: 'Schedule',
component: () => import('../views/ScheduleView.vue'),
meta: { title: 'Interview Schedule — HireAI' },
},
{
path: '/questions',
name: 'Questions',
component: () => import('../views/QuestionsView.vue'),
meta: { title: 'AI Questions — HireAI' },
},
{
path: '/pronunciation',
name: 'Pronunciation',
component: () => import('../views/PronunciationView.vue'),
meta: { title: 'Pronunciation Guide — HireAI' },
},
// ── 404 fallback ──────────────────────────────────────────
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
return { top: 0, behavior: 'smooth' }
},
})
// ── Global Navigation Guard ───────────────────────────────────
router.beforeEach((to) => {
const token = localStorage.getItem('hireai_token')
const isPublic = to.meta?.public === true
// Unauthenticated user trying to access protected route
if (!isPublic && !token) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
// Already logged-in user going to /login → send home
if (to.name === 'Login' && token) {
return { name: 'Home' }
}
return true
})
// ── Update document title ─────────────────────────────────────
router.afterEach((to) => {
document.title = to.meta?.title || 'HireAI'
})
export default router

View file

@ -0,0 +1,411 @@
/* ============================================================
HIRING PLATFORM — Design System
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
/* ── CSS Design Tokens ─────────────────────────────────────── */
:root {
/* Colors */
--clr-bg: #0A0D12;
--clr-bg-2: #0F1318;
--clr-surface: #141920;
--clr-surface-2: #1A2030;
--clr-border: rgba(255, 255, 255, 0.07);
--clr-border-2: rgba(255, 255, 255, 0.12);
--clr-accent: #22C55E;
--clr-accent-glow: rgba(34, 197, 94, 0.25);
--clr-accent-dim: rgba(34, 197, 94, 0.08);
--clr-accent-2: #16A34A;
--clr-purple: #8B5CF6;
--clr-purple-dim: rgba(139, 92, 246, 0.12);
--clr-blue: #3B82F6;
--clr-blue-dim: rgba(59, 130, 246, 0.12);
--clr-amber: #F59E0B;
--clr-amber-dim: rgba(245, 158, 11, 0.12);
--clr-text-primary: #F0F4F8;
--clr-text-secondary: #94A3B8;
--clr-text-muted: #4B5A6E;
/* Typography */
--font-base: 'Inter', system-ui, sans-serif;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-md: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.375rem;
--text-2xl: 1.75rem;
--text-3xl: 2.25rem;
--text-4xl: 3rem;
--text-5xl: 3.75rem;
--text-6xl: 5rem;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
--space-24: 6rem;
/* Border radius */
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-2xl: 32px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4);
--shadow-md: 0 4px 16px rgba(0,0,0,0.4);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
--shadow-glow: 0 0 40px var(--clr-accent-glow);
--shadow-card: 0 1px 1px rgba(0,0,0,0.2), 0 4px 20px rgba(0,0,0,0.3);
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
/* Navbar */
--navbar-h: 64px;
}
/* ── Reset & Base ───────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-base);
font-size: var(--text-md);
color: var(--clr-text-primary);
background-color: var(--clr-bg);
min-height: 100vh;
line-height: 1.6;
}
a { color: inherit; text-decoration: none; }
button { cursor: pointer; font-family: inherit; border: none; }
img, svg { display: block; }
ul, ol { list-style: none; }
/* ── Scrollbar ──────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--clr-bg); }
::-webkit-scrollbar-thumb {
background: var(--clr-surface-2);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover { background: var(--clr-accent); }
/* ── Layout Utilities ───────────────────────────────────────── */
.container {
width: 100%;
max-width: 1200px;
margin-inline: auto;
padding-inline: var(--space-6);
}
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.gap-2 { gap: var(--space-2); }
.gap-4 { gap: var(--space-4); }
.gap-6 { gap: var(--space-6); }
.gap-8 { gap: var(--space-8); }
.text-center { text-align: center; }
.text-sm { font-size: var(--text-sm); }
.text-muted { color: var(--clr-text-secondary); }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
/* ── Section Layout ─────────────────────────────────────────── */
.section {
padding-block: var(--space-20);
}
.section-sm {
padding-block: var(--space-12);
}
.section-label {
display: inline-flex;
align-items: center;
gap: var(--space-2);
background: var(--clr-accent-dim);
border: 1px solid rgba(34, 197, 94, 0.2);
color: var(--clr-accent);
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
margin-bottom: var(--space-5);
}
.section-heading {
font-size: clamp(var(--text-2xl), 4vw, var(--text-4xl));
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.02em;
color: var(--clr-text-primary);
margin-bottom: var(--space-4);
}
.section-sub {
font-size: var(--text-lg);
color: var(--clr-text-secondary);
max-width: 560px;
line-height: 1.7;
}
/* ── Glass Card ─────────────────────────────────────────────── */
.glass-card {
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: var(--radius-lg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: var(--shadow-card);
transition: border-color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-base);
}
.glass-card:hover {
border-color: var(--clr-border-2);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
/* ── Buttons ────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0.625rem var(--space-5);
font-size: var(--text-sm);
font-weight: 600;
border-radius: var(--radius-md);
transition: all var(--transition-base);
white-space: nowrap;
}
.btn-primary {
background: var(--clr-accent);
color: #000;
box-shadow: 0 0 20px var(--clr-accent-glow);
}
.btn-primary:hover {
background: #28D869;
box-shadow: 0 0 32px rgba(34,197,94,0.4);
transform: translateY(-1px);
}
.btn-primary:active { transform: translateY(0); }
.btn-ghost {
background: transparent;
color: var(--clr-text-secondary);
border: 1px solid var(--clr-border);
}
.btn-ghost:hover {
background: var(--clr-surface-2);
color: var(--clr-text-primary);
border-color: var(--clr-border-2);
}
.btn-outline {
background: transparent;
color: var(--clr-accent);
border: 1px solid rgba(34, 197, 94, 0.35);
}
.btn-outline:hover {
background: var(--clr-accent-dim);
border-color: var(--clr-accent);
}
.btn-lg {
padding: 0.8rem var(--space-6);
font-size: var(--text-md);
border-radius: var(--radius-lg);
}
.btn-sm {
padding: 0.4rem var(--space-3);
font-size: var(--text-xs);
border-radius: var(--radius-sm);
}
/* ── Badge ──────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
font-size: var(--text-xs);
font-weight: 600;
border-radius: var(--radius-full);
}
.badge-green { background: var(--clr-accent-dim); color: var(--clr-accent); border: 1px solid rgba(34,197,94,0.2); }
.badge-purple { background: var(--clr-purple-dim); color: var(--clr-purple); border: 1px solid rgba(139,92,246,0.2); }
.badge-blue { background: var(--clr-blue-dim); color: var(--clr-blue); border: 1px solid rgba(59,130,246,0.2); }
.badge-amber { background: var(--clr-amber-dim); color: var(--clr-amber); border: 1px solid rgba(245,158,11,0.2); }
/* ── Form Elements ──────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.form-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--clr-text-secondary);
}
.form-input, .form-select, .form-textarea {
width: 100%;
background: var(--clr-surface-2);
border: 1px solid var(--clr-border);
border-radius: var(--radius-md);
color: var(--clr-text-primary);
font-family: var(--font-base);
font-size: var(--text-sm);
padding: 0.65rem var(--space-4);
transition: border-color var(--transition-base), box-shadow var(--transition-base);
outline: none;
}
.form-input::placeholder, .form-textarea::placeholder {
color: var(--clr-text-muted);
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
border-color: var(--clr-accent);
box-shadow: 0 0 0 3px var(--clr-accent-dim);
}
.form-textarea { resize: vertical; min-height: 120px; }
.form-select { appearance: none; cursor: pointer; }
/* ── Gradient Text ──────────────────────────────────────────── */
.gradient-text {
background: linear-gradient(135deg, var(--clr-accent) 0%, #16A34A 50%, #22D3EE 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ── Dot grid background ────────────────────────────────────── */
.dot-bg {
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
background-size: 28px 28px;
}
/* ── Glow orb ───────────────────────────────────────────────── */
.glow-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
}
/* ── Divider ────────────────────────────────────────────────── */
.divider {
height: 1px;
background: var(--clr-border);
margin-block: var(--space-8);
}
/* ── Page wrapper (accounts for sticky navbar) ──────────────── */
.page-content {
padding-top: var(--navbar-h);
}
/* ── Fade in animation ──────────────────────────────────────── */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 20px var(--clr-accent-glow); }
50% { box-shadow: 0 0 40px rgba(34,197,94,0.45); }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-fade-up { animation: fadeUp 0.6s ease both; }
.animate-fade-in { animation: fadeIn 0.4s ease both; }
.animate-scale-in { animation: scaleIn 0.4s ease both; }
.animate-float { animation: float 4s ease-in-out infinite; }
/* Staggered children */
.stagger > *:nth-child(1) { animation-delay: 0ms; }
.stagger > *:nth-child(2) { animation-delay: 80ms; }
.stagger > *:nth-child(3) { animation-delay: 160ms; }
.stagger > *:nth-child(4) { animation-delay: 240ms; }
.stagger > *:nth-child(5) { animation-delay: 320ms; }
/* ── Skeleton loader ────────────────────────────────────────── */
.skeleton {
background: linear-gradient(90deg,
var(--clr-surface) 25%,
var(--clr-surface-2) 50%,
var(--clr-surface) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-sm);
}
/* ── Responsive grid ────────────────────────────────────────── */
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-6); }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-6); }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-6); }
@media (max-width: 900px) {
.grid-3, .grid-4 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 640px) {
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
.container { padding-inline: var(--space-4); }
.section { padding-block: var(--space-12); }
.section-heading { font-size: var(--text-2xl); }
}

View file

@ -0,0 +1,77 @@
<template>
<div class="callback-page">
<div class="callback-card glass-card animate-scale-in">
<div v-if="!error">
<div class="spinner"></div>
<h2>Signing you in…</h2>
<p class="text-muted">Please wait while we set up your session.</p>
</div>
<div v-else class="error-state">
<div class="error-icon">⚠️</div>
<h2>Sign-in Failed</h2>
<p class="text-muted">{{ error }}</p>
<RouterLink to="/login" class="btn btn-primary" style="margin-top: 1rem">
Try Again
</RouterLink>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuth } from '../composables/useAuth.js'
const route = useRoute()
const router = useRouter()
const { setToken } = useAuth()
const error = ref('')
onMounted(() => {
const token = route.query.token
if (!token) {
error.value = 'No authentication token received from Google.'
return
}
// Store token + decode user
setToken(token)
// Redirect to original destination or home
const redirect = route.query.redirect || '/'
router.replace(redirect)
})
</script>
<style scoped>
.callback-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--clr-bg);
}
.callback-card {
padding: var(--space-12) var(--space-10);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
min-width: 320px;
}
.spinner {
width: 48px; height: 48px;
border: 3px solid var(--clr-border);
border-top-color: var(--clr-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: var(--space-2);
}
@keyframes spin { to { transform: rotate(360deg); } }
.error-state { display: flex; flex-direction: column; align-items: center; gap: var(--space-3); }
.error-icon { font-size: 2.5rem; }
</style>

View file

@ -0,0 +1,477 @@
<template>
<div class="home">
<!-- ── Hero ─────────────────────────────────────────────── -->
<section class="hero dot-bg">
<!-- Background orbs -->
<div class="glow-orb orb-1"></div>
<div class="glow-orb orb-2"></div>
<div class="container hero__content">
<div class="hero__badge animate-fade-up">
<span class="status-dot"></span>
Powered by Sentiense AI
</div>
<h1 class="hero__heading animate-fade-up" style="animation-delay:80ms">
Hire Smarter,<br>
<span class="gradient-text">Not Harder.</span>
</h1>
<p class="hero__sub animate-fade-up" style="animation-delay:160ms">
Upload resumes, set requirements, schedule interviews, and let AI generate
tailored questions — all in one platform built for modern hiring teams.
</p>
<div class="hero__ctas animate-fade-up" style="animation-delay:240ms">
<RouterLink to="/resume" class="btn btn-primary btn-lg" id="hero-cta-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Upload a Resume
</RouterLink>
<RouterLink to="/questions" class="btn btn-ghost btn-lg" id="hero-cta-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
Generate Questions
</RouterLink>
</div>
<!-- Stats -->
<div class="hero__stats animate-fade-up stagger" style="animation-delay:320ms">
<div class="stat-item" v-for="stat in stats" :key="stat.label">
<span class="stat-value">{{ stat.value }}</span>
<span class="stat-label">{{ stat.label }}</span>
</div>
</div>
</div>
<!-- Hero visual -->
<div class="hero__visual animate-fade-in" style="animation-delay:400ms">
<div class="hero-card animate-float" id="hero-card-main">
<div class="hcard-header">
<div class="hcard-avatar">SD</div>
<div>
<div class="hcard-name">Suyash Dixit</div>
<div class="hcard-role">Senior Data Engineer</div>
</div>
<div class="badge badge-green" style="margin-left:auto">Match 94%</div>
</div>
<div class="hcard-skills">
<span class="badge badge-blue">Python</span>
<span class="badge badge-purple">FastAPI</span>
<span class="badge badge-green">MongoDB</span>
<span class="badge badge-amber">Docker</span>
</div>
<div class="hcard-divider"></div>
<div class="hcard-q">
<div class="hcard-q-label">🤖 AI Generated Question</div>
<div class="hcard-q-text">"Explain how you'd design a distributed rate limiter in Python."</div>
</div>
<div class="hcard-footer">
<span class="hcard-schedule">📅 Interview: Tomorrow 10:00 AM</span>
<span class="badge badge-green">Scheduled</span>
</div>
</div>
<!-- Floating mini-card -->
<div class="mini-card mc-1 animate-float" style="animation-delay:1s">
<span>🔊</span>
<div>
<div class="mc-word">pol·y·mor·phism</div>
<div class="mc-phonetic">/ˌpɒlɪˈmɔːfɪzəm/</div>
</div>
</div>
<div class="mini-card mc-2 animate-float" style="animation-delay:2s">
<span>✅</span>
<div>
<div class="mc-word">5 Questions Ready</div>
<div class="mc-phonetic">Tailored to resume</div>
</div>
</div>
</div>
</section>
<!-- ── Feature Cards ─────────────────────────────────────── -->
<section class="section features" id="features">
<div class="container">
<div class="text-center" style="margin-bottom: var(--space-12)">
<div class="section-label">✦ Everything You Need</div>
<h2 class="section-heading">One Platform.<br>Every Hiring Step.</h2>
<p class="section-sub" style="margin-inline:auto">From the first resume to the final question, HireAI handles your entire hiring workflow with AI precision.</p>
</div>
<div class="features-grid stagger">
<RouterLink
v-for="feature in features"
:key="feature.to"
:to="feature.to"
class="feature-card glass-card"
:id="`feature-${feature.id}`"
>
<div class="feature-icon" :style="{ background: feature.bg }">
{{ feature.icon }}
</div>
<h3 class="feature-title">{{ feature.title }}</h3>
<p class="feature-desc">{{ feature.desc }}</p>
<div class="feature-arrow">
→ <span>Get started</span>
</div>
</RouterLink>
</div>
</div>
</section>
<!-- ── How It Works ──────────────────────────────────────── -->
<section class="section how-it-works" id="how-it-works">
<div class="container">
<div class="text-center" style="margin-bottom: var(--space-12)">
<div class="section-label">⚡ Workflow</div>
<h2 class="section-heading">From Resume to Interview<br>in Minutes</h2>
</div>
<div class="steps">
<div class="step" v-for="(step, i) in steps" :key="i">
<div class="step-num">{{ i + 1 }}</div>
<div class="step-line" v-if="i < steps.length - 1"></div>
<div class="step-body glass-card">
<div class="step-icon">{{ step.icon }}</div>
<h4 class="step-title">{{ step.title }}</h4>
<p class="step-desc">{{ step.desc }}</p>
</div>
</div>
</div>
</div>
</section>
<!-- ── CTA Banner ────────────────────────────────────────── -->
<section class="section-sm cta-banner">
<div class="container">
<div class="cta-inner glass-card">
<div class="glow-orb cta-orb"></div>
<div class="cta-text">
<div class="section-label">🚀 Ready?</div>
<h2 class="section-heading" style="font-size: var(--text-3xl)">Start Hiring Smarter Today</h2>
<p class="section-sub">Upload your first resume and let AI do the heavy lifting.</p>
</div>
<RouterLink to="/resume" class="btn btn-primary btn-lg" id="home-bottom-cta">
Get Started Free
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
</svg>
</RouterLink>
</div>
</div>
</section>
</div>
</template>
<script setup>
const stats = [
{ value: '5x', label: 'Faster Screening' },
{ value: '94%', label: 'Question Relevance' },
{ value: '∞', label: 'Resumes Supported' },
]
const features = [
{
to: '/resume',
id: 'resume',
icon: '📄',
bg: 'rgba(59,130,246,0.1)',
title: 'Resume Upload',
desc: 'Drag & drop PDF or DOCX resumes. Our AI instantly extracts skills, experience, and key data.',
},
{
to: '/requirements',
id: 'requirements',
icon: '📋',
bg: 'rgba(139,92,246,0.1)',
title: 'Interview Requirements',
desc: 'Define job titles, required skills, experience levels, and custom interview criteria.',
},
{
to: '/schedule',
id: 'schedule',
icon: '📅',
bg: 'rgba(245,158,11,0.1)',
title: 'Interview Schedule',
desc: 'Book interviews, detect conflicts, and automatically notify candidates by email.',
},
{
to: '/questions',
id: 'questions',
icon: '🤖',
bg: 'rgba(34,197,94,0.1)',
title: 'AI Questions',
desc: 'Gemini AI generates 5 tailored interview questions based on the candidate\'s resume and role.',
},
{
to: '/pronunciation',
id: 'pronunciation',
icon: '🔊',
bg: 'rgba(236,72,153,0.1)',
title: 'Pronunciation Guide',
desc: 'Get phonetics, audio playback, and definitions for complex tech terms like "polymorphism."',
},
]
const steps = [
{ icon: '📄', title: 'Upload Resume', desc: 'Upload candidate PDF/DOCX and let AI parse it instantly.' },
{ icon: '📋', title: 'Set Requirements', desc: 'Define the role, skills, and experience needed.' },
{ icon: '📅', title: 'Schedule Interview', desc: 'Pick a slot — candidate gets an email automatically.' },
{ icon: '🤖', title: 'Generate Questions', desc: 'Gemini creates 5 smart, tailored interview questions.' },
]
</script>
<style scoped>
/* ── Hero ─────────────────────────────────────────────────── */
.hero {
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: var(--space-12);
min-height: 100vh;
padding-block: var(--space-24);
padding-inline: var(--space-6);
max-width: 1200px;
margin-inline: auto;
}
.orb-1 {
width: 500px; height: 500px;
background: radial-gradient(circle, rgba(34,197,94,0.12) 0%, transparent 70%);
top: -100px; left: -150px;
}
.orb-2 {
width: 400px; height: 400px;
background: radial-gradient(circle, rgba(139,92,246,0.1) 0%, transparent 70%);
bottom: 0; right: -100px;
}
.hero__content { position: relative; z-index: 1; }
.hero__badge {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--clr-accent-dim);
border: 1px solid rgba(34,197,94,0.2);
color: var(--clr-accent);
font-size: var(--text-xs);
font-weight: 600;
padding: 6px 14px;
border-radius: var(--radius-full);
margin-bottom: var(--space-5);
}
.status-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--clr-accent);
animation: pulse-glow 2s ease-in-out infinite;
}
.hero__heading {
font-size: clamp(2.5rem, 5vw, var(--text-6xl));
font-weight: 900;
line-height: 1.08;
letter-spacing: -0.03em;
margin-bottom: var(--space-5);
}
.hero__sub {
font-size: var(--text-lg);
color: var(--clr-text-secondary);
line-height: 1.7;
max-width: 480px;
margin-bottom: var(--space-8);
}
.hero__ctas {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
margin-bottom: var(--space-10);
}
.hero__stats {
display: flex;
gap: var(--space-8);
border-top: 1px solid var(--clr-border);
padding-top: var(--space-6);
}
.stat-item { display: flex; flex-direction: column; gap: 2px; }
.stat-value { font-size: var(--text-2xl); font-weight: 800; color: var(--clr-accent); letter-spacing: -0.02em; }
.stat-label { font-size: var(--text-xs); color: var(--clr-text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
/* Hero Visual */
.hero__visual {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
}
.hero-card {
background: var(--clr-surface);
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-xl);
padding: var(--space-5);
width: 100%;
max-width: 380px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.hcard-header { display: flex; align-items: center; gap: var(--space-3); }
.hcard-avatar {
width: 44px; height: 44px;
background: linear-gradient(135deg, var(--clr-accent), var(--clr-blue));
border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: var(--text-sm); color: #000; flex-shrink: 0;
}
.hcard-name { font-weight: 600; font-size: var(--text-sm); }
.hcard-role { font-size: var(--text-xs); color: var(--clr-text-secondary); }
.hcard-skills { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.hcard-divider { height: 1px; background: var(--clr-border); }
.hcard-q-label { font-size: var(--text-xs); color: var(--clr-text-muted); margin-bottom: var(--space-2); font-weight: 600; }
.hcard-q-text { font-size: var(--text-sm); color: var(--clr-text-primary); line-height: 1.6; font-style: italic; }
.hcard-footer { display: flex; align-items: center; justify-content: space-between; }
.hcard-schedule { font-size: var(--text-xs); color: var(--clr-text-secondary); }
/* Mini floating cards */
.mini-card {
position: absolute;
display: flex;
align-items: center;
gap: var(--space-2);
background: var(--clr-surface-2);
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-lg);
padding: 10px 14px;
box-shadow: var(--shadow-lg);
font-size: 1.2rem;
}
.mc-1 { bottom: -20px; left: -30px; }
.mc-2 { top: -16px; right: -20px; }
.mc-word { font-size: var(--text-xs); font-weight: 600; color: var(--clr-text-primary); }
.mc-phonetic { font-size: 10px; color: var(--clr-text-muted); }
/* ── Features ─────────────────────────────────────────────── */
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-5);
}
.feature-card {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-6);
cursor: pointer;
text-decoration: none;
}
.feature-icon {
width: 48px; height: 48px;
border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem;
}
.feature-title { font-size: var(--text-lg); font-weight: 700; color: var(--clr-text-primary); }
.feature-desc { font-size: var(--text-sm); color: var(--clr-text-secondary); line-height: 1.6; flex: 1; }
.feature-arrow {
font-size: var(--text-sm);
color: var(--clr-text-muted);
display: flex; align-items: center; gap: 6px;
transition: color var(--transition-fast), gap var(--transition-fast);
}
.feature-card:hover .feature-arrow { color: var(--clr-accent); gap: 10px; }
/* ── Steps ────────────────────────────────────────────────── */
.steps {
display: flex;
align-items: flex-start;
gap: 0;
position: relative;
}
.step {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
position: relative;
}
.step-num {
width: 36px; height: 36px;
background: var(--clr-accent);
color: #000;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: var(--text-sm);
z-index: 1;
box-shadow: 0 0 16px var(--clr-accent-glow);
flex-shrink: 0;
}
.step-line {
position: absolute;
top: 18px;
left: calc(50% + 18px);
right: calc(-50% + 18px);
height: 1px;
background: linear-gradient(90deg, var(--clr-accent), var(--clr-border));
}
.step-body {
width: 100%;
padding: var(--space-5);
text-align: center;
}
.step-icon { font-size: 1.5rem; margin-bottom: var(--space-2); }
.step-title { font-size: var(--text-md); font-weight: 700; margin-bottom: var(--space-2); }
.step-desc { font-size: var(--text-sm); color: var(--clr-text-secondary); line-height: 1.6; }
/* ── CTA Banner ─────────────────────────────────────────── */
.cta-banner { margin-bottom: 0; }
.cta-inner {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-8);
padding: var(--space-10) var(--space-10);
}
.cta-orb {
width: 300px; height: 300px;
background: radial-gradient(circle, rgba(34,197,94,0.15) 0%, transparent 70%);
right: 200px; top: -100px;
}
.cta-text { position: relative; z-index: 1; }
/* ── Responsive ──────────────────────────────────────────── */
@media (max-width: 960px) {
.hero { grid-template-columns: 1fr; text-align: center; padding-block: var(--space-16); }
.hero__visual { display: none; }
.hero__sub { margin-inline: auto; }
.hero__ctas { justify-content: center; }
.hero__stats { justify-content: center; }
.features-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 700px) {
.features-grid { grid-template-columns: 1fr; }
.steps { flex-direction: column; align-items: stretch; }
.step-line { display: none; }
.cta-inner { flex-direction: column; text-align: center; padding: var(--space-8); }
}
</style>

View file

@ -0,0 +1,218 @@
<template>
<div class="login-page dot-bg">
<!-- Background Glow -->
<div class="glow-orb login-glow"></div>
<div class="login-container animate-fade-up">
<div class="login-card glass-card">
<!-- Logo -->
<div class="login-logo">
<div class="logo-icon-lg">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 2L2 7l10 5 10-5-10-5z" fill="currentColor"/>
<path d="M2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
<span class="logo-text-lg">Hire<span class="logo-accent">AI</span></span>
</div>
<h1 class="login-title">Welcome!</h1>
<p class="login-sub">Sign in to access your AI-powered hiring dashboard</p>
<!-- Google Button -->
<button @click="loginWithGoogle" class="btn-google">
<img src="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg" alt="Google" class="google-icon">
<span>Continue with Google</span>
</button>
<!-- Feature List -->
<div class="feature-list">
<div class="feature-item">
<span class="check">✓</span>
<span class="feature-text">AI-generated interview questions from resumes</span>
</div>
<div class="feature-item">
<span class="check">✓</span>
<span class="feature-text">Pronunciation guide for 50+ tech terms</span>
</div>
<div class="feature-item">
<span class="check">✓</span>
<span class="feature-text">Smart interview scheduling with email alerts</span>
</div>
</div>
<!-- Footer -->
<p class="login-footer">
By signing in, you agree to our
<a href="#" class="link">Terms of Service</a> and
<a href="#" class="link">Privacy Policy</a>.
</p>
</div>
</div>
</div>
</template>
<script setup>
import { useAuth } from '../composables/useAuth.js'
const { loginWithGoogle } = useAuth()
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
position: relative;
overflow: hidden;
background-color: #050505; /* Deepest black */
}
.login-glow {
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(34, 197, 94, 0.08) 0%, transparent 70%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.login-container {
width: 100%;
max-width: 440px;
position: relative;
z-index: 1;
}
.login-card {
padding: var(--space-10) var(--space-8);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-6);
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(15, 15, 15, 0.7);
backdrop-filter: blur(20px);
}
.login-logo {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-2);
}
.logo-icon-lg {
width: 48px;
height: 48px;
background: var(--clr-accent);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: #000;
box-shadow: 0 0 20px var(--clr-accent-glow);
}
.logo-text-lg {
font-size: 2.25rem;
font-weight: 800;
letter-spacing: -0.04em;
color: white;
}
.logo-accent {
color: var(--clr-accent);
}
.login-title {
font-size: 2.25rem;
font-weight: 800;
letter-spacing: -0.04em;
color: white;
margin-bottom: -10px;
}
.login-sub {
font-size: var(--text-sm);
color: var(--clr-text-secondary);
text-align: center;
margin-bottom: var(--space-2);
}
.btn-google {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
background: #1a1a1e;
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
font-size: var(--text-md);
font-weight: 600;
padding: 14px 20px;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
cursor: pointer;
}
.btn-google:hover {
background: #242429;
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
}
.google-icon {
width: 20px;
height: 20px;
}
.feature-list {
width: 100%;
background: rgba(34, 197, 94, 0.03);
border: 1px solid rgba(34, 197, 94, 0.1);
border-radius: var(--radius-lg);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.feature-item {
display: flex;
align-items: center;
gap: var(--space-3);
}
.check {
color: #22c55e;
font-weight: 800;
font-size: 1.1rem;
}
.feature-text {
font-size: var(--text-sm);
color: #a1a1aa; /* text-zinc-400 */
}
.login-footer {
font-size: 0.75rem;
color: #71717a; /* text-zinc-500 */
text-align: center;
line-height: 1.6;
}
.link {
color: #a1a1aa;
text-decoration: underline;
text-underline-offset: 3px;
transition: color var(--transition-fast);
}
.link:hover {
color: white;
}
</style>

View file

@ -0,0 +1,363 @@
<template>
<div class="pronunciation-page section">
<div class="container">
<div class="page-header animate-fade-up">
<div class="section-label">🔊 Step 5 of 5</div>
<h1 class="section-heading">Pronunciation Guide</h1>
<p class="section-sub">Master the pronunciation of complex technical terms before your interviews.</p>
</div>
<div class="pronunciation-layout">
<!-- Main Search and Display -->
<div class="main-panel animate-fade-up" style="animation-delay: 100ms;">
<!-- Search Bar -->
<div class="search-box glass-card">
<svg class="search-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
type="text"
v-model="searchQuery"
placeholder="Search for a tech term (e.g., Kubernetes)..."
class="search-input"
@keyup.enter="handleSearch"
/>
<button class="btn btn-primary btn-search" @click="handleSearch" :disabled="loading">
<span v-if="loading" class="spinner-sm"></span>
<span v-else>Lookup</span>
</button>
</div>
<!-- Result Card -->
<div class="result-card glass-card" v-if="currentWord && !loading">
<div class="result-header">
<h2 class="word-title">{{ currentWord.word }}</h2>
<button class="btn-play" @click="playAudio(currentWord.word)" title="Play Pronunciation">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
</div>
<div class="phonetic-display">
<span class="phonetic-text">{{ currentWord.phonetic }}</span>
<div class="syllables">
<span class="syllable" v-for="(syllable, i) in currentWord.syllables" :key="i">
{{ syllable }}
<span class="dot" v-if="i < currentWord.syllables.length - 1">•</span>
</span>
</div>
</div>
<div class="definition-box">
<h4 class="def-label">Definition</h4>
<p class="def-text">{{ currentWord.definition }}</p>
</div>
</div>
<!-- Initial/Empty/Error States -->
<div class="empty-state glass-card" v-else-if="!loading && !currentWord">
<div class="empty-icon">🗣️</div>
<h3>Ready to learn</h3>
<p class="text-muted">Type a word above or click one from the bank to hear how it's pronounced.</p>
<p class="error-text" v-if="error">{{ error }}</p>
</div>
<div class="skeleton-card glass-card" v-if="loading">
<div class="skeleton-line title"></div>
<div class="skeleton-line text"></div>
<div class="skeleton-line text short"></div>
</div>
</div>
<!-- Sidebar (Word of the Day + Bank) -->
<div class="sidebar animate-fade-up" style="animation-delay: 200ms;">
<!-- Word of the Day -->
<div class="wod-card glass-card" v-if="wordOfDay">
<div class="wod-header">
<span class="wod-badge">⭐ Word of the Day</span>
</div>
<h3 class="wod-title">{{ wordOfDay.word }}</h3>
<p class="wod-phonetic">{{ wordOfDay.phonetic }}</p>
<p class="wod-def">{{ wordOfDay.definition }}</p>
<button class="btn btn-ghost btn-sm" @click="loadWord(wordOfDay.word)" style="margin-top: 12px; width: 100%">
Load Details
</button>
</div>
<!-- Word Bank -->
<div class="word-bank glass-card">
<h3 class="bank-title">Popular Tech Terms</h3>
<div class="bank-grid">
<button
class="bank-chip"
v-for="term in techTerms"
:key="term"
@click="loadWord(term)"
>
{{ term }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../api/index.js'
const searchQuery = ref('')
const currentWord = ref(null)
const loading = ref(false)
const error = ref('')
const wordOfDay = ref(null)
const techTerms = ref([])
onMounted(async () => {
fetchWordOfDay()
fetchTechTerms()
})
async function fetchWordOfDay() {
try {
const { data } = await api.get('/api/pronunciation/word-of-the-day')
wordOfDay.value = data
} catch (e) {
console.error("Failed to load WOD", e)
}
}
async function fetchTechTerms() {
try {
const { data } = await api.get('/api/pronunciation/tech-terms')
techTerms.value = data
} catch (e) {
console.error("Failed to load terms", e)
}
}
async function handleSearch() {
if (!searchQuery.value.trim()) return
await loadWord(searchQuery.value)
}
async function loadWord(word) {
searchQuery.value = word
loading.value = true
error.value = ''
currentWord.value = null
try {
const { data } = await api.get(`/api/pronunciation/${encodeURIComponent(word.trim())}`)
currentWord.value = data
// Auto-play audio on load
playAudio(data.word)
} catch (e) {
error.value = "Failed to find pronunciation. Try a different word."
} finally {
loading.value = false
}
}
function playAudio(text) {
if ('speechSynthesis' in window) {
// Cancel any ongoing speech
window.speechSynthesis.cancel()
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = 'en-US'
utterance.rate = 0.9 // slightly slower for clarity
// Attempt to pick a good English voice
const voices = window.speechSynthesis.getVoices()
const preferredVoice = voices.find(v => v.name.includes('Google') && v.lang.startsWith('en'))
|| voices.find(v => v.lang === 'en-US')
if (preferredVoice) {
utterance.voice = preferredVoice
}
window.speechSynthesis.speak(utterance)
} else {
alert("Sorry, your browser doesn't support text-to-speech functionality.")
}
}
</script>
<style scoped>
.pronunciation-layout {
display: grid;
grid-template-columns: 1fr 320px;
gap: var(--space-8);
align-items: start;
}
@media (max-width: 900px) {
.pronunciation-layout { grid-template-columns: 1fr; }
}
/* Search Box */
.search-box {
display: flex;
align-items: center;
padding: 8px 8px 8px 16px;
gap: 12px;
border-radius: var(--radius-full);
margin-bottom: var(--space-6);
}
.search-icon { color: var(--clr-text-muted); }
.search-input {
flex: 1;
background: transparent;
border: none;
color: white;
font-size: var(--text-lg);
outline: none;
}
.search-input::placeholder { color: var(--clr-text-muted); }
.btn-search {
border-radius: var(--radius-full);
padding: 10px 24px;
min-width: 100px;
}
/* Result Card */
.result-card {
padding: var(--space-8);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.word-title {
font-size: 3rem;
font-weight: 800;
letter-spacing: -0.03em;
color: white;
}
.btn-play {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--clr-accent);
color: black;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 0 20px rgba(34, 197, 94, 0.4);
}
.btn-play:hover {
transform: scale(1.05);
box-shadow: 0 0 30px rgba(34, 197, 94, 0.6);
}
.phonetic-display {
display: flex;
flex-direction: column;
gap: 4px;
}
.phonetic-text {
font-size: var(--text-xl);
color: var(--clr-accent);
font-family: monospace;
}
.syllables {
display: flex;
gap: 8px;
font-size: var(--text-md);
color: var(--clr-text-secondary);
font-weight: 500;
}
.syllable { display: flex; align-items: center; gap: 8px; }
.dot { color: var(--clr-border); font-size: 10px; }
.definition-box {
background: rgba(255,255,255,0.03);
padding: var(--space-5);
border-radius: var(--radius-md);
border-left: 3px solid var(--clr-accent);
}
.def-label { font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.1em; color: var(--clr-text-muted); margin-bottom: 8px; }
.def-text { font-size: var(--text-md); line-height: 1.6; color: #d1d5db; }
/* Empty / Error */
.empty-state {
height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-8);
}
.empty-icon { font-size: 3rem; margin-bottom: var(--space-4); }
.error-text { color: #ef4444; margin-top: var(--space-4); }
/* Skeleton */
.skeleton-card { height: 300px; padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-4); }
.skeleton-line { background: rgba(255,255,255,0.05); border-radius: 4px; animation: pulse 1.5s infinite ease-in-out; }
.skeleton-line.title { height: 40px; width: 50%; }
.skeleton-line.text { height: 14px; width: 100%; }
.skeleton-line.text.short { width: 60%; }
@keyframes pulse { 0% { opacity: 0.5; } 50% { opacity: 1; } 100% { opacity: 0.5; } }
/* Sidebar */
.sidebar { display: flex; flex-direction: column; gap: var(--space-6); }
.wod-card {
padding: var(--space-5);
background: linear-gradient(145deg, rgba(34, 197, 94, 0.05) 0%, rgba(10, 13, 18, 0.8) 100%);
border-color: rgba(34, 197, 94, 0.15);
}
.wod-badge { font-size: 10px; font-weight: 700; color: #fcd34d; text-transform: uppercase; letter-spacing: 0.05em; display: inline-block; margin-bottom: 12px; }
.wod-title { font-size: var(--text-xl); font-weight: 800; color: white; margin-bottom: 4px; }
.wod-phonetic { font-size: var(--text-sm); font-family: monospace; color: var(--clr-text-secondary); margin-bottom: 12px; }
.wod-def { font-size: var(--text-sm); color: var(--clr-text-muted); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.word-bank { padding: var(--space-5); max-height: 400px; overflow-y: auto; }
.word-bank::-webkit-scrollbar { width: 4px; }
.word-bank::-webkit-scrollbar-thumb { background: var(--clr-border); border-radius: 4px; }
.bank-title { font-size: var(--text-sm); font-weight: 700; color: white; margin-bottom: var(--space-4); }
.bank-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.bank-chip {
background: rgba(255,255,255,0.03);
border: 1px solid var(--clr-border);
color: var(--clr-text-secondary);
padding: 6px 12px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.bank-chip:hover {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
color: var(--clr-accent);
}
</style>

View file

@ -0,0 +1,316 @@
<template>
<div class="questions-page section">
<div class="container">
<div class="page-header animate-fade-up">
<div class="section-label">🤖 Step 4 of 5</div>
<h1 class="section-heading">AI Question Generator</h1>
<p class="section-sub">Gemini AI will analyze the resume against your requirements to generate 5 targeted technical questions.</p>
</div>
<div class="questions-layout">
<!-- Configuration Column -->
<div class="config-col animate-fade-up" style="animation-delay: 100ms;">
<div class="glass-card config-card">
<h3 class="config-title">Generation Settings</h3>
<div class="config-form">
<!-- Resume Selector -->
<div class="form-group">
<label for="resumeSelect">Candidate Resume</label>
<select id="resumeSelect" v-model="selection.resume_id" class="form-control" @change="fetchExisting">
<option value="" disabled>-- Select Candidate --</option>
<option v-for="res in resumes" :key="res.id" :value="res.id">
{{ res.parsed?.name || res.filename }}
</option>
</select>
</div>
<!-- Requirement Selector -->
<div class="form-group">
<label for="reqSelect">Job Requirement</label>
<select id="reqSelect" v-model="selection.requirement_id" class="form-control" @change="fetchExisting">
<option value="" disabled>-- Select Requirement --</option>
<option v-for="req in requirements" :key="req.id" :value="req.id">
{{ req.role_name }}
</option>
</select>
</div>
<button
class="btn btn-primary generate-btn"
:disabled="!isReady || generating"
@click="handleGenerate"
>
<span v-if="generating" class="spinner-sm"></span>
{{ generating ? 'Generating with Gemini...' : 'Generate Questions' }}
</button>
</div>
</div>
<!-- Selection Summary -->
<div class="glass-card summary-card" v-if="selectedResume || selectedReq">
<h4 class="summary-title">Matching Context</h4>
<div class="summary-item" v-if="selectedResume">
<span class="label">Candidate:</span>
<span class="value">{{ selectedResume.parsed?.name }}</span>
</div>
<div class="summary-item" v-if="selectedReq">
<span class="label">Role:</span>
<span class="value">{{ selectedReq.role_name }}</span>
</div>
<div class="summary-item" v-if="selectedReq">
<span class="label">Target Skills:</span>
<div class="skill-tags">
<span class="mini-tag" v-for="s in selectedReq.mandatory_skills" :key="s">{{ s }}</span>
</div>
</div>
</div>
</div>
<!-- Questions Column -->
<div class="results-col animate-fade-up" style="animation-delay: 200ms;">
<!-- Generating State -->
<div v-if="generating" class="skeleton-list">
<div class="skeleton-card glass-card" v-for="i in 3" :key="i">
<div class="skeleton-line title"></div>
<div class="skeleton-line text"></div>
<div class="skeleton-line text short"></div>
</div>
</div>
<!-- Empty State -->
<div v-else-if="!currentQuestions.length" class="empty-state glass-card">
<div class="empty-icon">🤖</div>
<h3>Ready to generate</h3>
<p class="text-muted">Select a candidate and requirement to generate tailored questions.</p>
</div>
<!-- Questions List -->
<div v-else class="questions-list">
<div class="question-card glass-card animate-fade-up" v-for="(q, i) in currentQuestions" :key="i" :style="{animationDelay: (i * 100) + 'ms'}">
<div class="q-header">
<div class="q-num">Q{{ i + 1 }}</div>
<div class="q-badges">
<span class="badge" :class="'difficulty-' + q.difficulty?.toLowerCase()">{{ q.difficulty }}</span>
<span class="badge category-badge">{{ q.category }}</span>
</div>
</div>
<h4 class="q-text">{{ q.question }}</h4>
<div class="q-expanders">
<details class="q-details">
<summary>View Expected Answer</summary>
<div class="details-content answer">
{{ q.expected_answer }}
</div>
</details>
<details class="q-details">
<summary>View Interviewer Hint</summary>
<div class="details-content hint">
{{ q.hint }}
</div>
</details>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import api from '../api/index.js'
import { useToast } from '../composables/useToast.js'
const { addToast } = useToast()
const selection = ref({
resume_id: '',
requirement_id: ''
})
const resumes = ref([])
const requirements = ref([])
const currentQuestions = ref([])
const generating = ref(false)
const error = ref('')
const isReady = computed(() => selection.value.resume_id && selection.value.requirement_id)
const selectedResume = computed(() => resumes.value.find(r => r.id === selection.value.resume_id))
const selectedReq = computed(() => requirements.value.find(r => r.id === selection.value.requirement_id))
onMounted(async () => {
await Promise.all([loadResumes(), loadRequirements()])
})
async function loadResumes() {
const { data } = await api.get('/api/resume/')
resumes.value = data
}
async function loadRequirements() {
const { data } = await api.get('/api/requirements/')
requirements.value = data
}
async function fetchExisting() {
if (!isReady.value) return
try {
const { data } = await api.get(`/api/questions/${selection.value.resume_id}`)
// Look for a match with this requirement
const match = data.find(q => q.requirement_id === selection.value.requirement_id)
if (match) {
currentQuestions.value = match.questions
} else {
currentQuestions.value = []
}
} catch (e) {
console.error(e)
addToast('Failed to load initial data.', 'error')
}
}
async function handleGenerate() {
if (!selection.value.resume_id || !selection.value.requirement_id) {
addToast('Please select both a resume and a requirement.', 'error')
return
}
generating.value = true
error.value = ''
currentQuestions.value = []
try {
const { data } = await api.post('/api/questions/generate', selection.value)
currentQuestions.value = data.questions
addToast('Questions generated successfully!', 'success')
} catch (e) {
error.value = e.response?.data?.detail || 'Failed to generate questions'
addToast(error.value, 'error')
} finally {
generating.value = false
}
}
</script>
<style scoped>
.questions-layout {
display: grid;
grid-template-columns: 350px 1fr;
gap: var(--space-8);
align-items: start;
}
@media (max-width: 900px) {
.questions-layout { grid-template-columns: 1fr; }
}
.config-card { padding: var(--space-6); margin-bottom: var(--space-4); }
.config-title { font-size: var(--text-md); font-weight: 700; margin-bottom: var(--space-4); }
.config-form { display: flex; flex-direction: column; gap: var(--space-4); }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group label { font-size: var(--text-xs); font-weight: 600; color: var(--clr-text-secondary); }
.form-control {
width: 100%;
padding: 10px 14px;
background: var(--clr-surface-2);
border: 1px solid var(--clr-border);
border-radius: var(--radius-md);
color: white;
font-size: var(--text-sm);
}
.generate-btn {
margin-top: var(--space-2);
height: 44px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.summary-card { padding: var(--space-5); }
.summary-title { font-size: var(--text-xs); font-weight: 700; color: var(--clr-text-muted); text-transform: uppercase; margin-bottom: var(--space-3); }
.summary-item { display: flex; flex-direction: column; gap: 2px; margin-bottom: var(--space-3); }
.summary-item .label { font-size: 10px; color: var(--clr-text-muted); }
.summary-item .value { font-size: var(--text-sm); font-weight: 600; }
.skill-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.mini-tag { font-size: 10px; padding: 2px 6px; background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.2); border-radius: 4px; color: var(--clr-accent); }
/* Results */
.questions-list { display: flex; flex-direction: column; gap: var(--space-4); }
.question-card { padding: var(--space-6); }
.q-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-4); }
.q-num { font-weight: 800; color: var(--clr-accent); font-size: var(--text-lg); opacity: 0.5; }
.q-badges { display: flex; gap: var(--space-2); }
.badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: var(--radius-full); text-transform: uppercase; }
.difficulty-easy { background: rgba(34, 197, 94, 0.1); color: #22c55e; border: 1px solid rgba(34, 197, 94, 0.2); }
.difficulty-medium { background: rgba(245, 158, 11, 0.1); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.2); }
.difficulty-hard { background: rgba(239, 68, 68, 0.1); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.2); }
.category-badge { background: rgba(59, 130, 246, 0.1); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.2); }
.q-text { font-size: var(--text-lg); font-weight: 600; line-height: 1.5; margin-bottom: var(--space-6); }
.q-expanders { display: flex; flex-direction: column; gap: var(--space-2); }
.q-details {
border-top: 1px solid var(--clr-border);
padding-top: var(--space-2);
}
.q-details summary {
font-size: var(--text-xs);
font-weight: 600;
color: var(--clr-text-secondary);
cursor: pointer;
padding: 4px 0;
list-style: none;
display: flex;
align-items: center;
gap: 8px;
}
.q-details summary::before { content: '▶'; font-size: 8px; transition: transform 0.2s; }
.q-details[open] summary::before { transform: rotate(90deg); }
.details-content {
padding: var(--space-3);
margin-top: var(--space-2);
border-radius: var(--radius-md);
font-size: var(--text-sm);
line-height: 1.6;
}
.details-content.answer { background: rgba(255,255,255,0.03); color: #d1d5db; }
.details-content.hint { background: rgba(245, 158, 11, 0.05); color: #fcd34d; font-style: italic; }
.empty-state {
height: 400px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-8);
}
.empty-icon { font-size: 3rem; margin-bottom: var(--space-4); opacity: 0.5; }
/* Skeleton */
.skeleton-card { height: 200px; padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-4); }
.skeleton-line { background: rgba(255,255,255,0.05); border-radius: 4px; animation: pulse 1.5s infinite ease-in-out; }
.skeleton-line.title { height: 24px; width: 80%; }
.skeleton-line.text { height: 14px; width: 100%; }
.skeleton-line.text.short { width: 60%; }
@keyframes pulse { 0% { opacity: 0.5; } 50% { opacity: 1; } 100% { opacity: 0.5; } }
</style>

View file

@ -0,0 +1,491 @@
<template>
<div class="requirements-page section">
<div class="container">
<div class="page-header animate-fade-up">
<div class="section-label">📋 Step 2 of 5</div>
<h1 class="section-heading">Interview Requirements</h1>
<p class="section-sub">Define the role and expected skills. Our AI will use this to generate targeted interview questions.</p>
</div>
<div class="requirements-layout">
<!-- Form Column -->
<div class="form-col animate-fade-up" style="animation-delay: 100ms;">
<div class="glass-card form-card">
<h3 class="form-title">Role Configuration</h3>
<form @submit.prevent="saveRequirements" class="req-form">
<!-- Role Name -->
<div class="form-group">
<label for="roleName">Role / Job Title *</label>
<input
type="text"
id="roleName"
v-model="form.role_name"
class="form-control"
placeholder="e.g. Senior Frontend Developer"
required
/>
</div>
<!-- Experience -->
<div class="form-group">
<label for="expYears">Required Experience (Years) *</label>
<input
type="number"
id="expYears"
v-model.number="form.experience_years"
class="form-control"
min="0"
step="0.5"
required
/>
</div>
<!-- Mandatory Skills -->
<div class="form-group">
<label>Mandatory Skills</label>
<div class="skill-input-wrap">
<input
type="text"
v-model="newMandatorySkill"
@keydown.enter.prevent="addMandatorySkill"
class="form-control"
placeholder="Type a skill and press Enter..."
/>
<button type="button" class="btn btn-primary add-btn" @click="addMandatorySkill">+</button>
</div>
<div class="skills-list" v-if="form.mandatory_skills.length">
<span class="skill-chip mandatory" v-for="(skill, i) in form.mandatory_skills" :key="'m'+i">
{{ skill }}
<button type="button" class="chip-remove" @click="removeMandatorySkill(i)">✕</button>
</span>
</div>
</div>
<!-- Nice to have Skills -->
<div class="form-group">
<label>Nice-to-Have Skills</label>
<div class="skill-input-wrap">
<input
type="text"
v-model="newNiceSkill"
@keydown.enter.prevent="addNiceSkill"
class="form-control"
placeholder="Type a skill and press Enter..."
/>
<button type="button" class="btn btn-outline add-btn" @click="addNiceSkill">+</button>
</div>
<div class="skills-list" v-if="form.nice_to_have_skills.length">
<span class="skill-chip nice" v-for="(skill, i) in form.nice_to_have_skills" :key="'n'+i">
{{ skill }}
<button type="button" class="chip-remove" @click="removeNiceSkill(i)">✕</button>
</span>
</div>
</div>
<!-- Job Description -->
<div class="form-group">
<label for="jobDesc">Brief Job Description (Optional)</label>
<textarea
id="jobDesc"
v-model="form.job_description"
class="form-control"
rows="4"
placeholder="Paste key responsibilities or a brief summary..."
></textarea>
</div>
<!-- Submit -->
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save Requirements' }}
</button>
<span class="save-msg success" v-if="saveSuccess">✓ Saved successfully</span>
<span class="save-msg error" v-if="saveError">{{ saveError }}</span>
</div>
</form>
</div>
</div>
<!-- History/List Column -->
<div class="list-col animate-fade-up" style="animation-delay: 200ms;">
<h3 class="list-title">Saved Configurations</h3>
<div class="loading-state" v-if="loadingList">
<div class="spinner"></div> Loading...
</div>
<div class="empty-state glass-card" v-else-if="!reqList.length">
<div class="empty-icon">📝</div>
<h4>No configurations yet</h4>
<p class="text-muted">Save your first job requirement configuration on the left.</p>
</div>
<div class="req-list" v-else>
<div class="req-card glass-card" v-for="req in reqList" :key="req.id">
<div class="req-header">
<h4>{{ req.role_name }}</h4>
<div class="req-actions">
<button class="btn-icon" @click="editReq(req)" title="Edit">✏️</button>
<button class="btn-icon delete" @click="deleteReq(req.id)" title="Delete">🗑</button>
</div>
</div>
<p class="req-meta">{{ req.experience_years }} years exp. · {{ req.mandatory_skills.length }} core skills</p>
<div class="req-skills">
<span class="tiny-chip" v-for="skill in req.mandatory_skills.slice(0,3)" :key="skill">{{ skill }}</span>
<span class="tiny-chip more" v-if="req.mandatory_skills.length > 3">+{{ req.mandatory_skills.length - 3 }}</span>
</div>
<RouterLink :to="`/questions?req=${req.id}`" class="btn btn-outline btn-sm req-action-btn">
Generate Questions
</RouterLink>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../api/index.js'
const form = ref({
role_name: '',
experience_years: 0,
mandatory_skills: [],
nice_to_have_skills: [],
job_description: ''
})
const editingId = ref(null)
const newMandatorySkill = ref('')
const newNiceSkill = ref('')
const saving = ref(false)
const saveSuccess = ref(false)
const saveError = ref('')
const loadingList = ref(false)
const reqList = ref([])
onMounted(() => {
loadRequirements()
})
function addMandatorySkill() {
const skill = newMandatorySkill.value.trim()
if (skill && !form.value.mandatory_skills.includes(skill)) {
form.value.mandatory_skills.push(skill)
}
newMandatorySkill.value = ''
}
function removeMandatorySkill(index) {
form.value.mandatory_skills.splice(index, 1)
}
function addNiceSkill() {
const skill = newNiceSkill.value.trim()
if (skill && !form.value.nice_to_have_skills.includes(skill)) {
form.value.nice_to_have_skills.push(skill)
}
newNiceSkill.value = ''
}
function removeNiceSkill(index) {
form.value.nice_to_have_skills.splice(index, 1)
}
async function loadRequirements() {
loadingList.value = true
try {
const { data } = await api.get('/api/requirements/')
reqList.value = data
} catch (e) {
console.error(e)
} finally {
loadingList.value = false
}
}
async function saveRequirements() {
saving.value = true
saveSuccess.value = false
saveError.value = ''
try {
if (editingId.value) {
await api.put(`/api/requirements/${editingId.value}`, form.value)
} else {
await api.post('/api/requirements/', form.value)
}
saveSuccess.value = true
setTimeout(() => saveSuccess.value = false, 3000)
// reset form if creating new, else keep it
if (!editingId.value) {
form.value = {
role_name: '',
experience_years: 0,
mandatory_skills: [],
nice_to_have_skills: [],
job_description: ''
}
}
await loadRequirements()
} catch (e) {
saveError.value = e.response?.data?.detail || 'Failed to save'
} finally {
saving.value = false
}
}
function editReq(req) {
editingId.value = req.id
form.value = {
role_name: req.role_name,
experience_years: req.experience_years,
mandatory_skills: [...req.mandatory_skills],
nice_to_have_skills: [...req.nice_to_have_skills],
job_description: req.job_description || ''
}
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function deleteReq(id) {
if (!confirm('Are you sure you want to delete this configuration?')) return
try {
await api.delete(`/api/requirements/${id}`)
if (editingId.value === id) {
editingId.value = null
form.value = { role_name: '', experience_years: 0, mandatory_skills: [], nice_to_have_skills: [], job_description: '' }
}
await loadRequirements()
} catch (e) {
console.error('Delete failed', e)
}
}
</script>
<style scoped>
.requirements-layout {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: var(--space-8);
align-items: start;
}
@media (max-width: 900px) {
.requirements-layout {
grid-template-columns: 1fr;
}
}
.form-card {
padding: var(--space-6);
}
.form-title {
font-size: var(--text-lg);
font-weight: 700;
margin-bottom: var(--space-5);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--clr-border);
}
.req-form {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.form-group label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--clr-text-secondary);
}
.form-control {
width: 100%;
padding: 12px 16px;
background: var(--clr-surface-2);
border: 1px solid var(--clr-border);
border-radius: var(--radius-md);
color: var(--clr-text-primary);
font-size: var(--text-md);
transition: all var(--transition-fast);
}
.form-control:focus {
outline: none;
border-color: var(--clr-accent);
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
.skill-input-wrap {
display: flex;
gap: var(--space-2);
}
.add-btn {
padding: 0 var(--space-4);
font-size: 1.2rem;
font-weight: bold;
}
.skills-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-2);
}
.skill-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 600;
}
.skill-chip.mandatory {
background: rgba(34, 197, 94, 0.15);
color: var(--clr-accent);
border: 1px solid rgba(34, 197, 94, 0.3);
}
.skill-chip.nice {
background: rgba(139, 92, 246, 0.15);
color: #a78bfa;
border: 1px solid rgba(139, 92, 246, 0.3);
}
.chip-remove {
background: none;
border: none;
color: inherit;
font-size: 10px;
cursor: pointer;
padding: 2px;
opacity: 0.7;
}
.chip-remove:hover {
opacity: 1;
}
.form-actions {
display: flex;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-2);
}
.save-msg {
font-size: var(--text-sm);
font-weight: 500;
}
.save-msg.success { color: var(--clr-accent); }
.save-msg.error { color: #f87171; }
/* List Column */
.list-title {
font-size: var(--text-md);
font-weight: 600;
color: var(--clr-text-secondary);
margin-bottom: var(--space-4);
}
.empty-state {
padding: var(--space-8) var(--space-4);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
}
.empty-icon {
font-size: 2.5rem;
margin-bottom: var(--space-2);
}
.req-list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.req-card {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.req-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.req-header h4 {
font-size: var(--text-md);
font-weight: 700;
margin: 0;
}
.req-actions {
display: flex;
gap: var(--space-1);
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: background var(--transition-fast);
}
.btn-icon:hover { background: var(--clr-surface-2); }
.btn-icon.delete:hover { background: rgba(239, 68, 68, 0.1); }
.req-meta {
font-size: var(--text-xs);
color: var(--clr-text-muted);
}
.req-skills {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: var(--space-2);
}
.tiny-chip {
font-size: 10px;
padding: 2px 6px;
background: var(--clr-surface-2);
border: 1px solid var(--clr-border);
border-radius: 4px;
color: var(--clr-text-secondary);
}
.tiny-chip.more {
background: transparent;
border-color: transparent;
}
.req-action-btn {
margin-top: var(--space-3);
width: 100%;
text-align: center;
justify-content: center;
}
</style>

View file

@ -0,0 +1,638 @@
<template>
<div class="resume-page section">
<div class="container">
<!-- Page header -->
<div class="page-header animate-fade-up">
<div class="section-label">📄 Step 1 of 5</div>
<h1 class="section-heading">Resume Upload</h1>
<p class="section-sub">Upload a PDF or DOCX resume. Our AI parser instantly extracts all key candidate information.</p>
</div>
<div class="resume-layout">
<!-- Upload zone -->
<div class="upload-col animate-fade-up" style="animation-delay:100ms">
<div
class="upload-zone glass-card"
id="resume-drop-zone"
:class="{ 'drag-over': isDragging, 'has-file': selectedFile }"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
@click="!selectedFile && $refs.fileInput.click()"
>
<input
ref="fileInput"
type="file"
accept=".pdf,.docx,.doc"
style="display:none"
@change="handleFileSelect"
id="resume-file-input"
/>
<!-- Empty state -->
<template v-if="!selectedFile && !uploading && !uploadedResume">
<div class="upload-icon-wrap" :class="{ bounce: isDragging }">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<h3 class="upload-title">Drop your resume here</h3>
<p class="upload-sub">PDF or DOCX · Max 10MB</p>
<button class="btn btn-outline" id="resume-browse-btn" @click.stop="$refs.fileInput.click()">
Browse Files
</button>
</template>
<!-- File selected + uploading -->
<template v-else-if="selectedFile && !uploadedResume">
<div class="file-preview">
<div class="file-icon">
<span>{{ selectedFile.name.endsWith('.pdf') ? '📕' : '📘' }}</span>
</div>
<div class="file-info">
<div class="file-name">{{ selectedFile.name }}</div>
<div class="file-size">{{ formatSize(selectedFile.size) }}</div>
</div>
<button class="remove-btn" @click.stop="clearFile" id="resume-remove-btn" :disabled="uploading">✕</button>
</div>
<!-- Progress bar -->
<div class="progress-wrap" v-if="uploading">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
</div>
<span class="progress-label">Parsing resume… {{ Math.round(progress) }}%</span>
</div>
<!-- Error -->
<div class="upload-error" v-if="uploadError" id="resume-upload-error">
<span>⚠️ {{ uploadError }}</span>
</div>
<button
class="btn btn-primary"
id="resume-upload-btn"
:disabled="uploading"
@click.stop="uploadResume"
v-if="!uploading"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Upload & Parse
</button>
</template>
<!-- Success state -->
<template v-else-if="uploadedResume">
<div class="success-state">
<div class="success-icon">✅</div>
<h3>Resume Uploaded!</h3>
<p class="text-muted">{{ uploadedResume.filename }}</p>
<button class="btn btn-outline btn-sm" @click.stop="clearAll" id="resume-upload-another">
Upload Another
</button>
</div>
</template>
</div>
<!-- Tips -->
<div class="tips-card glass-card">
<h4 class="tips-title">💡 For Best Results</h4>
<ul class="tips-list">
<li>Use a standard resume format with clear section headings</li>
<li>Include a skills section with comma-separated tech keywords</li>
<li>Ensure text is selectable (not scanned images in PDF)</li>
<li>Include your name on the first line of the document</li>
</ul>
</div>
</div>
<!-- Parsed result panel -->
<div class="result-col animate-fade-up" style="animation-delay:200ms">
<!-- Skeleton while uploading -->
<template v-if="uploading">
<div class="result-card glass-card">
<div class="skeleton" style="height:28px; width:60%; margin-bottom:1rem"></div>
<div class="skeleton" style="height:16px; width:40%; margin-bottom:2rem"></div>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:1.5rem">
<div class="skeleton" style="height:24px; width:70px; border-radius:999px" v-for="i in 5" :key="i"></div>
</div>
<div class="skeleton" style="height:100px; margin-bottom:1rem"></div>
<div class="skeleton" style="height:100px"></div>
</div>
</template>
<!-- Parsed data -->
<template v-else-if="uploadedResume">
<div class="result-card glass-card" id="resume-parsed-result">
<!-- Header: name + contact -->
<div class="result-header">
<div class="result-avatar">
{{ getInitials(uploadedResume.parsed?.name) }}
</div>
<div style="flex:1">
<h2 class="result-name">{{ uploadedResume.parsed?.name || 'Unknown Candidate' }}</h2>
<div class="result-contact">
<span v-if="uploadedResume.parsed?.email">📧 {{ uploadedResume.parsed.email }}</span>
<span v-if="uploadedResume.parsed?.phone">📞 {{ uploadedResume.parsed.phone }}</span>
<a v-if="uploadedResume.parsed?.linkedin" :href="uploadedResume.parsed.linkedin" target="_blank" class="social-link">🔗 LinkedIn</a>
<a v-if="uploadedResume.parsed?.github" :href="uploadedResume.parsed.github" target="_blank" class="social-link">🐙 GitHub</a>
<a v-if="uploadedResume.parsed?.website" :href="uploadedResume.parsed.website" target="_blank" class="social-link">🌐 Website</a>
</div>
</div>
<div class="match-badge">
<span class="badge badge-green">✓ Parsed</span>
</div>
</div>
<!-- Objective -->
<div class="result-section" v-if="uploadedResume.parsed?.objective">
<h4 class="result-section-title">Objective / Summary</h4>
<p class="result-summary">{{ uploadedResume.parsed.objective }}</p>
</div>
<!-- Skills -->
<div class="result-section" v-if="uploadedResume.parsed?.skills?.length">
<h4 class="result-section-title">Skills ({{ uploadedResume.parsed.skills.length }})</h4>
<div class="skills-grid">
<span class="skill-tag" v-for="skill in uploadedResume.parsed.skills" :key="skill">{{ skill }}</span>
</div>
</div>
<!-- Projects -->
<div class="result-section" v-if="uploadedResume.parsed?.projects?.length">
<h4 class="result-section-title">Projects ({{ uploadedResume.parsed.projects.length }})</h4>
<div class="projects-list">
<div class="project-item" v-for="(proj, i) in uploadedResume.parsed.projects" :key="i">
<div class="project-name">{{ proj.name }}</div>
<p class="project-desc" v-if="proj.description">{{ proj.description }}</p>
<div class="project-tech" v-if="proj.tech?.length">
<span class="tech-tag" v-for="t in proj.tech" :key="t">{{ t }}</span>
</div>
</div>
</div>
</div>
<!-- Education -->
<div class="result-section" v-if="uploadedResume.parsed?.education?.length">
<h4 class="result-section-title">Education</h4>
<div class="edu-list">
<div class="edu-item" v-for="(edu, i) in uploadedResume.parsed.education" :key="i">
<div class="edu-degree">{{ edu.degree }}</div>
<div class="edu-meta">{{ edu.institution }} <span v-if="edu.year">· {{ edu.year }}</span></div>
</div>
</div>
</div>
<!-- Languages & Interests row -->
<div class="result-row">
<div class="result-section" v-if="uploadedResume.parsed?.languages?.length">
<h4 class="result-section-title">Languages</h4>
<div class="skills-grid">
<span class="skill-tag lang-tag" v-for="lang in uploadedResume.parsed.languages" :key="lang">{{ lang }}</span>
</div>
</div>
<div class="result-section" v-if="uploadedResume.parsed?.interests?.length">
<h4 class="result-section-title">Interests</h4>
<div class="skills-grid">
<span class="skill-tag interest-tag" v-for="interest in uploadedResume.parsed.interests" :key="interest">{{ interest }}</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="result-actions">
<RouterLink :to="`/questions?resume=${uploadedResume.id}`" class="btn btn-primary" id="resume-goto-questions">
🤖 Generate Questions
</RouterLink>
<RouterLink :to="`/schedule?resume=${uploadedResume.id}`" class="btn btn-ghost" id="resume-goto-schedule">
📅 Schedule Interview
</RouterLink>
</div>
</div>
</template>
<!-- Empty state -->
<template v-else>
<div class="result-empty glass-card">
<div class="empty-icon">📋</div>
<h3>Parsed Resume Appears Here</h3>
<p class="text-muted">Upload a resume on the left to see the extracted candidate information — name, skills, experience, and more.</p>
</div>
</template>
<!-- Previous resumes -->
<div class="prev-resumes" v-if="prevResumes.length > 0" id="resume-previous-list">
<h4 class="prev-title">Previously Uploaded</h4>
<div
class="prev-item glass-card"
v-for="r in prevResumes"
:key="r.id"
@click="selectExisting(r)"
:id="`resume-prev-${r.id}`"
>
<span class="prev-icon">{{ r.file_type === 'pdf' ? '📕' : '📘' }}</span>
<div class="prev-info">
<div class="prev-name">{{ r.filename }}</div>
<div class="prev-meta">
{{ r.parsed?.name || 'No name' }} ·
{{ r.parsed?.skills?.length || 0 }} skills ·
{{ r.parsed?.projects?.length || 0 }} projects ·
{{ formatDate(r.uploaded_at) }}
</div>
</div>
<div class="prev-actions">
<button
class="btn btn-ghost btn-sm"
:id="`resume-view-${r.id}`"
title="View Original PDF"
@click.stop="viewPdf(r.id, r.filename)"
>👁 View</button>
<button
class="btn btn-ghost btn-sm btn-delete"
@click.stop="deleteResume(r.id)"
:id="`resume-delete-${r.id}`"
title="Delete"
>🗑</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../api/index.js'
import { useToast } from '../composables/useToast.js'
const { addToast } = useToast()
const fileInput = ref(null)
const selectedFile = ref(null)
const isDragging = ref(false)
const uploading = ref(false)
const progress = ref(0)
const uploadError = ref('')
const uploadedResume = ref(null)
const prevResumes = ref([])
// ── File handling ──────────────────────────────────────────────
function handleDrop(e) {
isDragging.value = false
const file = e.dataTransfer.files[0]
if (file) validateAndSet(file)
}
function handleFileSelect(e) {
const file = e.target.files[0]
if (file) validateAndSet(file)
}
function validateAndSet(file) {
uploadError.value = ''
const allowed = ['application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword']
if (!allowed.includes(file.type)) {
uploadError.value = 'Only PDF and DOCX files are accepted.'
return
}
if (file.size > 10 * 1024 * 1024) {
uploadError.value = 'File too large. Max size is 10MB.'
return
}
selectedFile.value = file
}
function clearFile() {
selectedFile.value = null
uploadError.value = ''
if (fileInput.value) fileInput.value.value = ''
}
function clearAll() {
clearFile()
uploadedResume.value = null
}
// ── Upload ─────────────────────────────────────────────────────
async function uploadResume() {
if (!selectedFile.value) return
uploading.value = true
uploadError.value = ''
progress.value = 0
// Fake progress animation while API processes
const progressTimer = setInterval(() => {
if (progress.value < 88) progress.value += Math.random() * 12
}, 300)
try {
const formData = new FormData()
formData.append('file', selectedFile.value)
const { data } = await api.post('/api/resume/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
clearInterval(progressTimer)
progress.value = 100
addToast('Resume uploaded and parsed successfully!', 'success')
await new Promise(r => setTimeout(r, 400))
uploadedResume.value = data
await loadPreviousResumes()
} catch (err) {
clearInterval(progressTimer)
uploadError.value = err.response?.data?.detail || 'Upload failed. Please try again.'
addToast(uploadError.value, 'error')
} finally {
uploading.value = false
}
}
// ── Previous resumes ───────────────────────────────────────────
async function loadPreviousResumes() {
try {
const { data } = await api.get('/api/resume/')
prevResumes.value = data
} catch (_) {}
}
function selectExisting(r) {
uploadedResume.value = r
selectedFile.value = null
}
async function deleteResume(id) {
if (!confirm('Are you sure you want to delete this resume?')) return
try {
await api.delete(`/api/resume/${id}`)
addToast('Resume deleted.', 'info')
prevResumes.value = prevResumes.value.filter(r => r.id !== id)
if (uploadedResume.value?.id === id) uploadedResume.value = null
} catch (e) {
addToast('Failed to delete resume.', 'error')
}
}
async function viewPdf(id, filename) {
try {
const token = localStorage.getItem('hireai_token')
const response = await fetch(`http://localhost:8000/api/resume/${id}/download`, {
headers: { Authorization: `Bearer ${token}` }
})
if (!response.ok) throw new Error('Failed to fetch')
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const win = window.open(url, '_blank')
// Revoke object URL after tab opens to free memory
setTimeout(() => URL.revokeObjectURL(url), 10000)
} catch (e) {
addToast('Could not open the PDF. Please try again.', 'error')
}
}
// ── Utils ──────────────────────────────────────────────────────
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function formatDate(iso) {
if (!iso) return ''
return new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })
}
function getInitials(name) {
if (!name) return '?'
return name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()
}
onMounted(loadPreviousResumes)
</script>
<style scoped>
.resume-page { min-height: 80vh; }
.page-header { margin-bottom: var(--space-10); }
.resume-layout {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: var(--space-6);
align-items: start;
}
/* Upload zone */
.upload-zone {
min-height: 280px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
padding: var(--space-8);
border: 2px dashed var(--clr-border-2);
text-align: center;
cursor: pointer;
transition: all var(--transition-base);
}
.upload-zone:hover, .upload-zone.drag-over {
border-color: var(--clr-accent);
background: var(--clr-accent-dim);
}
.upload-zone.has-file { cursor: default; }
.upload-icon-wrap {
width: 72px; height: 72px;
background: var(--clr-surface-2);
border-radius: var(--radius-xl);
display: flex; align-items: center; justify-content: center;
color: var(--clr-accent);
border: 1px solid var(--clr-border-2);
}
.upload-icon-wrap.bounce { animation: float 0.8s ease-in-out infinite; }
.upload-title { font-size: var(--text-xl); font-weight: 700; }
.upload-sub { font-size: var(--text-sm); color: var(--clr-text-muted); }
/* File preview */
.file-preview {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
background: var(--clr-surface-2);
border: 1px solid var(--clr-border);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
}
.file-icon { font-size: 2rem; }
.file-info { flex: 1; text-align: left; }
.file-name { font-size: var(--text-sm); font-weight: 600; word-break: break-all; }
.file-size { font-size: var(--text-xs); color: var(--clr-text-muted); }
.remove-btn {
background: none; border: none;
color: var(--clr-text-muted); font-size: 1rem;
cursor: pointer; padding: 4px 8px; border-radius: 4px;
transition: color var(--transition-fast);
}
.remove-btn:hover { color: #F87171; }
/* Progress */
.progress-wrap { width: 100%; display: flex; flex-direction: column; gap: var(--space-2); }
.progress-bar {
height: 6px;
background: var(--clr-surface-2);
border-radius: var(--radius-full);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--clr-accent), #22D3EE);
border-radius: var(--radius-full);
transition: width 0.3s ease;
animation: pulse-glow 1.5s ease-in-out infinite;
}
.progress-label { font-size: var(--text-xs); color: var(--clr-text-secondary); text-align: left; }
/* Error */
.upload-error {
background: rgba(239,68,68,0.1);
border: 1px solid rgba(239,68,68,0.25);
color: #F87171;
font-size: var(--text-sm);
padding: var(--space-3);
border-radius: var(--radius-md);
width: 100%;
}
/* Success */
.success-state {
display: flex; flex-direction: column; align-items: center; gap: var(--space-3);
}
.success-icon { font-size: 2.5rem; }
/* Tips */
.tips-card {
margin-top: var(--space-4);
padding: var(--space-5);
}
.tips-title { font-size: var(--text-sm); font-weight: 700; margin-bottom: var(--space-3); color: var(--clr-accent); }
.tips-list {
display: flex; flex-direction: column; gap: var(--space-2);
font-size: var(--text-sm); color: var(--clr-text-secondary);
list-style: disc; padding-left: var(--space-4);
}
/* Result panel */
.result-col { display: flex; flex-direction: column; gap: var(--space-4); }
.result-card { padding: var(--space-6); }
.result-empty {
padding: var(--space-12) var(--space-8);
text-align: center;
display: flex; flex-direction: column; align-items: center; gap: var(--space-3);
}
.empty-icon { font-size: 3rem; }
.result-header {
display: flex; align-items: center; gap: var(--space-4);
margin-bottom: var(--space-5);
padding-bottom: var(--space-5);
border-bottom: 1px solid var(--clr-border);
}
.result-avatar {
width: 56px; height: 56px;
background: linear-gradient(135deg, var(--clr-accent), var(--clr-blue));
border-radius: var(--radius-lg);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 1.1rem; color: #000; flex-shrink: 0;
}
.result-name { font-size: var(--text-xl); font-weight: 700; margin-bottom: 4px; }
.result-contact {
display: flex; flex-wrap: wrap; gap: var(--space-3);
font-size: var(--text-sm); color: var(--clr-text-secondary);
}
.match-badge { margin-left: auto; }
.result-section { margin-bottom: var(--space-5); }
.result-section-title {
font-size: var(--text-xs); font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--clr-text-muted); margin-bottom: var(--space-3);
}
.result-summary { font-size: var(--text-sm); color: var(--clr-text-secondary); line-height: 1.7; }
.skills-grid { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.skill-tag {
background: var(--clr-surface-2);
border: 1px solid var(--clr-border);
color: var(--clr-text-secondary);
font-size: var(--text-xs);
font-weight: 500;
padding: 3px 10px;
border-radius: var(--radius-full);
transition: all var(--transition-fast);
}
.skill-tag:hover { border-color: var(--clr-accent); color: var(--clr-accent); }
.result-actions {
display: flex; gap: var(--space-3); flex-wrap: wrap;
padding-top: var(--space-5);
border-top: 1px solid var(--clr-border);
margin-top: var(--space-4);
}
/* Previous resumes */
.prev-title { font-size: var(--text-sm); font-weight: 600; color: var(--clr-text-muted); margin-bottom: var(--space-3); }
.prev-item {
display: flex; align-items: center; gap: var(--space-3);
padding: var(--space-3) var(--space-4);
cursor: pointer; margin-bottom: var(--space-2);
}
.prev-icon { font-size: 1.5rem; flex-shrink: 0; }
.prev-info { flex: 1; min-width: 0; }
.prev-name { font-size: var(--text-sm); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.prev-meta { font-size: var(--text-xs); color: var(--clr-text-muted); }
.prev-actions { display: flex; gap: var(--space-2); align-items: center; flex-shrink: 0; }
.btn-delete:hover { background: rgba(239, 68, 68, 0.1); color: #F87171; }
/* Projects */
.projects-list { display: flex; flex-direction: column; gap: var(--space-3); }
.project-item {
background: rgba(255,255,255,0.02);
border: 1px solid var(--clr-border);
border-left: 3px solid var(--clr-accent);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
}
.project-name { font-size: var(--text-sm); font-weight: 700; color: white; margin-bottom: 4px; }
.project-desc { font-size: var(--text-xs); color: var(--clr-text-secondary); line-height: 1.5; margin-bottom: var(--space-2); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.project-tech { display: flex; flex-wrap: wrap; gap: 4px; }
.tech-tag { font-size: 10px; padding: 2px 6px; background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); border-radius: 4px; color: var(--clr-accent); font-weight: 500; }
/* Education */
.edu-list { display: flex; flex-direction: column; gap: var(--space-2); }
.edu-item { background: rgba(255,255,255,0.02); border-radius: var(--radius-md); padding: var(--space-3); border: 1px solid var(--clr-border); }
.edu-degree { font-size: var(--text-sm); font-weight: 600; color: white; }
.edu-meta { font-size: var(--text-xs); color: var(--clr-text-muted); margin-top: 2px; }
/* Languages & Interests row */
.result-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
@media (max-width: 600px) { .result-row { grid-template-columns: 1fr; } }
.lang-tag { color: var(--clr-purple) !important; border-color: rgba(139,92,246,0.3) !important; background: rgba(139,92,246,0.08) !important; }
.interest-tag { color: var(--clr-amber) !important; border-color: rgba(245,158,11,0.3) !important; background: rgba(245,158,11,0.08) !important; }
.social-link { color: var(--clr-accent); font-size: var(--text-sm); text-decoration: none; transition: opacity 0.15s; }
.social-link:hover { opacity: 0.75; }
@media (max-width: 860px) {
.resume-layout { grid-template-columns: 1fr; }
}
</style>

View file

@ -0,0 +1,366 @@
<template>
<div class="schedule-page section">
<div class="container">
<div class="page-header animate-fade-up">
<div class="section-label">📅 Step 3 of 5</div>
<h1 class="section-heading">Interview Schedule</h1>
<p class="section-sub">Select a candidate and schedule their interview. An automated email invitation will be sent.</p>
</div>
<div class="schedule-layout">
<!-- Form Column -->
<div class="form-col animate-fade-up" style="animation-delay: 100ms;">
<div class="glass-card form-card">
<h3 class="form-title">Schedule New Interview</h3>
<form @submit.prevent="submitSchedule" class="sched-form">
<!-- Candidate Selection -->
<div class="form-group">
<label for="candidateSelect">Select Candidate *</label>
<select
id="candidateSelect"
v-model="form.resume_id"
class="form-control"
required
@change="onCandidateChange"
>
<option value="" disabled>-- Select a parsed resume --</option>
<option v-for="res in resumes" :key="res.id" :value="res.id">
{{ res.parsed?.name || res.filename }} ({{ res.parsed?.email || 'No email' }})
</option>
</select>
</div>
<!-- Candidate Name & Email (Auto-filled but editable) -->
<div class="form-row">
<div class="form-group">
<label for="cName">Candidate Name *</label>
<input type="text" id="cName" v-model="form.candidate_name" class="form-control" required />
</div>
<div class="form-group">
<label for="cEmail">Candidate Email *</label>
<input type="email" id="cEmail" v-model="form.candidate_email" class="form-control" required />
</div>
</div>
<!-- Date & Time -->
<div class="form-row">
<div class="form-group">
<label for="iDate">Interview Date *</label>
<input type="date" id="iDate" v-model="form.interview_date" class="form-control" required :min="today" />
</div>
<div class="form-group">
<label for="iTime">Time *</label>
<select id="iTime" v-model="form.interview_time" class="form-control" required>
<option value="" disabled>Select a slot</option>
<option v-for="slot in timeSlots" :key="slot" :value="slot">{{ slot }}</option>
</select>
</div>
</div>
<!-- Interview Type -->
<div class="form-group">
<label for="iType">Interview Type *</label>
<select id="iType" v-model="form.interview_type" class="form-control" required>
<option value="Technical">Technical Round</option>
<option value="HR">HR Round</option>
<option value="Managerial">Managerial Round</option>
<option value="System Design">System Design</option>
</select>
</div>
<!-- Meeting Link -->
<div class="form-group">
<label for="mLink">Meeting Link (Google Meet, Zoom, etc.)</label>
<input type="url" id="mLink" v-model="form.meeting_link" class="form-control" placeholder="https://meet.google.com/..." />
</div>
<!-- Notes -->
<div class="form-group">
<label for="notes">Notes to Candidate</label>
<textarea id="notes" v-model="form.notes" class="form-control" rows="3" placeholder="Any special instructions..."></textarea>
</div>
<!-- Submit -->
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Scheduling...' : 'Schedule & Send Email' }}
</button>
</div>
</form>
</div>
</div>
<!-- Schedule List Column -->
<div class="list-col animate-fade-up" style="animation-delay: 200ms;">
<h3 class="list-title">Upcoming Interviews</h3>
<div class="loading-state" v-if="loadingList">
<div class="spinner"></div> Loading...
</div>
<div class="empty-state glass-card" v-else-if="!schedules.length">
<div class="empty-icon">🛋️</div>
<h4>No interviews scheduled</h4>
<p class="text-muted">Schedule your first interview on the left.</p>
</div>
<div class="sched-list" v-else>
<div class="sched-card glass-card" v-for="sched in schedules" :key="sched.id">
<div class="sched-date-badge">
<div class="month">{{ formatMonth(sched.interview_date) }}</div>
<div class="day">{{ formatDay(sched.interview_date) }}</div>
</div>
<div class="sched-details">
<h4 class="sched-name">{{ sched.candidate_name }}</h4>
<div class="sched-meta">
<span class="type-tag">{{ sched.interview_type }}</span>
<span class="time-meta">🕒 {{ sched.interview_time }}</span>
</div>
<div class="sched-contact" v-if="sched.meeting_link">
🔗 <a :href="sched.meeting_link" target="_blank" rel="noopener">Meeting Link</a>
</div>
</div>
<div class="sched-actions">
<button class="btn-icon delete" @click="deleteSchedule(sched.id)" title="Cancel Interview">✕</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import api from '../api/index.js'
import { useToast } from '../composables/useToast.js'
const route = useRoute()
const { addToast } = useToast()
const form = ref({
resume_id: '',
candidate_name: '',
candidate_email: '',
interview_date: '',
interview_time: '',
interview_type: 'Technical',
meeting_link: '',
notes: ''
})
const timeSlots = [
'09:00 AM', '09:30 AM', '10:00 AM', '10:30 AM', '11:00 AM', '11:30 AM',
'01:00 PM', '01:30 PM', '02:00 PM', '02:30 PM', '03:00 PM', '03:30 PM',
'04:00 PM', '04:30 PM', '05:00 PM'
]
const today = computed(() => {
const d = new Date()
return d.toISOString().split('T')[0]
})
const resumes = ref([])
const schedules = ref([])
const loadingList = ref(false)
const saving = ref(false)
onMounted(async () => {
await loadResumes()
await loadSchedules()
// Pre-select resume if passed in query
if (route.query.resume && resumes.value.some(r => r.id === route.query.resume)) {
form.value.resume_id = route.query.resume
onCandidateChange()
}
})
async function loadResumes() {
try {
const { data } = await api.get('/api/resume/')
resumes.value = data
} catch (e) {
console.error('Failed to load resumes', e)
}
}
async function loadSchedules() {
loadingList.value = true
try {
const { data } = await api.get('/api/schedule/')
// Sort by date soonest first
schedules.value = data.sort((a, b) => new Date(a.interview_date) - new Date(b.interview_date))
} catch (e) {
console.error('Failed to load schedules', e)
} finally {
loadingList.value = false
}
}
function onCandidateChange() {
const selected = resumes.value.find(r => r.id === form.value.resume_id)
if (selected && selected.parsed) {
form.value.candidate_name = selected.parsed.name || ''
form.value.candidate_email = selected.parsed.email || ''
}
}
async function submitSchedule() {
saving.value = true
try {
await api.post('/api/schedule/', form.value)
addToast('Interview scheduled and email sent!', 'success')
// Reset form
form.value = {
resume_id: '', candidate_name: '', candidate_email: '',
interview_date: '', interview_time: '', interview_type: 'Technical',
meeting_link: '', notes: ''
}
await loadSchedules()
} catch (e) {
addToast(e.response?.data?.detail || 'Failed to schedule interview', 'error')
} finally {
saving.value = false
}
}
async function deleteSchedule(id) {
if (!confirm('Are you sure you want to cancel this interview?')) return
try {
await api.delete(`/api/schedule/${id}`)
addToast('Interview canceled.', 'info')
await loadSchedules()
} catch (e) {
console.error('Cancel failed', e)
addToast('Failed to cancel interview.', 'error')
}
}
// ── Date Formatting ──
function formatMonth(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short' }).toUpperCase()
}
function formatDay(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).getDate().toString().padStart(2, '0')
}
</script>
<style scoped>
.schedule-layout {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: var(--space-8);
align-items: start;
}
@media (max-width: 900px) {
.schedule-layout { grid-template-columns: 1fr; }
}
.form-card { padding: var(--space-6); }
.form-title {
font-size: var(--text-lg);
font-weight: 700;
margin-bottom: var(--space-5);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--clr-border);
}
.sched-form {
display: flex; flex-direction: column; gap: var(--space-5);
}
.form-row {
display: flex; gap: var(--space-4);
}
.form-row .form-group { flex: 1; }
.form-group { display: flex; flex-direction: column; gap: var(--space-2); }
.form-group label {
font-size: var(--text-sm); font-weight: 600; color: var(--clr-text-secondary);
}
.form-control {
width: 100%;
padding: 12px 16px;
background: var(--clr-surface-2);
border: 1px solid var(--clr-border);
border-radius: var(--radius-md);
color: var(--clr-text-primary);
font-size: var(--text-md);
transition: all var(--transition-fast);
}
.form-control:focus {
outline: none; border-color: var(--clr-accent);
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
.form-actions {
display: flex; align-items: center; gap: var(--space-4); margin-top: var(--space-2);
}
.save-msg { font-size: var(--text-sm); font-weight: 500; }
.save-msg.success { color: var(--clr-accent); }
.save-msg.error { color: #f87171; }
/* List Column */
.list-title {
font-size: var(--text-md); font-weight: 600; color: var(--clr-text-secondary); margin-bottom: var(--space-4);
}
.empty-state {
padding: var(--space-8) var(--space-4); text-align: center;
display: flex; flex-direction: column; align-items: center; gap: var(--space-2);
}
.empty-icon { font-size: 2.5rem; margin-bottom: var(--space-2); }
.sched-list { display: flex; flex-direction: column; gap: var(--space-4); }
.sched-card {
padding: var(--space-4);
display: flex; gap: var(--space-4); align-items: center;
}
.sched-date-badge {
background: var(--clr-surface-2);
border: 1px solid var(--clr-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-width: 60px;
}
.sched-date-badge .month { font-size: 10px; font-weight: 700; color: var(--clr-accent); text-transform: uppercase; letter-spacing: 1px; }
.sched-date-badge .day { font-size: var(--text-xl); font-weight: 800; }
.sched-details { flex: 1; min-width: 0; }
.sched-name { font-size: var(--text-md); font-weight: 700; margin-bottom: 4px; }
.sched-meta { display: flex; gap: var(--space-3); align-items: center; font-size: var(--text-xs); margin-bottom: var(--space-2); }
.type-tag {
background: rgba(59, 130, 246, 0.15); color: #60A5FA;
padding: 2px 8px; border-radius: var(--radius-full); font-weight: 600;
}
.time-meta { color: var(--clr-text-secondary); font-weight: 500; }
.sched-contact a {
font-size: var(--text-xs); color: var(--clr-accent); text-decoration: underline; text-underline-offset: 2px;
}
.sched-actions { display: flex; align-items: flex-start; height: 100%; }
.btn-icon {
background: none; border: none; cursor: pointer; padding: 6px; border-radius: 4px; color: var(--clr-text-muted);
transition: all var(--transition-fast);
}
.btn-icon:hover { background: rgba(239, 68, 68, 0.1); color: #F87171; }
</style>

7
frontend/vite.config.js Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})