commit 7a72438fd16e72b0dfb4475a996533cc550cb61c Author: AnuragJangid18 Date: Thu Oct 8 02:50:35 2026 +0530 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0551f6 --- /dev/null +++ b/README.md @@ -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 . Verify it with: + +- Health check: +- Swagger UI: +- 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 . + +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. \ No newline at end of file diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..79a31bc --- /dev/null +++ b/backend/.env @@ -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 diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..75c81a7 Binary files /dev/null and b/backend/__pycache__/main.cpython-313.pyc differ diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e998d61 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# HireAI app package diff --git a/backend/app/__pycache__/__init__.cpython-313.pyc b/backend/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..4d68418 Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/app/__pycache__/config.cpython-313.pyc b/backend/app/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..fa6b496 Binary files /dev/null and b/backend/app/__pycache__/config.cpython-313.pyc differ diff --git a/backend/app/__pycache__/database.cpython-313.pyc b/backend/app/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000..9295ff0 Binary files /dev/null and b/backend/app/__pycache__/database.cpython-313.pyc differ diff --git a/backend/app/__pycache__/dependencies.cpython-313.pyc b/backend/app/__pycache__/dependencies.cpython-313.pyc new file mode 100644 index 0000000..f08ac15 Binary files /dev/null and b/backend/app/__pycache__/dependencies.cpython-313.pyc differ diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..dba9029 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..935b2f3 --- /dev/null +++ b/backend/app/database.py @@ -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 diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..eaea9e2 --- /dev/null +++ b/backend/app/dependencies.py @@ -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 diff --git a/backend/app/models/__pycache__/pronunciation.cpython-313.pyc b/backend/app/models/__pycache__/pronunciation.cpython-313.pyc new file mode 100644 index 0000000..479e7d3 Binary files /dev/null and b/backend/app/models/__pycache__/pronunciation.cpython-313.pyc differ diff --git a/backend/app/models/__pycache__/question.cpython-313.pyc b/backend/app/models/__pycache__/question.cpython-313.pyc new file mode 100644 index 0000000..681f3e6 Binary files /dev/null and b/backend/app/models/__pycache__/question.cpython-313.pyc differ diff --git a/backend/app/models/__pycache__/requirements.cpython-313.pyc b/backend/app/models/__pycache__/requirements.cpython-313.pyc new file mode 100644 index 0000000..68947f2 Binary files /dev/null and b/backend/app/models/__pycache__/requirements.cpython-313.pyc differ diff --git a/backend/app/models/__pycache__/resume.cpython-313.pyc b/backend/app/models/__pycache__/resume.cpython-313.pyc new file mode 100644 index 0000000..48b74ef Binary files /dev/null and b/backend/app/models/__pycache__/resume.cpython-313.pyc differ diff --git a/backend/app/models/__pycache__/schedule.cpython-313.pyc b/backend/app/models/__pycache__/schedule.cpython-313.pyc new file mode 100644 index 0000000..4fadcc5 Binary files /dev/null and b/backend/app/models/__pycache__/schedule.cpython-313.pyc differ diff --git a/backend/app/models/__pycache__/user.cpython-313.pyc b/backend/app/models/__pycache__/user.cpython-313.pyc new file mode 100644 index 0000000..06df442 Binary files /dev/null and b/backend/app/models/__pycache__/user.cpython-313.pyc differ diff --git a/backend/app/models/pronunciation.py b/backend/app/models/pronunciation.py new file mode 100644 index 0000000..3a20498 --- /dev/null +++ b/backend/app/models/pronunciation.py @@ -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 diff --git a/backend/app/models/question.py b/backend/app/models/question.py new file mode 100644 index 0000000..eff8a4a --- /dev/null +++ b/backend/app/models/question.py @@ -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 diff --git a/backend/app/models/requirements.py b/backend/app/models/requirements.py new file mode 100644 index 0000000..3e43e6c --- /dev/null +++ b/backend/app/models/requirements.py @@ -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 diff --git a/backend/app/models/resume.py b/backend/app/models/resume.py new file mode 100644 index 0000000..0cb55f4 --- /dev/null +++ b/backend/app/models/resume.py @@ -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 diff --git a/backend/app/models/schedule.py b/backend/app/models/schedule.py new file mode 100644 index 0000000..3a107a6 --- /dev/null +++ b/backend/app/models/schedule.py @@ -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 diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..e6e012b --- /dev/null +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..239684c --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1,3 @@ +from app.routers import auth, resume, requirements, schedule, questions, pronunciation + +__all__ = ["auth", "resume", "requirements", "schedule", "questions", "pronunciation"] diff --git a/backend/app/routers/__pycache__/__init__.cpython-313.pyc b/backend/app/routers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..38f78fd Binary files /dev/null and b/backend/app/routers/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/app/routers/__pycache__/auth.cpython-313.pyc b/backend/app/routers/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000..7a07eff Binary files /dev/null and b/backend/app/routers/__pycache__/auth.cpython-313.pyc differ diff --git a/backend/app/routers/__pycache__/pronunciation.cpython-313.pyc b/backend/app/routers/__pycache__/pronunciation.cpython-313.pyc new file mode 100644 index 0000000..6cda928 Binary files /dev/null and b/backend/app/routers/__pycache__/pronunciation.cpython-313.pyc differ diff --git a/backend/app/routers/__pycache__/questions.cpython-313.pyc b/backend/app/routers/__pycache__/questions.cpython-313.pyc new file mode 100644 index 0000000..2e7003f Binary files /dev/null and b/backend/app/routers/__pycache__/questions.cpython-313.pyc differ diff --git a/backend/app/routers/__pycache__/requirements.cpython-313.pyc b/backend/app/routers/__pycache__/requirements.cpython-313.pyc new file mode 100644 index 0000000..07d3652 Binary files /dev/null and b/backend/app/routers/__pycache__/requirements.cpython-313.pyc differ diff --git a/backend/app/routers/__pycache__/resume.cpython-313.pyc b/backend/app/routers/__pycache__/resume.cpython-313.pyc new file mode 100644 index 0000000..f4df2f1 Binary files /dev/null and b/backend/app/routers/__pycache__/resume.cpython-313.pyc differ diff --git a/backend/app/routers/__pycache__/schedule.cpython-313.pyc b/backend/app/routers/__pycache__/schedule.cpython-313.pyc new file mode 100644 index 0000000..2dd707b Binary files /dev/null and b/backend/app/routers/__pycache__/schedule.cpython-313.pyc differ diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..92abcb8 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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"}) diff --git a/backend/app/routers/pronunciation.py b/backend/app/routers/pronunciation.py new file mode 100644 index 0000000..34ebf93 --- /dev/null +++ b/backend/app/routers/pronunciation.py @@ -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 diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py new file mode 100644 index 0000000..f440572 --- /dev/null +++ b/backend/app/routers/questions.py @@ -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 diff --git a/backend/app/routers/requirements.py b/backend/app/routers/requirements.py new file mode 100644 index 0000000..986e373 --- /dev/null +++ b/backend/app/routers/requirements.py @@ -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") diff --git a/backend/app/routers/resume.py b/backend/app/routers/resume.py new file mode 100644 index 0000000..4a9c503 --- /dev/null +++ b/backend/app/routers/resume.py @@ -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}) diff --git a/backend/app/routers/schedule.py b/backend/app/routers/schedule.py new file mode 100644 index 0000000..48666e9 --- /dev/null +++ b/backend/app/routers/schedule.py @@ -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") diff --git a/backend/app/services/__pycache__/email.cpython-313.pyc b/backend/app/services/__pycache__/email.cpython-313.pyc new file mode 100644 index 0000000..9d81a5c Binary files /dev/null and b/backend/app/services/__pycache__/email.cpython-313.pyc differ diff --git a/backend/app/services/__pycache__/gemini_service.cpython-313.pyc b/backend/app/services/__pycache__/gemini_service.cpython-313.pyc new file mode 100644 index 0000000..51b68ec Binary files /dev/null and b/backend/app/services/__pycache__/gemini_service.cpython-313.pyc differ diff --git a/backend/app/services/__pycache__/ollama_service.cpython-313.pyc b/backend/app/services/__pycache__/ollama_service.cpython-313.pyc new file mode 100644 index 0000000..c95855a Binary files /dev/null and b/backend/app/services/__pycache__/ollama_service.cpython-313.pyc differ diff --git a/backend/app/services/__pycache__/resume_parser.cpython-313.pyc b/backend/app/services/__pycache__/resume_parser.cpython-313.pyc new file mode 100644 index 0000000..e22efe0 Binary files /dev/null and b/backend/app/services/__pycache__/resume_parser.cpython-313.pyc differ diff --git a/backend/app/services/email.py b/backend/app/services/email.py new file mode 100644 index 0000000..0d0d6b3 --- /dev/null +++ b/backend/app/services/email.py @@ -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""" +
+

