first commit
This commit is contained in:
commit
7a72438fd1
74 changed files with 7548 additions and 0 deletions
298
README.md
Normal file
298
README.md
Normal 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
31
backend/.env
Normal 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
|
||||
BIN
backend/__pycache__/main.cpython-313.pyc
Normal file
BIN
backend/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# HireAI app package
|
||||
BIN
backend/app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/config.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/database.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/database.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/dependencies.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/dependencies.cpython-313.pyc
Normal file
Binary file not shown.
40
backend/app/config.py
Normal file
40
backend/app/config.py
Normal 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
39
backend/app/database.py
Normal 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
|
||||
58
backend/app/dependencies.py
Normal file
58
backend/app/dependencies.py
Normal 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
|
||||
BIN
backend/app/models/__pycache__/pronunciation.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/pronunciation.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/question.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/question.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/requirements.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/requirements.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/resume.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/resume.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/schedule.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/schedule.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/user.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/user.cpython-313.pyc
Normal file
Binary file not shown.
13
backend/app/models/pronunciation.py
Normal file
13
backend/app/models/pronunciation.py
Normal 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
|
||||
22
backend/app/models/question.py
Normal file
22
backend/app/models/question.py
Normal 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
|
||||
18
backend/app/models/requirements.py
Normal file
18
backend/app/models/requirements.py
Normal 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
|
||||
31
backend/app/models/resume.py
Normal file
31
backend/app/models/resume.py
Normal 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
|
||||
22
backend/app/models/schedule.py
Normal file
22
backend/app/models/schedule.py
Normal 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
|
||||
19
backend/app/models/user.py
Normal file
19
backend/app/models/user.py
Normal 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
|
||||
3
backend/app/routers/__init__.py
Normal file
3
backend/app/routers/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from app.routers import auth, resume, requirements, schedule, questions, pronunciation
|
||||
|
||||
__all__ = ["auth", "resume", "requirements", "schedule", "questions", "pronunciation"]
|
||||
BIN
backend/app/routers/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/app/routers/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/auth.cpython-313.pyc
Normal file
BIN
backend/app/routers/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/pronunciation.cpython-313.pyc
Normal file
BIN
backend/app/routers/__pycache__/pronunciation.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/questions.cpython-313.pyc
Normal file
BIN
backend/app/routers/__pycache__/questions.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/requirements.cpython-313.pyc
Normal file
BIN
backend/app/routers/__pycache__/requirements.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/resume.cpython-313.pyc
Normal file
BIN
backend/app/routers/__pycache__/resume.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/schedule.cpython-313.pyc
Normal file
BIN
backend/app/routers/__pycache__/schedule.cpython-313.pyc
Normal file
Binary file not shown.
130
backend/app/routers/auth.py
Normal file
130
backend/app/routers/auth.py
Normal 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"})
|
||||
79
backend/app/routers/pronunciation.py
Normal file
79
backend/app/routers/pronunciation.py
Normal 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
|
||||
84
backend/app/routers/questions.py
Normal file
84
backend/app/routers/questions.py
Normal 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
|
||||
98
backend/app/routers/requirements.py
Normal file
98
backend/app/routers/requirements.py
Normal 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")
|
||||
180
backend/app/routers/resume.py
Normal file
180
backend/app/routers/resume.py
Normal 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})
|
||||
110
backend/app/routers/schedule.py
Normal file
110
backend/app/routers/schedule.py
Normal 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")
|
||||
BIN
backend/app/services/__pycache__/email.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/email.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/gemini_service.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/gemini_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/ollama_service.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/ollama_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/resume_parser.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/resume_parser.cpython-313.pyc
Normal file
Binary file not shown.
51
backend/app/services/email.py
Normal file
51
backend/app/services/email.py
Normal 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
|
||||
104
backend/app/services/gemini_service.py
Normal file
104
backend/app/services/gemini_service.py
Normal 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}")
|
||||
267
backend/app/services/resume_parser.py
Normal file
267
backend/app/services/resume_parser.py
Normal 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
58
backend/main.py
Normal 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
14
backend/requirements.txt
Normal 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
24
frontend/.gitignore
vendored
Normal 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
3
frontend/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal 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
21
frontend/index.html
Normal 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
1449
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
frontend/package.json
Normal file
20
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
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
24
frontend/public/icons.svg
Normal 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
85
frontend/src/App.vue
Normal 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
34
frontend/src/api/index.js
Normal 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
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
100
frontend/src/components/AppFooter.vue
Normal file
100
frontend/src/components/AppFooter.vue
Normal 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>
|
||||
442
frontend/src/components/Navbar.vue
Normal file
442
frontend/src/components/Navbar.vue
Normal 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>
|
||||
|
||||
78
frontend/src/composables/useAuth.js
Normal file
78
frontend/src/composables/useAuth.js
Normal 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,
|
||||
}
|
||||
}
|
||||
25
frontend/src/composables/useToast.js
Normal file
25
frontend/src/composables/useToast.js
Normal 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
6
frontend/src/main.js
Normal 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')
|
||||
96
frontend/src/router/index.js
Normal file
96
frontend/src/router/index.js
Normal 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
|
||||
411
frontend/src/styles/main.css
Normal file
411
frontend/src/styles/main.css
Normal 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); }
|
||||
}
|
||||
|
||||
|
||||
77
frontend/src/views/AuthCallbackView.vue
Normal file
77
frontend/src/views/AuthCallbackView.vue
Normal 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>
|
||||
477
frontend/src/views/HomeView.vue
Normal file
477
frontend/src/views/HomeView.vue
Normal 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>
|
||||
218
frontend/src/views/LoginView.vue
Normal file
218
frontend/src/views/LoginView.vue
Normal 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>
|
||||
363
frontend/src/views/PronunciationView.vue
Normal file
363
frontend/src/views/PronunciationView.vue
Normal 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>
|
||||
316
frontend/src/views/QuestionsView.vue
Normal file
316
frontend/src/views/QuestionsView.vue
Normal 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>
|
||||
491
frontend/src/views/RequirementsView.vue
Normal file
491
frontend/src/views/RequirementsView.vue
Normal 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>
|
||||
638
frontend/src/views/ResumeUploadView.vue
Normal file
638
frontend/src/views/ResumeUploadView.vue
Normal 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>
|
||||
366
frontend/src/views/ScheduleView.vue
Normal file
366
frontend/src/views/ScheduleView.vue
Normal 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
7
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue