From 7a72438fd16e72b0dfb4475a996533cc550cb61c Mon Sep 17 00:00:00 2001 From: AnuragJangid18 Date: Thu, 8 Oct 2026 02:50:35 +0530 Subject: [PATCH] first commit --- README.md | 298 ++++ backend/.env | 31 + backend/__pycache__/main.cpython-313.pyc | Bin 0 -> 2286 bytes backend/app/__init__.py | 1 + .../app/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 166 bytes .../app/__pycache__/config.cpython-313.pyc | Bin 0 -> 1705 bytes .../app/__pycache__/database.cpython-313.pyc | Bin 0 -> 1705 bytes .../__pycache__/dependencies.cpython-313.pyc | Bin 0 -> 2916 bytes backend/app/config.py | 40 + backend/app/database.py | 39 + backend/app/dependencies.py | 58 + .../__pycache__/pronunciation.cpython-313.pyc | Bin 0 -> 924 bytes .../__pycache__/question.cpython-313.pyc | Bin 0 -> 1388 bytes .../__pycache__/requirements.cpython-313.pyc | Bin 0 -> 1160 bytes .../models/__pycache__/resume.cpython-313.pyc | Bin 0 -> 2957 bytes .../__pycache__/schedule.cpython-313.pyc | Bin 0 -> 1254 bytes .../models/__pycache__/user.cpython-313.pyc | Bin 0 -> 1076 bytes backend/app/models/pronunciation.py | 13 + backend/app/models/question.py | 22 + backend/app/models/requirements.py | 18 + backend/app/models/resume.py | 31 + backend/app/models/schedule.py | 22 + backend/app/models/user.py | 19 + backend/app/routers/__init__.py | 3 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 345 bytes .../routers/__pycache__/auth.cpython-313.pyc | Bin 0 -> 5782 bytes .../__pycache__/pronunciation.cpython-313.pyc | Bin 0 -> 4065 bytes .../__pycache__/questions.cpython-313.pyc | Bin 0 -> 4254 bytes .../__pycache__/requirements.cpython-313.pyc | Bin 0 -> 4917 bytes .../__pycache__/resume.cpython-313.pyc | Bin 0 -> 7284 bytes .../__pycache__/schedule.cpython-313.pyc | Bin 0 -> 5398 bytes backend/app/routers/auth.py | 130 ++ backend/app/routers/pronunciation.py | 79 + backend/app/routers/questions.py | 84 + backend/app/routers/requirements.py | 98 ++ backend/app/routers/resume.py | 180 ++ backend/app/routers/schedule.py | 110 ++ .../__pycache__/email.cpython-313.pyc | Bin 0 -> 2683 bytes .../gemini_service.cpython-313.pyc | Bin 0 -> 5050 bytes .../ollama_service.cpython-313.pyc | Bin 0 -> 8550 bytes .../__pycache__/resume_parser.cpython-313.pyc | Bin 0 -> 13856 bytes backend/app/services/email.py | 51 + backend/app/services/gemini_service.py | 104 ++ backend/app/services/resume_parser.py | 267 +++ backend/main.py | 58 + backend/requirements.txt | 14 + frontend/.gitignore | 24 + frontend/.vscode/extensions.json | 3 + frontend/README.md | 5 + frontend/index.html | 21 + frontend/package-lock.json | 1449 +++++++++++++++++ frontend/package.json | 20 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.vue | 85 + frontend/src/api/index.js | 34 + frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/vite.svg | 1 + frontend/src/components/AppFooter.vue | 100 ++ frontend/src/components/Navbar.vue | 442 +++++ frontend/src/composables/useAuth.js | 78 + frontend/src/composables/useToast.js | 25 + frontend/src/main.js | 6 + frontend/src/router/index.js | 96 ++ frontend/src/styles/main.css | 411 +++++ frontend/src/views/AuthCallbackView.vue | 77 + frontend/src/views/HomeView.vue | 477 ++++++ frontend/src/views/LoginView.vue | 218 +++ frontend/src/views/PronunciationView.vue | 363 +++++ frontend/src/views/QuestionsView.vue | 316 ++++ frontend/src/views/RequirementsView.vue | 491 ++++++ frontend/src/views/ResumeUploadView.vue | 638 ++++++++ frontend/src/views/ScheduleView.vue | 366 +++++ frontend/vite.config.js | 7 + 74 files changed, 7548 insertions(+) create mode 100644 README.md create mode 100644 backend/.env create mode 100644 backend/__pycache__/main.cpython-313.pyc create mode 100644 backend/app/__init__.py create mode 100644 backend/app/__pycache__/__init__.cpython-313.pyc create mode 100644 backend/app/__pycache__/config.cpython-313.pyc create mode 100644 backend/app/__pycache__/database.cpython-313.pyc create mode 100644 backend/app/__pycache__/dependencies.cpython-313.pyc create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/dependencies.py create mode 100644 backend/app/models/__pycache__/pronunciation.cpython-313.pyc create mode 100644 backend/app/models/__pycache__/question.cpython-313.pyc create mode 100644 backend/app/models/__pycache__/requirements.cpython-313.pyc create mode 100644 backend/app/models/__pycache__/resume.cpython-313.pyc create mode 100644 backend/app/models/__pycache__/schedule.cpython-313.pyc create mode 100644 backend/app/models/__pycache__/user.cpython-313.pyc create mode 100644 backend/app/models/pronunciation.py create mode 100644 backend/app/models/question.py create mode 100644 backend/app/models/requirements.py create mode 100644 backend/app/models/resume.py create mode 100644 backend/app/models/schedule.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/__pycache__/__init__.cpython-313.pyc create mode 100644 backend/app/routers/__pycache__/auth.cpython-313.pyc create mode 100644 backend/app/routers/__pycache__/pronunciation.cpython-313.pyc create mode 100644 backend/app/routers/__pycache__/questions.cpython-313.pyc create mode 100644 backend/app/routers/__pycache__/requirements.cpython-313.pyc create mode 100644 backend/app/routers/__pycache__/resume.cpython-313.pyc create mode 100644 backend/app/routers/__pycache__/schedule.cpython-313.pyc create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/pronunciation.py create mode 100644 backend/app/routers/questions.py create mode 100644 backend/app/routers/requirements.py create mode 100644 backend/app/routers/resume.py create mode 100644 backend/app/routers/schedule.py create mode 100644 backend/app/services/__pycache__/email.cpython-313.pyc create mode 100644 backend/app/services/__pycache__/gemini_service.cpython-313.pyc create mode 100644 backend/app/services/__pycache__/ollama_service.cpython-313.pyc create mode 100644 backend/app/services/__pycache__/resume_parser.cpython-313.pyc create mode 100644 backend/app/services/email.py create mode 100644 backend/app/services/gemini_service.py create mode 100644 backend/app/services/resume_parser.py create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 frontend/.gitignore create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/index.js create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/AppFooter.vue create mode 100644 frontend/src/components/Navbar.vue create mode 100644 frontend/src/composables/useAuth.js create mode 100644 frontend/src/composables/useToast.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/styles/main.css create mode 100644 frontend/src/views/AuthCallbackView.vue create mode 100644 frontend/src/views/HomeView.vue create mode 100644 frontend/src/views/LoginView.vue create mode 100644 frontend/src/views/PronunciationView.vue create mode 100644 frontend/src/views/QuestionsView.vue create mode 100644 frontend/src/views/RequirementsView.vue create mode 100644 frontend/src/views/ResumeUploadView.vue create mode 100644 frontend/src/views/ScheduleView.vue create mode 100644 frontend/vite.config.js 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 0000000000000000000000000000000000000000..75c81a708c2d2d4619a45e37692be50e2839c429 GIT binary patch literal 2286 zcmbVN&2Jk;6rb^aIriFdypGc*L71d8acJ#?w)8WtLMucZ5m1(vLsyD6yOVg^^{zX+ zPH9f1J%H3pa;VCk1N6kdl88vQR*j$n3H9d4r}o5~^%o#2q^vY=-h1=j%x~VuY%7t7 zA^6lEqIYhJ2>s54?l;h3T8|QE7fBYhE& z!w$vndVkjBmD#@3<<8+Y_g1gOk-U;e@)|snSL#S!L;ve_rW>RFtSa4jshQD!hDRad z|7AVgv(Em_dTe7Pf9w_8G>-%66W#L{zE}h{dHwSh^)t&fZE|0y1T>27J#Y-eb$s%* zU(p?XgV5)ZAO;$T82+lc7DNr(^$2iaE^iQj)g!)dIU8PH40yffmjjU!uT~*}Ov$~P zMM;G?z86G2qfE@2O@e`YHRAb}>v%z;N?oVs7?#fDfYYm0qf;MY3^<@1G*D|Dm`Rj_ zPP*M&@7DDqbW|BEH}O+$S&+d>Md$fV^b{)dHrpZm-fq2tqG)*>hIF1=$9cR=p_w66 zGzF8#j>`q}*Dq-|JwiQAcWPAEZV>OT?^d<1$Xf0O*(A1GW!Kco7KK=7Yr1ilIHp$7 zEvHa@5JYWjop@E1v z$!A`B$H4Saat?3f)BH|$4*!UY{7>Q>{u$5VH?dD4#0!f#Na@zZ!s6+wd!JBZ=GsVd zRa^JhU0TU?AiWXh3bTdTH$u^L4R1>)w# z$7+q*!9eK5*sbBR?^iDsi?(a%cG>m(OXud!pZPMtGkHEp=(g?NUv;UqVL`Ts+M)s8 z5GzZ!p#a*d3h~RX*%Qk|H{s*}7Y(?2?-PJ~45P&Y(yj&-s}Yq`c~vOzCs3F_ahYad zoel#DBwBME$Oz4Xl;^{g(&KDJ^nBf~c>&LE^&C-_j}{C>(`=ciuItljU<)w3cYv^L zHAR#fZz!XU^jIS`ayTH!ViO61_}b&a;!!k)x1=J#xBc=zphFF}p|j2V&}VT6G`CDo zz)BEt?}kX)WC~PmIAVKG${y+qFg#Y94A5_KX4~A2kL~hf9Snkmm-FL+v<`i)S1rmq zAQ(&Nkf4Csd z6x6NxkS@yphBVFyt8kiNB+1Ai5O@GO4@Fo_VJ3p4ZknroNP)Z#?|~1U6P8)hatym> zlGXMB@UVmj1sps(VQRu~{5w?9D;1Z$Ggm1K6l-7aGSDW6F+M;$TX7K!-OP7 z9&*hL!h9X2_fWdQryI&xU76lfrgvu+cGDj>vZDYcAH6$vtIqAD!sy z=IhGTo-(zMrr8CBZ%4iv`A&H}ei#w4$ZnWyG7j)`){g%I5F;3mv&BrvQC5e40i+xA AOaK4? literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d6841812e7a6df554f6116f80c6fe30e1d312ee GIT binary patch literal 166 zcmey&%ge<81f@M3S)xGtF^B^Lj8MjB79e9PLpp;dqu)w~A|@d3Gf3)|tFu*1XmM&$ zaZF-fX;ET~OKNd;Nq#|0cxsY@OKMqaPJTgdYF qd}dx|NqoFsLFFwDo80`A(wtPgB37WOAX|$;jE~HWjEqIhKo$Ul0xC`b literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/config.cpython-313.pyc b/backend/app/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fa6b4964781563cb15c0fc547e781ed8710b9e8a GIT binary patch literal 1705 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 Z=lh-!F20j^Ve7;zoy4o(2(WXE{0`p+fBpaf literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..479e7d3ed05fc119c87322482e474785fca9eda0 GIT binary patch literal 924 zcmaJ=&ubGw6rRn_ZZ>JtwDwn2EJctK58k|pcxVIC)>dT|F9X9SJ8jo)X4jdCl5;`v z=B41>lmCq%NEqnJQ*J`y-S>9eVnJNUH*ene=Dm6En@k#wI)QKXOYnNtA>=1+E3L^ zWW5y&-wo4}9Q=UWfhOD&XUksPRqb%Z`P zS|Wml_&`SW z#>w_*ZsBBa)Li_u^>O%}wnp=d@1LsWi|Bvra~J6U=;MPPg+3t#6}+}@n^#omY>sCj zgRLa7t@o6Gw-Cc9iUGj9)f`rDBFrN!AY4OOM8F2r5&{mayy^;CtqZ{98cAp|m^pbo zns5D}tuhjW_S;oZei%m*G*I~JtZ#-njIz+DtWXTBNO`r44IY+H;E`H`lkm?X8eiNm l*KwRPvUWyR&&aJQrOwjl+LXXDovk?6zpZTiCa@N__yaFn*}4D# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..681f3e62dc047022f70de65e26ad3a3b7c98b7de GIT binary patch literal 1388 zcma)6&uiR96dvu4w4;r8*NNkL6H?a>gjhM`5Xd2=^w0)cuYZK1mNp;|vNX1pXtna2 zQIov|_R#jWJp_8{y?;(I7%>of$jP^WH@6=8W_Gu1!O#fw&71e;$M@d*q>EOoN$`Q+ z*|WbbLf-1h{IO@oMUKX6;uGK6Aw3JM4Q0>__}(DlnO9SEHx*K`CxuK0d6Z$8 zC6VMZ?elng(G+L5l8XT6uSr0_@(I*@3+i`BKm*&a-NlHp>i=3)TfPlW& z*8sNQW5(x#t9h!WZ%QsSpKZze=X#rBCo`Ao@RtRTB~QXA7cV#{dYtA`IeR=w%f4zR z>EU4-mzf+Y_gfwx=V=^eip7}J(Ex^jkbSSISQzF}pNFAphGBn@l$o}d!tkVwvRR}P zhKDH#nWZ_;2NALRkRFth>s41Vc7ZQZ zyI+19d?z@FAj(UKg2!AumxChso*!&H=0EamQ1p2&gX0v^{3tkx;^#b1f~Y70B+4@p z%u8{<7y`p14QK}9KEh9=YF{1gRjb`o+O3+c(RQ`5^{O{!zpeb*I;C6Hy7%hqpO44l zm;M>`;Kuxb%e#FK!ynRZpMrB_dyZ<&d*^exou4n^e&qreC0094R2%q7nF7}-;@s3_ z-)q1%O=CsFbq%W;KGLwJp{qfca7y>4wrgXurC?c1G8xEA^tw6Z-)p zACj06w2`r~Y$I)y^P-pohO%xL2{V+iivnvel;KG!&y6-}Sb#=}z1NwqBLVmXLAmpW z3e}8(qfk-Xa#ot=|CEO6V~h$MZCzHWO&!pnNr){3tQ5O4`l@Ozk9OxZJEdz?XJx$m zXJ_-2ZdS|PGuoXNQE%P%T*ZoE66Gju&_4s^$YFsB1;fC60EU6#Vwz*_{cqP4`)vBP zeGVR;Vc{ztAiS$vmi30L|3x<6kdG(Sw(gA|O$ZK?cFpRJw*K> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..68947f2be044b1114d1ee3b0373c23b8532b4b7a GIT binary patch literal 1160 zcmZ`(L2KMb6dtW+CGE!R-6l@D4l%gFu2B!|q4ZKp4oOJKI<-qhX$w;ZS(-XyYe&i( zZRp+#KI9ghL%==un12&6gk>P)(35WkyZ64QwSx<-K%d_G^k&}7H=lIc>vb7ke>{)& zUU`iDNy)|4J`YX>1}|B{5^s}@Jn%*>XsuG-+Vn>p_^1u-CigdkQ2@az8?xIh;Wt^* z7Ty>Bd>#lJHb6EM;d?gPjpEJ;mBcOAxtm(`O_{60wI66zwUAOq1dNR?V#>&+8p*aa~GS`8!P|~Dh1ue=4>eotv zRwh$&s#36SFis8bq=c!o`?@HsyJj@LtxNeheX7pME!W>GcVwLB%8UV zbX_UOWj?`X(k)2&WRe!t6|=sSzi6;^p^Y*n627g0*wmP^PDxKIB`nbLd~IszVFV-v zEE4nyn$297($auTrgaT^XHqK~IlzKbyqWv<-k0KgtH6rXOdu8amEEt)O8lU9R`08) zswgW`M?BV`&92x+P zJLW61+iMi&N1wmY$9!$JzVVuWc(ins{l0w6H)iYcZ`+4I9%V0ne#2u}zL@a8T|*uI z-!+YYd^&XzTzIF68pk97gy@9G^)EYNds3f}j;^J17fw(ei9t65h4#%+N!OiJWMzsQ zEw|zbKA>e@CAdbgL~xzp20=(LBp~hQd%8){tqY)zmMT!L=*q#@v)brMb$J<4W2B}_X8Zk1@*MTQ<^)08)5ci ngui^x^WL%bckH9LY~?Iyd!HQsbjI*;HsId!q4}HPqgl>B;xryR literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..48b74ef86827d6b88c0a2e95cd0f85f297ac9781 GIT binary patch literal 2957 zcmb_e-D}%c6qoEPTmH&No2G8kIL=p7Z)>|z3R}Bw(DW_Uy4WTF;%chVAVc0CD;_O={`0s*bK1QJ~kuR9IzvOY_>L9 z8r#E{lw_W|X8Lr)tx~%bZ7-!#maB&0n00Cx zJZ%_tx7x7L&Kkyd4b$$8j2gz4#r(jw9O}4+!7}g)2Dz7b+z%Lsano_!z{H68FrLNn z9Kr}rI$;<-4ps~^2$;3m08e1nD2(mHPvUDUm-R1w%6#2)8qCzMQ-3>fJ^d@XS-egk zQ``0G)Cu&57PFk1zG+srsZ-TW&(pzWYWq5)expv$dpj%z3ktwE_?H2GmO9h3%^SNP zhbyhCZ8F=LnQPwK{W`3)zG;)WBV%(*&Gj(n9xOII&-Y;Xo#r{g(vZ_6 z&)ujtDk7XBr5MUGPgH4*QOYxx<3n~E7;R2=sL4bqOBptX?v5iILpY9b8Ub^~CJ;^_ z%pepHCJ`_Z(WmConmPb{t^WZ4MDTR6d2{!6sI@lQq}X{Ir8ZgWOck2fch80wTjySr zLbMRBF+67fr#3D?X7%)84IR`*W=L&hMQvp6GFKET76!(o%n6D*(q%fJm4_(J$~KQT zm>q>NH#Cg%@aLmpL~*kP1T1Q{h%hXmMI1mt4-O>xHfU{9I1O zS9bH^LJK506NMKS8y;Rsez%f@kf-&`VaTJ_6Jy!FkSDPn!Mt4GJhjJ-05{red8r;Z z2Hbd$OZT{Az#Z>#nI1RMw_di#o#^9o$Y~>NQcplb9TiVw>?9<9oM-Qv%%|0RqAl#9 ztji2j$Q4>QEt@OeL)U@!=(`omw0)j(MMJk9Q=ag*EgPsbJ?yJIMGxwct6n52_>XNm zwT4-Pwwkh>fI<%nd@ga7nco`$eG)`^%Kv9QodpM33E@2iA*%P$!Wv}f5JY!7kCv#? z3uqM)K1BEk;UdB?8J@ub1Pq7&5&-IYP=*4X%YMV@TmP9+`+(}Pt>Lx*1HjQntiyJz#ZVQ(5*x0t#5OA| z(Lg{wiY9a|IH6`uWl3GtiBP1X3FV3dL>dkdnM*NzDFid6WualKr5sO1O1skt_VA57 zW>vYmW!V(F4NswE1Unuy?}lf)W|dZr%^jZbgi8BJGbr;^jak(#->?Qx7f{&Z+Q;+_ zek`6Xv^Z00q@?h%Ls!nPxXzYU+e2;K7h)`z62sKU$2`|-*l*1Qd3h)!|u5C3RrRW+kk?e=>MsN2Zp~kB?U2IO`kHb`Z38x@nT>UEmpjtKa^j2qN z{OLw#^w`sKXMCbfCOX-Co8)`;fIR;uvQq3y0FjfpS{ah2dOKCqfpZCq1H%)LAUIw` z{)pmBAWb%X*I`L8qv{idSHws03Btd@9h9q|MklGy*iD#+iSsuA{*24A{6;GLD$Tu- zX5L7rx(Onm2_JMNxOKB}c{W__N^t9@iChRv$QQcVtb8*3q$|O#TS&{rF!h%NH}NU| E0xnR6`2YX_ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4fadcc51269cb39c785c6e4c39e3c1adf3277f67 GIT binary patch literal 1254 zcmaJ=&ui3B5PsSG-nLe&ZMUsUOR?$->Olk%^qXXs;J@IXBMY)TC>}iZCbZt2$?jGQVjy3>H}huR%)BoLBO_&o&zBF*%jYG=e$Zm@ zn0<1K0gYtitA*XG}BC^ zYN6HU_L0?DcWUlFZK>(Xz3Zp)Q7e#8wNv2wxT zE;nc{7*+1u%75903fnLYt-G}mRrVSiGRPzOFy6N?NVy=>yeUN(sL_TW2O%{fBz34x z{4vCSQ>qhToJn{c$~S^`{k^8PmHR|CHsY}1W9Xl)*;XP~)o@edY~pnhh4H2`<5niq zFRV7K93d#V5UMOhvqiNb9~R;cpX-%m3tUWD09BAKA-rSV(XpM??s&Ch zRJ&&;JI3TOoL<;oZ7+6=h3@?M?UnY$&(}U)?ilC0Gqc-EyEod4?{9xIX5s80@4p4M zg6{tajAT^^%rn3$B-E3ZjrKxXoS0q6#Q_K9QdUC)xjfFZeVVK)p-}7`M@5Qp2kt;w zG~hf{F0nZAcooXI)l=yms9~grZ%X8+u4&qLcJ(W}y2obrSoP3yw7K@wA;Zt% PgsDxnFa2WpDJuF6tr0pl literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..06df442f49986344666ccd9bf500b4d51f531613 GIT binary patch literal 1076 zcmaJ=OHUI~6u#4Wwop(IMF}a8_~^!sabt`N#0Vvp#l*ts~Q!@yxABcO;Dw z6Blgw5&j+$(%gi&aK+A~xc8iC@qxxk=9}}l=lR_kR;v|;kNL@a^WI?WD=FuXJr;)B zC>%1M`Nk$|7+^F?P+B6rv}rahunO7Sv>P_qOKhI4FyFete488h%~1|;3af)w_cyEC z_Uh$RI#Ms`@@kOFXK5&7?LFxPQS2+I8`|5-RFoz`OmY|~siKZ-jna;AT!Rfm)DIbF zVE7D5z5(V<#w~99rQ7J?4oYSrkx54Eew`f$fIb&OyS5R}u+C-swg;s{iwq37i z?|Cj^BUxKLbud(y+iBX4rHDdpOX^))Nzjo^?PgI+bwTP%3nVrlia_Z~9OO#GX*)_j zvwBr~LeOa;bVZ0x8g?<0(33*E?FRAKGcClc2yzuiiA+*-o4Epw)*wkz70`-uA;7|J zph`d^!4v^?2-669PKcb&wy+BY(N0%M3c7^8A-+tnKYqYzvYZD=7XrQ}^Ier@{Ds_E zT9fZ&oMs)FDE>NvC~5PZptUQLkOx`Dv2PjYyo=FyvmSVut^lf@-$VGo2GcYB=3rvB zzcr{-`x}GB<-Mo-+Xtu?R8PA!`c_w+(z1Yl445F8L}-q-N~@}u;WC+L2xbYc5X=!=CHS@fYot=lXp-wB zEt~^Y$!iEdH#605T-?mDwLFTYuqHO?WxX&+kVBw*Ul6Vx)yt5N5$6Tn#rlg%e^%Gt y!_ftM1XLM+d6wTo_+}c0@rB(vVGAd0@yv0IrTyL+!_V1_WvuK+-x+?2PJRG98ud>A literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..38f78fdc66c7d4d0415618e2b931e189b88d4c7b GIT binary patch literal 345 zcmXwz%}T>S5XX0uG>NqpAEDQP)Vqimdyt5N6zgSS*|tNwHr=GNS;3cISF{xi(SuHWx6va7fJ@txv#5dRkKlidj=uWZc5{F;>t%ANSa(}3rW{9 a+Rn-CG2u7noPV;(2b+9#CodWHsQ3peongrU literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7a07effe4634afdfe1cbbc8c30f09caaafb9d0b5 GIT binary patch literal 5782 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6cda928dbda4cc3ab21e01c109fa69b6d375231c GIT binary patch literal 4065 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 GAM}5o#sZuG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2e7003fa592c088fa847a43c2f29256dabd1dccd GIT binary patch literal 4254 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 W>C*ODCfx#j1 zSmOBTnd4&yla(`?3F-I%QEMJJIg;O?yYNKS)8h`qMhbJ%5AjccN8S+It7J{2T4MgZ6)d;yQ}oL1TY+21;$c>-*L_bm!3``zhskBm8Q3Y4UpK z`_FG2PwB@~#c+u_x5Xl^VQJy@S3YX(Ds>N)TMukGMyNo=*}O?Z4@bqf+#{U3G_w7a z+O!eo0)=ec8*Q()Elpe>UiazyjuzWW)YA{r<)ZjXLtvAJzNQN(qHC1evH^)-0C)Ze D|8muk literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..07d365202a93ad429068fd4aed5fd57a651f64f6 GIT binary patch literal 4917 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¾t4HFc{xWm=fj}(oN`;CD>JX$GrOKvWU?}z zyX{?%kmB@Lt1NKUE@O7&rW+@|w%s1wum^{`$^otK0XS4LS!GeSbBhwomV;P(n08$W z(7SLT6HrJe;6T_@SBA0)R5rLYxcuHtVSV?Mx_hcLSY|@o?aRTu!RR`XsqYL~)HcmL LV3DV%;fekSd*=-x literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f4df2f150ca8ee5df79165c5656ae42325e56b8d GIT binary patch literal 7284 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 SQ6x|(a*)|F07++I@%|59?E8xV literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2dd707b17174a97c43c371e7e89f830365b54511 GIT binary patch literal 5398 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 T6zWSu5v@%!n=HyS0Uzmqya0OD literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9d81a5ceb04e1ea95d2504700873dfda913f9d72 GIT binary patch literal 2683 zcmZuzO>7&-6`mz`#a;f3qGUyhr7@+dW^GA^CD2LF;NJcWZi6|Rgo7{$rx;EU@y}?s{ zL!bg?ynAzK!$Unb<~PL+iAosdr5L)=Gey05K8Et%%$T#uU<7u2Fmn*F6Am7Xz`ik< zISANE2M4S2)>)zT3<_LflX(!AW&4a zl3G$sQh>9-;erD~Q|iRx*_y?hDqw$|5axKHrfLr?LDNlQ6o&?Pi-LfE1<%=^A)clY zhM3yxFK1kR*aw$;NEt=vCHcNUA2^bw3n1CwGb8o|`XB~Td;|7p4Wo)ncIUVtlKGqq zdLieAekjL7@3DI^Cpfkhuq#VB0oJ@r?p;py0rD>ew4ps;*B-0~a%h3e4edF(gEH*Y zuQWOMCa-z+h8=n@;vmN}O`JqIF$3p*wdFjH%sOOT)s4_0_py6N@Uzo|M6@GZLVNw} z@*rgzAx%1WC;unM;7-ChDTtO_j%|#lz`7=-B)B1aEOTXa$32D0+%0#Rix2NKSM9*X zMM_FUGgYN#V0h=aXh-=0u=7}cpf(y15?IwtLJw8)5ZiHt4FYkcRp#$CKlNg0iIRGV z4fCi*?!?NvX3mwBx>`G0#wiMhuVO>dj5&i)wR{`bl}B?A)sk6V#y78BYdm5Mt*F{E zzJ`?pQ@;%^)O5OxrwO1V-j)t(q?A~wE}m0v zB;QtX^X}EeLgOX&ef@x*J6}~X@IE0LHXJ*+tW%sYOsZ>@WOFpJ(1Tbp2lPsxo_}=% zM|HcKUU&=AYq>9u6AG?UQoa*wx?;)ZaAHA8zGR{KdZLkZZ0xY4Jg3~`nXHAH>w{a` zr!Spvg%~ELq@vK0aWw&pZ>yA~)^V07_5MbQ1$cp7wdTdaU9M|~s;RKrhv#rt4{D{y zIM}}rCDHOyQdAo%M2}(d`+DifUNWIBW}9R8px%%YHg!8#x^c6N;{i)_3ZxZaHK}j_ zabXvu?zEv8#zUQ!EKg0ZKv+~P&qIZ304OUQlvM|dubTB5h2#Oq0@m`gh%D5*rQ4Fy zkBX$h{I&!_sZQfw%5s#BurA6vmYS618HJLAC}bJ3JdVDrT(>x4uum%syl%5LSC{iS zaMs8x+5uJaYs7e9>W%z+WFN1QLsHWlb-0UsRi)r?a4GR32wfq{SzZmblhIe3}zF0k2;?Ui)qwh2Cg~7uwP3*2qjdF#J6H`e_)S zgz*bW2n`+o=(`~DMt(c<;pFj3$0Z1(?O^yL{nz^MDqjR=PsQ1mINJ`5yl^Aw`~UvN z7d=BRVU+nE_#n_4pMU&|$Hmsz;+Nvm^HB6Z9RU{qYplSTf4LG|y~sTsj;_q1r{CjN zuJBK1d4_+$G5!k2@SJyb!u|BtYpbK~XJG;OXQKiz&nDb}Kv7_$6j_@XNfwZMYR z4wY{F2(Z`se7E{h_Ti!Iw+NkL9fr=5>}V}hz21Wjotl=< zX4(5C)DrE9Su=XbFluy$>AIn2*e~}F=nUqFUSX`5sOzj$CFu`g%06nw@1g6sIga}) z3jQ7Ket~wsLKnY6(_i~8e3(2IzxG^cM2Ov0h8pJ0--0p+$_-T0NBs^ow zOrJ!i+rzJ&4#!T0W9{g8d*s545wGYv<~!q4+~{Xw2f^@{X?tkpe%u{@UhIy>p*?mG Qdds!y`WF+Wx4_H)0Z;&-s{jB1 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..51b68ec4745190b6552dce0ea12b23249d4a51b9 GIT binary patch literal 5050 zcmcIoOKcm*8J^{GNs1Jy_sdqCF>SPfY!p))o>~cxj zb(&r}kY@gw`RDaNzvaDP(2tHTN1vJH-}Q5lnU*bBnEPjCN(Y!S*lNp$mhYDP)N%$&4^c1)>AdoF&GJP7Fg< zvsVB4BGC<5Q)dm5hxxWes;NaaB};n-y;4D5StlhkK#5+=lNEzpFw7>ZDP%>J^2GG$ z>$0Ngrk`9W5GoTjMND6TYHK8A=%$Afqe#^hdxld2K>QT`?_F^rbO>S4+p;r<9BHNx zBD4(ic8R+ks?ml)*JGe{Rxn!j+7!sn<%|+e? z6K`@>H`;_J&Tjfp5xqyzSnXcUIjT1yh`xkJY+}z0A<_RWYXc(htc4piyBTjV;e|Rh z>KzKgU4*mq2nFCh=UifoDu}HK4rbdDJk;&qa?kpEfu|!OK;4;OJ9mu+RNsL;vW+)C z^cd*I!J{57Vt2x~-0F=}TE_lFAcRKil6k;B9-0FyfgPs;87_lP@Q5n3>%57|-}hrE(~_E&(~?1s z=onngatu7M7@s{CpIw@Lc?KIfq7y8uxTF;+ezoS6`e4leB@<}b6~U(4AgNs0!}c43 z9&^32D~8QLn&>Gi7nsY+5pajH4-YH>Zw!}RgZ^?4Td|;F$0M;K>jqrDyky<&C9Q}h zO0cA2%YO~b&Qax*q`-v?LN{b`p3tbpY*lmGDvpy^i!vp7q8c%4a0tgCAi$CRPS@s0 z+9(6n%!6Oko8pCH3 z;u2mZz)m5&!RFGcRs@;l2u|(MvR)u5IU}d=V*K3P%=D62lR9JHAhs|up3>Ky^>vx2 zVWAFp&~aW$lL$`bBt;==mgqPmrARsg+DheQP!fbynAa$l^Pt-eOBp)5NeQ3Vib@(b zSg##5gd0ye7QtgNEaohh6s)5d5(P+1!FC|W8LH*27p;M&X+zU{Y~VB;OQck;F3$+F zuMIg(Y}pM*>oYOD1j2D_8q0=Gl#IRANYt{q6qw_Rr0QU1F+2~~FKA$SIx|Vw5bkBq zVSUe??F~kwI0vjTJjEudVfC<_c~BqlKG;n+im7#b*|8|-F9Yf+Dn==WXC%E8!DmTY zF6J$Po{?zU<{XQHS`CubDDW|-vK%9VPm!FoA!{HsYk^n-m&)U3i7sbVd*%JstQ9+k zMxtXw8AZ}__gkexK>^2NCU$g9*VJsrDt|lqE^oF&b}fL~$x2>kNfuJJ*-{_VQ#q2C z%mADMIaXq-hc!Z8OB2QPK}a&d6Cv5!!Kr-Uv?MDejSa1)Tkr-)19}V(wgRUDnc6VD zik8iS6PN;_RHLT<1xYEA8H;wWfgVgAq-T1plXMdvWjohO?M%w@vf1VM+_ZfYXdp(K zZWd~&hjF;sdrd*nq_oaL8G{fr7gEFuTZ7G%rC-WW9%U()vOGm09U`;UiCX|%QcQ9t zS1^4nMydWWvyE5)bc(q&9ZM#3LiMDi7O9j3ovj;MA^9R% z1^sOhMJuqVmdwc%#FAWdShXRih@P}H3m3Bb(L%}Wugj$#Ggs>OpvU17 zEX(RYhVc9)x`_S~XuC9i$Jg?D@KUhSJ^rch_?=L1rSGYo&_u;IQT3qSeHC}-pS>-+ z`&TNy-rcsoE8#1{zv$nb+U?nYx%6gftL?X4zwX-FxJGvlj_mY|Zl2j~?%#UiTHCep zcl)jdwnw(5Kak&%>t{b2*%@E>$fzuemGPy@SfY}6rSii}<&|tDyH>&Lx0=^KY3~2K zfZ7jKeW-s74y(G|?Lp{-dN${)i0kd&?SJCRGe8Y=?{@FIEWasld3L&wRJsPL$TcW@ zE_7Ag-BrZ*2ykyt+r_h06l@o&J-s2{#naVeA>rhmj=sw$-aK);WB69b@NWCz-LCz2 zI(lz+L_g_>?j9PugAe~Ec?*y1_CNlW*WDSadQs;{75O^-o4$XvqprTob8pREx$w?w zS6|!d9NKK!ZRv$;{_{?t^B!`+?lm#?j&HxZovL)lKJ`7#q`80M`oZfRmA(@@p~;GG z@?TW}n7`4#2YUF)spyQKyD>cV__J;3gH{eYAGG;ry!;3IkIcCE4_yM#A9{HhH{Ito znofb<>V;XUgDkwQ!t!lvlY2aGbAxn>z7GtSTh9UYmC>C8=v4U_R+N!w$&TYws-%?4#L6fQ zF2F_k7!DV2fC+>f&|}fgffjQEUqGBHTG=`cKqRBlyp;j!iIN$Zog`Blt)U9Q50LfK zkjR}(1i7-1(^O)}DVy0sH+Xh&3J=UOoTgg4LbhD2vEeDDkds!4G01Xr8`{Oq?yJ1WV{yPj>k6Dkai-T05^gXDx>dni7uSaEVoUjk2ZyxGNuirFI@m$|M75 z+d^QF(ILYC6auJKA7zI#q{XBX!4Ad(d{koCS%MY$LJ>rP^_2A)%D@rR8QY4b96XSh zF5nT&?#w<@_1e+2hgl1=)apMI?iUg z*(W@E|H=b8rBB1;8OHxURR2SYal6{XpNF5=g2?S15UN6x6|K8lg~{)X*29ks)ZM7H z^Y!wj@|A&ihOQ3nvvW-{#kj|rE6aZ2v6R_FA25gKIb@H|iTzdD+JScLePSfZ7 zoMKsl8LB}8=Qk72v<iVB&evoOXI zkG>66mFGC_GZgv^1@5BWyXe4Obm%UM+(qMm4fOnM@}lqarr<@-E+4qf?}NYHVEgOI zOUbQ)+u?~@@E43-JpGmXICpY;x{9FrligIFPgGub>DKcvTZ5I{y4{}tQ5B)rxGC3H u2wmV#xvWtbT5!dIh96j7WHSq{AGp3|n^h@?xydcPilDh>e9aoltnuHavKL4I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c95855af8e90e35ad5f75bcd5927f7a73303ac1f GIT binary patch literal 8550 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e22efe0a5fb223c698ed19783b1996707317f31a GIT binary patch literal 13856 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 zcmV+cGycqpP)V|)f$;Qooc7=_G zlYe)HToTQIc!$)^+J1M1y0*T%w!p~7%ux`!eRhO?c80XDxKQ*R^lUUMnA>6NT^?feoZ8xxvP32D&s-9ow zqjcM}eesrC)NeDmsf)*P7wJ|K!&xP%Zy4iI8lF)Tv2!reW)tCzg_1=PmOwd1SQfxa z8;58t!=z~Ba7CYlNWVG>he8aRPY|+-JmozNhn!#9i#77Aa_Edt$ijyCWL#=~I>~2X zZNrQ8I0=D+NWD4pq=7~(i zhfThMNw|G>g^y9pGzxX7ZSApl@tIxFcs{p#MX{Ax&XZT+cR#U+OWc@S)pkIuI}dzu zH?^Q=<(y&Vq-oxSLfc0Zmq81bjZWf}RnssBaD6}2g-XJHLcN_|*IOu>m|x$nbm(?E zyNy!Zp=RroS;?Vg*kmoJYBi!n5{_^@rA!)=t#a^;N$8GL!*DsQb}`yvEuX!G@||An znOfUZAevPrkV_qjl|<~3QRZzG&h@C9Y5z zqpNH4xqbF_InIPh)kX}Vn^5kyed|mOuq+2>M;v~KO37a#yrEn3XDqtOl=rc6_KZ!; zreo)DFVB4|>1Zd(bvMI%8uM;3!)YMYu&cG?(PE!B~y@3yKBMt|R zAf=I16tFwPsl)!jDqvYkLHaAQ+f@W1m6F5aZvwhm4JL z{_l)@b;)mDSzle2gyFP5-r1x-5X{G}ot%VyWP@vEW80!Q=f%RTfpg>B*TA^pyWYUQ z<=xPtz}WcZ!;rFl4m1D&FFHv?K~#9!?A%+fn=lXt;9!Fc#kQ;zk~gZFsH z8e5iu@c_pzX&qb8&Dum*oXwB+fm6l6gFfC|o*wgEiy6tw~&co z9Vd_4)P%wP-KwQW7|lN-znGK#?N+j24U=$982myIBM+vsiKsc*@4-rwJxuAaHKna6 zT3wi!C~a4ZKH03qU}_1bKyx0&$CaK7_%Z+Kl$)fF5^op zZApQF2TvDav!s|krTjw-8US6ep z%!VmX4luub+fseQz_D9ATJQ?iQQwD}TZz{-yo#l12a%+7bT@E(X-hyaVS-5vuXc#^ zx^w;L21;NphGVoj*{s3f4dme0y2LC=G1-7THd`#z?;tuC{^9k(dM{Rf2GOxg7Jzho z7nSZHl7?M9kdalX`)YgoKEfiae5+;$(OGeN1eqxrv!ZCVKyH>xiyNqfe8xzY8*7)H zQls8KMp)F4D>ED;idMOU^^WhVF@q>ZSmeB0y~qC~|DB648hr%Sh|*T(4q|w2l?m2+ zvBVw3@7+Mz?^Yc#+se6KM;a<=(W-I>k)$-qL2V*t}VaW`;?P4)WqI%maIDq8!oUcSYAD`}wWjkSyAVsnF65#2zQ zZ>(K*TlS(E#4y$4Zq+e^_&}d)q20hCe3!LfLYP%nQpLJ~gM6a1hJlz3)aS<9C9me| zAcmJ#>tOwBy{HoP0Sm1&_(E+S@6 zgBIFUoei8zJmdpiq8q5=OY7t@`)JWxn_&GvKVr=Zdb_pEL_j|=?f;WK^U9Q0efd#K z9q7SfJTl4pmA$jsZ5oK8@O9#!I3Cv-kL)<8SalSsp#dcpvJ}Nz#G6FC0%9|7Fi#8; zGDJXtj!&GljT3*HE@0EE>G8Se&d)*nkqe}-?`3vPl&UqK?xG z!3XJ4M-x`EuQjhBbu?ik-)rmIt=DF_N?TVMP)8Gjn)TZ2V%H|zENbeix}kOxd@0}Q z>)HuH6Ean!uS#~4g2Ne2WsMGel|h%j9*W_quQheG^JqmKhc*RYzp0wKlGjBq2VzY_ zgOv8WC1+%W=W)k)Yp_`8kfE=uiiwOZTXi8Uj9YGr$f@yJcJ;#&-Nq~sJ7anE(@;QN z=~br%7%7`isKStX|7!1?L(apl^QvPKlrHV4S+6tNVQ*R1iGdC~WMNE1$a+=rpQmcB z>wxiLIBvOnm;u*;9Y!kJdy(T4lk|8>JAm(&wEsFIF1$_*{>2ZNd$V6DS=SfrGxAv0 zzKe377JI`&o9Ljr+VnS*EwehA{f&{cKZF(6*MG5!p5MvrFA3ll{fmRG*L@6^cb;o^ z3Wm8c?Sc6$`>~VEWw(c$Y?nRO;2Q$=ulpqPtM^=1IZx;@xK0PgO7rKQ^WHVLwtgUT z%|JF{^f(VH)wLKQ%dYiu2RmchBdxL0-M?wxxul_z*{h6ZZ`>-k(vizs((vW8Lt6Z6 zY;Dt?@JWyN`O`f;&d1Mb?e%9oyRK1ql?EE5XB2(W)|D1~Rx35$H6@6)$F?)7V|zEO zI}fu0-0}8W5=6sg$fPnZ~7=tTudl?Ecb@pxbo)vni%gP-?hL|%*?62C;x6?@E`VRnJv z?fTb;k4x;TS7Cu-z%J}uy}e-pwpLQ17Q@4DC+FCdAmNKklG$`I_pyw7E{fYmw~{Fj zi?6KcVy=Wrel)EB_DWO|0CKmI|13!gBV?X`Ozp7x>?6jr`>Qz=^4ea35!$*f}) zS$i+x_k+@P2q1RFUH^ZTTk7=n?cjfR>hTq3l3SY~#w+I8SSutXGyhw;Ws~=zMQ%Vc z>$On~47Ut?P*_!TOQ&PFmLAyJieB2X4_Fd_!WxI-AY`q1Lc-oK?+qcOTzlQ?@~x@OT}*9jTVNfl@3rGvZpWI=eKg>T zZb@6YWz)J=IhP7CF|c?G62vMEG%#U}?#86$0jR4sG~i(jRd#jmn`7b(O#?N;3a;1t zhXLssmUwGhp79luw#(*V8WL0|8+E z6=YZ_O@er~$LrD_PYGc(kJgB=;yw#+Z3X6LDUZ(NcwN=B-hjdiHm!JFar%m{(5bEW z@@_VEtG$5;`EJZ|OkJ@l&G9n((w@uNFwmU%bG|s#TbcJJos!{e+bjCjrCq_}LcN!UFgKtgg7siV*7# z!}1whTRRi*-avJPu->C}Z8EiuK$#886+H_#_!btv+rsiBbv2jAJvJ+O0{#}y(%L3H zfjU-kq_-L@2XrL*ae{{qYJkD{@dw%*bkh2P&YS-0!Xt!PRz7KHV0+~j(t9W8lAVWR zt@B*DgURgEz4>WuN>o?_iKcw$?k{||Pg7{Q2o4|VmJ)mg?{VQJA<}zEr^YAAS zgGm5RT4T3p)U;yz-tfBO^kw8?IoG!IVmc+Z3m#}AOQ?5MRa>)OcU!$N^_+yK6ayn? zK>~WK0!#ysuj^oNLakm)Zvu+J)OSubX^kv!c*xgdIvs;kln!rgG4*uZ;w0mQQO4XD zO9P{GNdv!=cQ(CAL{S(%KtuV^zC&Q{%g)PoXnp^gn^>c*`E>$hLYg2HjnbVGtWLa{7zHdG1jT@B{|Dm16 z7K2(jsfG+m*Zxof)iXxu+!H5Mo-0$pkyV3VV4B@Qms46M zuBxGRV@HxU7Wwx-6CB zaU*HO<_qn$5GH>&@?nRy1{z zkik!sLfWQ)r#75)vVwCBU*r_)Q6mp?!j85{#Xqse)ApRdE$V0%I0*~e(_{)5H)`Mk z#rExC>yjhZxuL@|+#v4#<Axw$+VpV zuT;!2Vww$je$DpAW`$FX_Ab|Ip%$;&T$-lW8jS~B$>G}rd>eQG+$h9lQx4Mx0w={m zx9?T6VU`>sR}XClkAhHEShOUe8awiq zmizhL+}5UKs3}6~It7vBTig9dfQ2Q8coo+Miiaw7n~>4ybv2Ptt0^^=VqX(t*Yya9 zr`FxxFX8(v*H=+uJ#JJWIB2A(==HDYx~^zZ2nu?2`}|Wsa*f3h3ixc+U|FDtAG$Y! z*lc_7se5Oso-Cgqe0){{!8H4g$3<8!R<6JOurD;((({c$1(pwb>(#TT!sge@4>r2@ zVL7>U`0`nsWAYErezk4(Z!gMI2?UTo{J3Ajo(u4)KYIRd>BRcG4BoS3G0EXyEp@tw z%P7__?A^a>Q&AKL@ayDO9D*Qkc!NHnO9l}kpp_6hXbMppYL(X1L?njdFT|-h2<_$; zAtDZ!1Rf%|yb!qbWKd}%0b`LzBeyNy43|QO(&h2mxQLUL)|0%agVOW)6TV!&Ip^Ls z`PG2cygM8)IecQx=Fc+nqYRo4hS^^-nM_&-y8?EJXUczP=DIw(GkTJdpEdh<_STs{ z|A)4n1GKdE=Wu!!nYoZHcUQ4S&R;oDOKX2lrkdF(mK>hz<$Pp>igjOcvoRIjlN=W8 zu8Gx5(roqn8$>gEE5vy{GiGeW8Tq{vnf3hS-V=$tZkQuftUVuU8o6k&dn=Yg3)6MOIH>nlK^-2+C6BZITr~1@So?NvG#TwL)|~=1YXGMTLpS<)ziK_CSOabe z=cB#5)yz|@0i9dSo?*CX)}UP=s6)B+F@~Em(u@Q(I9J9i_V{LmMu8BfXYMh~*oPP+ z!3~xTv|(>|=n6ZOtT~C@V!z!w%18*8T2t6}U2S##rC)mekBql&VsBX;$~ByGE$oA9 z`0Wzq8p?R{4)$l*on;!cLa}Dh^Xe?owiQZt9nH1fxxh$pN9K%CtOw?u3>85L7rr!d zXs)l{TZ{xXP&U8exz?9cv~dNNibOmt*K4I$?RxqIBZ0(?Mg-9FS{*9Bc49Qc1`=sIF-rye`aNT1G@4NwXcnyc@+bw_mTsR>5< zF<2;X0QesG_pw|TonqVBhRtfqI>ty(SIu&VOXd0CrLlfp+;WH7HYjhqnu^oAY!9cB z=B6#R?Rfz9BP`dJ=@v_?70s3HxQPk+{6Y+lM85f2NF^00*^OcM0~?JOZfR9ZPYF+# zYSs}(_BUYV8{n@2a1hD^SV41bwmi2uztR;PeBgF1F-`9>`zoNss-@3LaF2sjl~>OaaVmp7PNp+UT`6@}gR%uzqHDVeEZ14{Yt?n%JeQm+t(1_u zSc}oj^{b;+rlS|ME%+LjzSI&xu0Bblxo$MJ-J$kJ?Qu_XUXh}*@*-x@ny|}wVM%Lg z3tNB`yvr*}N?ClGL;H2cglcvErIccU3(eP7>@~4nOIcI~-`P8tSQnx=jI&{9)!1}l z;gQ%_h>ZlPSV@o@Azq1R$C6ja5!^ZGh;YRhhxs58qJWo9@Bceac&yy(pET1hnn`~7@}2L0&dfPKYs$ih7m2}R!25!(hxqA(!UIw; zK4+~Jowy3=RNC6nE=ncU{LH5?*9@W24lacJlvCZXB$CYtE@>c+~H zkV=(5I&gb{xn2!~f&fs2NQgAL6`p|kyt6kpWk}iVlqIp(H;ig`{_U9yxs1jzu^ETM z7~)Rg8C-NueqTYP&U8l{DY=Y47cR zOR@U%$KQV{mkRF|4)z9Y^t3K`@p>duY&QLUFeh6VoV`a`$U@)(z!-N*5Cj<11$EZW&hJLX83TO{lJYP74rlDZQPkm@t<=U^I)x@|UnHHkdQlh?!ltZwl92rE;;^ zZuIappj4dhld1}kttYYV-j|KF1Kus zWBnzttD^00%LFK(wrwNragFub6xiV8QE2rm<`&fcR4SLFcdtLxVuN!Aal-g6dE4%k zARZ}|xeo;K{0yf7@9aua%2j5o)CPcIOc6uLHFJOcgtB5owlcNAwyAHc0QB0Dts?c@ zUemG~j_E&W7R%+x-IO4FJl8e&*2Blmp1S#RA|)geVrxvP)NHdYuxi~g&Etn?QdNK8ZDKZ?QFLU?zh30G|t9G>a_X4zk}Ygw<^$7K!GIn(Io$>(d4ODJQ2XSd%jpK zm7>ptl$a3GyB}5-%p4>Q*p#VL^B{yQMuFCM^#l#+N!Ne z5_PrJWB=@Iy+t)H`g1lX`{bm($KE5I?0c(JEYm#t{F}j!xtsbob0{xu@0TB_*>G7w0ICn zr#VoBktqHZ~XxhiKD*lcG|b;H*|Ny3P^8ceV`sfBRfrhwZ!T+MFZ!F1Bt{q$8d9i6o?~ zODj^POr}&ivSa^R^YFIq7o0giLBKCycH_aU`F6)O6JX%nPTwh~Q`eq6*0iE#Srj2^ z*_hN3%*b83zfafy60@Cp3{J({RlSaEn&E?mrxRNC9GQ7#+f=s! z0KBf-9Ny_v2VbE%aB|Di)5kNJ^t&C`4D(>t7zYUWUFtbxt+Oq=!@O7BU)}>d*R72o zFF)3jQD_lLe4is&xzyJYC1-c{8TX$RU>&>P$%)ufpez0XSAukmh!xcekg`s$c<>-q zI#zn^JU0zzF}V60)o$_gY}PQH>b2M9&8fRZa#OauglPb zeQ@pMm&=!vNgos4CluQjLMV!pfkmxK+35bi^k&=k>9h02?l+u+m0agG;(h2|Jslc-llvtEwn~*w3bx7qnvZACG<8}AGeaDVvcHbKd2>3G^ zSFPULUn-?Pmo^-_`mLZr??uNH`2=I&yajlrF{DtUxMy#Nu}z=3y7qbUA;5`)hibMR zhXL@@uKyV0-2&A@t@!xyrBnMJl&^o@Gx$&5_q6?D=ji5grd-~=?dlg;ur(_V0wjh! zA=JV^C1m+DDkOsgr<%O9ZQFg!0}pD(#PSz4Dr_EyS5$`)VIAv);4n-SFP~YtC7sH= z7&*MfpH;gd*FHbkmD#)hVxb6xjc9~`t?_{=JS+@ip_cTicXxG<=7m9& zPX+Z8IC*GSAXuGCrZDHgR$r%jyk-fctis2Kx4HvZ|B~8uC@o)m^>Hy-O!&TKA?$&n zkP2Xc54w~!=z2?^NafyL*L0V9cbYrugHBBUj`xVyZmGFR&kvk#>1J*Z~i zNTz}?IAdJ$gkqd2!Gw(%LzE!O5s4C7q4%T~e_P{+z=DNDKrG**p=U`d5yg^vp`;Zn zsU=8gd0a9s4s0FPJePWR9eH5=+O^Kks&kC-iblNqTh2&Pw*^(4384f+D8N|fewZu_ zg2ejQ)ov;ztz;NQl7yj;A`(!H!XQu_$sqY9h_IrH*}_%1{L&_YLDvO?%R5Z-t+ClW z_qERbL?HKUZ!nt+!E9S`uoh^5A|DaIHe*_gf1`E_Vq+}{&T@t$EGhMnRjJ4z2w_W8 zp+qjs7as22^&S3wY1?+}^j-I=RcCE>#|39)g(lU7v_8;?=qK(9D8-*pPdiy)P3lIblG`+?%ea| zYoD3dopYt!tKgFicfNmNi(EWE=E4hC6(r|PYtanqJlmt57YOVrr2^tfrG(eG9C##X zu&1t@%L$RIvpj!wUA z8i>Pqot#_+Cnp6L2XPcZy1ar|9MnY+7eNvK1E)@Tr#2KsXq1*>)uUCozT7L##ok?o zhA6ofP4E|b*9tAfG?uf$#}>TIR&1A!yslP8}i7w-EzW(x#9VEvx18k%Tn=-$VV zkOtUr0b2!w3t>h?#8AZl^Az*(6KCGlD;4j~yx};`#2gN1_gv=%7KVzecIRakN{f*4 zeaI>yH;-o4OGhvGTU)(quWI)-q?V*(sVesSMv|wMUQ3hLEt=lBB$KZ9TyHr>)f7o%) zPYeU<3P)*P10*7vE)nA5#{c=6-E-_>r_u4e3i!I2+UksELwDqwMeBZ9FSP$;^Ajro z_@M#_Ss$?ejoB@!wN|kbGKs(0zLo%0QpQXW#t;oC$B0MZYZ&Ej?8~fNhcCVvPo3vo zFn0WWZaPliF^8_}yzb`*f@yg0uWv6HgNI)xa=pO%Ck(C<=-60l#uD3(wXP~c7!NoX z0&^6=N`zcc90F#qt@=Rn@r!3(*1v(Tl{B!m?Mc7yIA+nEHpY{YWr$=)F7rhR1P}(v zt{YhY#;jsW6G>#xhP*B`OCk|Pf+NN;ju1rxa*HAgoGq*rvqw&xe~;t1JA31$s?GBb z*g7&@cbKo4n<`>)!UlIAgR6q&))B0KYU8r66GbFj?8Guw4E%&}Qi_lT003LtoIZei zwD~=XZmeo+yZ2Pq3KYCF-R&11^p= z@H%s+=G`}wrbJ{()Mh71#2SP3Zy3m>l1n?0N-N1Q;z6?oSxr-G(H5m4EO>~&;}VKi zfY}3w+9z>vp#d)hVuu`)vG_aaH%3b=WKMnSu&c31;<3O;bz2iD=w+o4#oBb36 z5ZCF*Gu?zjZIR0S>_%pHY2$k8D^n7Sz_K8tCDeXM+dO<#LSg%h6`~dnVG1N@T7v&e z%wEd1!k{^zfz_1BTW{!$!B%g)J^2b87!9Y>>100X1SgT7s0z$o>^lAA=Gp_cC1(h=*5Tmf8z&LGJJ>$|K^~s`z9*OWz5MFUr?>Bi?_PGBB)#psD5?>n+q{o_ zz7~ez&;t#h8l$jwGPCC&xq2YetXYQT+0F3j(`xmNGf8dj#an|p#I*pvI*kwW4iuB> z+q3_7xB8y;pLzHG-S%+UHQA zvqp;$kmGJY>lLsN4C~&TcvAS1SErTcwcw0r@wngk zShAUA1M9b#g}^pL-zH7Q#z^&j#r9F8BTVfkR&qF<=e35goTu7c|GN)0mokj4m0%~0 zXJ8j4Hc_l;HJ&uU*Iw`8d_EscJ``s0tk9mkKo^&#TYXm-EoAzTQObxa@^u~g2t#T) zJz|rE!I_?i4dCJC=B8(_pZ{YR>|V?0iCcnU;E@$239^x?SYCfNaMHN;CtHIS_zHN9 zTkQc1v@O35okiFtq5_u+5FkY55ap@pi)O?}x0D1c*qB0KpYR}>Ul+B0Vmr}Z@+%mJ|As}sis_=ROPbov@*2thpE&?!V#Qgu$snYvCZ zrkhmkMU+fSf-s8(L37fPr&M*jRs{{THb!aXQu|P9l_-vJhHvLzMGH zE?1U0H_+PmNABp9`|KzkGfrrZ%XvdGo6*<{d5m9~L7 z_^`M;X6xDo=m6LY6RfvJEvsTK1!u8d2HPx|$S}p;sRy!I zWL55Yxu~_B`OP@~(q6&W3#)~I&+MGL%GWR$#udC151^wsswhqlii;rP9jJpiI7o&Z zAb})=HY7?4HA|re3ns`%$)FuvKCFWjhb~?IE)F6dF2K5}poj-NK6Gf;hw$t3=1txY zoxQxZWrQU6K!%|~!m?~Bnw-6Rr!F3BZ{u5!LqnZTDON}Coj9^@&le)V!NYrVwS~B% zEL+>Sr@}qGwGvu|HrOo|gSt__ezN^&%~{*)a=rf7y1HujUcr`zZB<4#l@T#eN)si} z)lZA<{=tKx8E%c9>A(##6}_p+~EZpKsl5a4pj`E*;_-6`ysiv zffA!7=MT1vCz}-m4~tjVey1b2KSR4OEtLd-(_DdUqYZ74LaDkhH?KFh?%WAOP2WbX zp@zT+Dx|5_f%JQiAGvVw!oh+g3e50u!aPfMxdC=E)XB{F5IcEZhePIM- zph6Y`$Oy?JBL<8Ex(SqEhLeQ@XcrdA>a?rx+_~HLA;l14)WmmpH}_w?Pg#HBZs0eS zwypwAW?M-x+3AU-(GGWSJ=ngxUEcEZ5OsX(Qlt!MQ zn^(`S{GHkAv(8@D`EAfSYig%Cxv?z!{=w^F#y)5_d7FuKZH7qlR-#5B0bt806%D0I zT7VdVP_?q*%Rq8UR;JkD4i^RXowt+E%#V2U>TfDqzZSDZ+dR!a#T3I>-z_$q9@k|m zy5~A*m~&JWP@E7a=pc}4kVHTc4h&R;Li7d@f`|hKMLkbb^uhOakNr3&FLjlm~i5NBM< zFaYI{;cpiHCNRdE0dg*>qIm(_t?#$h=(SCw?h3rJV2*ER8{O4^3#=dO)KwklZkoqU zS8i5c%YL*y*4;FY#D=XmkQnYj%LH)?02~gSJH`Qp1XY64g>%c_K$xseI&|e)7vRoL zAqRba$G@%fSGA7X7hQk%_3NVOYVS+$leU_!&6*5uN)8#5ZBz_6ASCA;azYS-Rt@ki zg2NWz(=;t}SC(~Ibl63$5C8FPmhXqb^)5#jaJ~I{Ex3xZ!+2h8$}}h_g@Be>HZ;72 z6#y#>AY3^skuVKF#0WxFBQ()5d5_nWb?c6c>EeMM|Mh+*&wEpPyxHCq{R-Gdr-`hN zF=1sxl&mBoK+#qRLl9#CEN|Fg8>nbmsTg3a1;#M9enQ$RgWk}kp#-5wh=EF&1tl%mJln2V^8o%Qv(*=zEuO7y z=m*8?xpUn-*@h5Cl_3BK3joiGkyaScK+>|MWdMRWm@RT!Q1piAlv5hL@B6>3&GI8) zP!xBc6}ZNIpJLL%2a8Y!+(<=f%WX>_uWVxlga9!D*oYt$l0cxRDMvqfU;Kq_mLK5k z)dvqYcgLa_Lz?3HyeF)@$%$&6lI?r4I>6W#M*<)vq{?&Oqrx``d`mhpVPr> z#q078F6gw_X<=?KR>8%^t%@wbITvNMu!hKiTSkCTJkw>1!e*Y{%31#_yMf=LW7{RJ zYoC^w$6%3cBtVG5)x#{Hg6IVTh9XEcM{gQwXk!R^y95^f-hZ`d{aVa+xW1EO4wDV4 zB?JgD7*?qkvc|$nIykTvNl2x0j3Q!MXoLL^)~}d7jcYf(H8D~c+?$pKL(px>Z3`eb z04RzS6_AgFT6Pn#iZAg$Sl_j8#;6ShF%&(Fag#E2asU@@LaN;=b=Wf7sgPKhfzhBM zC@eFL8^MrnA*9&Khe*Ab@CC9*uyJGXyi(;y2>lQLJZt;ShtJi?3Yf_t`F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4 zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+ zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f= P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 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()], +})