Interview Scheduled

+

Hi {candidate_name},

+

Your interview has been scheduled for {date} at {time}.

+

Meeting Link: {meeting_link}

+ {f'

Notes: {notes}

' if notes else ''} +
+

Best regards,
HireAI Team

+
+ """ + + 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 diff --git a/backend/app/services/gemini_service.py b/backend/app/services/gemini_service.py new file mode 100644 index 0000000..a433b02 --- /dev/null +++ b/backend/app/services/gemini_service.py @@ -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}") diff --git a/backend/app/services/resume_parser.py b/backend/app/services/resume_parser.py new file mode 100644 index 0000000..6fc102a --- /dev/null +++ b/backend/app/services/resume_parser.py @@ -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": []} diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..4b4b79a --- /dev/null +++ b/backend/main.py @@ -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"} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2d3b5f0 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/frontend/README.md @@ -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 ` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..6e4fe8c --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1449 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3514dcb --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..0dbc2c9 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..30d3f2e --- /dev/null +++ b/frontend/src/api/index.js @@ -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 diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/AppFooter.vue b/frontend/src/components/AppFooter.vue new file mode 100644 index 0000000..ccfa0c1 --- /dev/null +++ b/frontend/src/components/AppFooter.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/frontend/src/components/Navbar.vue b/frontend/src/components/Navbar.vue new file mode 100644 index 0000000..9387528 --- /dev/null +++ b/frontend/src/components/Navbar.vue @@ -0,0 +1,442 @@ + + + + + + diff --git a/frontend/src/composables/useAuth.js b/frontend/src/composables/useAuth.js new file mode 100644 index 0000000..f7e095f --- /dev/null +++ b/frontend/src/composables/useAuth.js @@ -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, + } +} diff --git a/frontend/src/composables/useToast.js b/frontend/src/composables/useToast.js new file mode 100644 index 0000000..651e645 --- /dev/null +++ b/frontend/src/composables/useToast.js @@ -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 + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..9661972 --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..277dcc2 --- /dev/null +++ b/frontend/src/router/index.js @@ -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 diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 0000000..2dbf5d7 --- /dev/null +++ b/frontend/src/styles/main.css @@ -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); } +} + + diff --git a/frontend/src/views/AuthCallbackView.vue b/frontend/src/views/AuthCallbackView.vue new file mode 100644 index 0000000..25d6602 --- /dev/null +++ b/frontend/src/views/AuthCallbackView.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue new file mode 100644 index 0000000..b05b599 --- /dev/null +++ b/frontend/src/views/HomeView.vue @@ -0,0 +1,477 @@ + + + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..7f57dbb --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/frontend/src/views/PronunciationView.vue b/frontend/src/views/PronunciationView.vue new file mode 100644 index 0000000..d6bf192 --- /dev/null +++ b/frontend/src/views/PronunciationView.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/frontend/src/views/QuestionsView.vue b/frontend/src/views/QuestionsView.vue new file mode 100644 index 0000000..ffd156e --- /dev/null +++ b/frontend/src/views/QuestionsView.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/frontend/src/views/RequirementsView.vue b/frontend/src/views/RequirementsView.vue new file mode 100644 index 0000000..24ffdbf --- /dev/null +++ b/frontend/src/views/RequirementsView.vue @@ -0,0 +1,491 @@ + + + + + diff --git a/frontend/src/views/ResumeUploadView.vue b/frontend/src/views/ResumeUploadView.vue new file mode 100644 index 0000000..79b4d29 --- /dev/null +++ b/frontend/src/views/ResumeUploadView.vue @@ -0,0 +1,638 @@ + + + + + diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue new file mode 100644 index 0000000..cf2d35d --- /dev/null +++ b/frontend/src/views/ScheduleView.vue @@ -0,0 +1,366 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..bbcf80c --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], +})