first commit
This commit is contained in:
commit
7a72438fd1
74 changed files with 7548 additions and 0 deletions
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
21
frontend/index.html
Normal file
21
frontend/index.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="HireAI — AI-Powered Hiring Platform. Upload resumes, generate tailored interview questions with Gemini AI, schedule interviews, and get pronunciation guides for tech terms." />
|
||||
<meta name="theme-color" content="#0A0D12" />
|
||||
<meta property="og:title" content="HireAI — Smart Hiring Platform" />
|
||||
<meta property="og:description" content="AI-powered hiring: resume parsing, question generation, interview scheduling — all in one platform." />
|
||||
<meta property="og:type" content="website" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>HireAI — Smart Hiring Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1449
frontend/package-lock.json
generated
Normal file
1449
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
frontend/package.json
Normal file
20
frontend/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.16.1",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
85
frontend/src/App.vue
Normal file
85
frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<template>
|
||||
<div id="app-root">
|
||||
<Navbar />
|
||||
<main class="page-content">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</main>
|
||||
<AppFooter />
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div class="toast-container">
|
||||
<TransitionGroup name="toast">
|
||||
<div v-for="toast in toasts" :key="toast.id" class="toast" :class="'toast-' + toast.type">
|
||||
<span class="toast-icon">
|
||||
<template v-if="toast.type === 'success'">✅</template>
|
||||
<template v-else-if="toast.type === 'error'">❌</template>
|
||||
<template v-else>ℹ️</template>
|
||||
</span>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Navbar from './components/Navbar.vue'
|
||||
import AppFooter from './components/AppFooter.vue'
|
||||
import { useToast } from './composables/useToast.js'
|
||||
|
||||
const { toasts } = useToast()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: var(--space-6);
|
||||
right: var(--space-6);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
background: var(--clr-surface-2);
|
||||
border: 1px solid var(--clr-border-2);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.toast-success { border-color: rgba(34, 197, 94, 0.4); }
|
||||
.toast-error { border-color: rgba(239, 68, 68, 0.4); }
|
||||
</style>
|
||||
34
frontend/src/api/index.js
Normal file
34
frontend/src/api/index.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:8000',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// ── Request interceptor: attach Bearer token ─────────────────
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('hireai_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
)
|
||||
|
||||
// ── Response interceptor: handle 401 globally ────────────────
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Clear auth and redirect to login
|
||||
localStorage.removeItem('hireai_token')
|
||||
localStorage.removeItem('hireai_user')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default api
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
100
frontend/src/components/AppFooter.vue
Normal file
100
frontend/src/components/AppFooter.vue
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<template>
|
||||
<footer class="footer">
|
||||
<div class="container footer__inner">
|
||||
<!-- Brand -->
|
||||
<div class="footer__brand">
|
||||
<RouterLink to="/" class="footer__logo">
|
||||
<div class="logo-icon-sm">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" fill="currentColor"/>
|
||||
<path d="M2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="logo-text-sm">Hire<span style="color: var(--clr-accent)">AI</span></span>
|
||||
</RouterLink>
|
||||
<p class="footer__tagline">AI-Powered Hiring for Modern Teams</p>
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
<div class="footer__links">
|
||||
<span class="footer-link-head">Platform</span>
|
||||
<RouterLink to="/resume" class="footer-link">Resume Upload</RouterLink>
|
||||
<RouterLink to="/requirements" class="footer-link">Requirements</RouterLink>
|
||||
<RouterLink to="/schedule" class="footer-link">Schedule</RouterLink>
|
||||
<RouterLink to="/questions" class="footer-link">AI Questions</RouterLink>
|
||||
<RouterLink to="/pronunciation" class="footer-link">Pronunciation</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="footer__status">
|
||||
<div class="status-pill">
|
||||
<span class="status-dot"></span>
|
||||
All systems operational
|
||||
</div>
|
||||
<p class="footer__copy">© {{ year }} HireAI. Built with FastAPI + Vue 3.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const year = new Date().getFullYear()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.footer {
|
||||
border-top: 1px solid var(--clr-border);
|
||||
background: var(--clr-bg-2);
|
||||
padding-block: var(--space-10);
|
||||
margin-top: var(--space-20);
|
||||
}
|
||||
.footer__inner {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
gap: var(--space-8);
|
||||
align-items: start;
|
||||
}
|
||||
.footer__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.logo-icon-sm {
|
||||
width: 26px; height: 26px;
|
||||
background: var(--clr-accent);
|
||||
border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #000;
|
||||
}
|
||||
.logo-text-sm { font-size: 1rem; font-weight: 800; letter-spacing: -0.03em; }
|
||||
.footer__tagline { font-size: var(--text-sm); color: var(--clr-text-muted); }
|
||||
|
||||
.footer__links { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.footer-link-head { font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--clr-text-muted); margin-bottom: var(--space-1); }
|
||||
.footer-link { font-size: var(--text-sm); color: var(--clr-text-secondary); transition: color var(--transition-fast); }
|
||||
.footer-link:hover { color: var(--clr-accent); }
|
||||
|
||||
.footer__status { display: flex; flex-direction: column; gap: var(--space-3); align-items: flex-end; }
|
||||
.status-pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: var(--text-xs); color: var(--clr-text-secondary);
|
||||
background: var(--clr-surface); border: 1px solid var(--clr-border);
|
||||
padding: 4px 12px; border-radius: var(--radius-full);
|
||||
}
|
||||
.status-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--clr-accent);
|
||||
box-shadow: 0 0 6px var(--clr-accent);
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
.footer__copy { font-size: var(--text-xs); color: var(--clr-text-muted); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.footer__inner { grid-template-columns: 1fr 1fr; }
|
||||
.footer__status { align-items: flex-start; }
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.footer__inner { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
442
frontend/src/components/Navbar.vue
Normal file
442
frontend/src/components/Navbar.vue
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
<template>
|
||||
<nav class="navbar" :class="{ 'navbar--scrolled': isScrolled }">
|
||||
<div class="container navbar__inner">
|
||||
<!-- Logo -->
|
||||
<RouterLink to="/" class="navbar__logo" id="nav-logo">
|
||||
<div class="logo-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" fill="currentColor"/>
|
||||
<path d="M2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="logo-text">Hire<span class="logo-accent">AI</span></span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Desktop Nav Links -->
|
||||
<ul class="navbar__links" id="nav-links">
|
||||
<li v-for="link in navLinks" :key="link.to">
|
||||
<RouterLink
|
||||
:to="link.to"
|
||||
class="navbar__link"
|
||||
:id="`nav-link-${link.id}`"
|
||||
:class="{ active: $route.path === link.to }"
|
||||
>
|
||||
<span class="link-icon">{{ link.icon }}</span>
|
||||
<span>{{ link.label }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- CTA + User Avatar + Mobile toggle -->
|
||||
<div class="navbar__actions">
|
||||
<!-- Not logged in -->
|
||||
<template v-if="!isAuthenticated">
|
||||
<RouterLink to="/login" class="btn btn-primary" id="nav-cta">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Sign In
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<!-- Logged in: avatar dropdown -->
|
||||
<template v-else>
|
||||
<div class="user-menu" id="nav-user-menu">
|
||||
<button class="avatar-btn" @click="userMenuOpen = !userMenuOpen" id="nav-avatar-btn">
|
||||
<img v-if="user?.picture" :src="user.picture" :alt="user.name" class="avatar-img" referrerpolicy="no-referrer" />
|
||||
<div v-else class="avatar-fallback">{{ userInitials }}</div>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" class="chevron" :class="{ open: userMenuOpen }">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div class="user-dropdown glass-card" v-if="userMenuOpen" id="nav-user-dropdown">
|
||||
<div class="dropdown-header">
|
||||
<img v-if="user?.picture" :src="user.picture" :alt="user.name" class="dropdown-avatar" referrerpolicy="no-referrer" />
|
||||
<div v-else class="dropdown-avatar-fb">{{ userInitials }}</div>
|
||||
<div class="dropdown-info">
|
||||
<div class="dropdown-name">{{ user?.name }}</div>
|
||||
<div class="dropdown-email">{{ user?.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" id="nav-logout-btn" @click="handleLogout">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button class="mobile-toggle" id="nav-mobile-toggle" @click="mobileOpen = !mobileOpen" :aria-expanded="mobileOpen">
|
||||
<span class="toggle-bar" :class="{ open: mobileOpen }"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Dropdown -->
|
||||
<Transition name="mobile-menu">
|
||||
<div class="navbar__mobile" v-if="mobileOpen" id="nav-mobile-menu">
|
||||
<ul>
|
||||
<li v-for="link in navLinks" :key="link.to">
|
||||
<RouterLink
|
||||
:to="link.to"
|
||||
class="mobile-link"
|
||||
:class="{ active: $route.path === link.to }"
|
||||
@click="mobileOpen = false"
|
||||
>
|
||||
<span class="link-icon">{{ link.icon }}</span>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
<RouterLink to="/resume" class="btn btn-primary" style="width:100%; justify-content:center" @click="mobileOpen = false">
|
||||
Start Hiring
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Transition>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useAuth } from '../composables/useAuth.js'
|
||||
|
||||
const { isAuthenticated, user, logout } = useAuth()
|
||||
|
||||
const isScrolled = ref(false)
|
||||
const mobileOpen = ref(false)
|
||||
const userMenuOpen = ref(false)
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/resume', label: 'Resume Upload', icon: '📄', id: 'resume' },
|
||||
{ to: '/requirements', label: 'Requirements', icon: '📋', id: 'requirements' },
|
||||
{ to: '/schedule', label: 'Schedule', icon: '📅', id: 'schedule' },
|
||||
{ to: '/questions', label: 'AI Questions', icon: '🤖', id: 'questions' },
|
||||
{ to: '/pronunciation', label: 'Pronunciation', icon: '🔊', id: 'pronunciation' },
|
||||
]
|
||||
|
||||
const userInitials = computed(() => {
|
||||
if (!user.value?.name) return '?'
|
||||
return user.value.name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
async function handleLogout() {
|
||||
userMenuOpen.value = false
|
||||
await logout()
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
isScrolled.value = window.scrollY > 16
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
function handleClickOutside(e) {
|
||||
if (!e.target.closest('#nav-user-menu')) {
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
height: var(--navbar-h);
|
||||
transition: background var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.navbar--scrolled {
|
||||
background: rgba(10, 13, 18, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom-color: var(--clr-border);
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.navbar__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.navbar__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background: var(--clr-accent);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #000;
|
||||
box-shadow: 0 0 16px var(--clr-accent-glow);
|
||||
}
|
||||
.logo-text {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--clr-text-primary);
|
||||
}
|
||||
.logo-accent { color: var(--clr-accent); }
|
||||
|
||||
/* Nav links */
|
||||
.navbar__links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.navbar__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--clr-text-secondary);
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.navbar__link:hover {
|
||||
color: var(--clr-text-primary);
|
||||
background: var(--clr-surface);
|
||||
}
|
||||
.navbar__link.active {
|
||||
color: var(--clr-accent);
|
||||
background: var(--clr-accent-dim);
|
||||
}
|
||||
|
||||
.link-icon { font-size: 0.9em; }
|
||||
|
||||
/* Actions */
|
||||
.navbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Mobile toggle */
|
||||
.mobile-toggle {
|
||||
display: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--clr-surface);
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: var(--radius-md);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toggle-bar {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
background: var(--clr-text-primary);
|
||||
border-radius: 2px;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
.toggle-bar::before, .toggle-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--clr-text-primary);
|
||||
border-radius: 2px;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
.toggle-bar::before { top: -6px; }
|
||||
.toggle-bar::after { top: 6px; }
|
||||
|
||||
.toggle-bar.open { background: transparent; }
|
||||
.toggle-bar.open::before { top: 0; transform: rotate(45deg); }
|
||||
.toggle-bar.open::after { top: 0; transform: rotate(-45deg); }
|
||||
|
||||
/* Mobile menu */
|
||||
.navbar__mobile {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(10, 13, 18, 0.97);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
padding: var(--space-4) var(--space-4) var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.mobile-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 500;
|
||||
color: var(--clr-text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.mobile-link:hover, .mobile-link.active {
|
||||
background: var(--clr-surface-2);
|
||||
color: var(--clr-text-primary);
|
||||
}
|
||||
.mobile-link.active { color: var(--clr-accent); }
|
||||
|
||||
/* Mobile menu transitions */
|
||||
.mobile-menu-enter-active,
|
||||
.mobile-menu-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.mobile-menu-enter-from,
|
||||
.mobile-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.navbar__links { display: none; }
|
||||
.mobile-toggle { display: flex; }
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.navbar__actions .btn { display: none; }
|
||||
}
|
||||
|
||||
/* User avatar menu */
|
||||
.user-menu { position: relative; }
|
||||
|
||||
.avatar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--clr-surface);
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 3px 10px 3px 3px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
.avatar-btn:hover {
|
||||
border-color: var(--clr-border-2);
|
||||
background: var(--clr-surface-2);
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatar-fallback {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--clr-accent);
|
||||
color: #000;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
transition: transform var(--transition-base);
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
.chevron.open { transform: rotate(180deg); }
|
||||
|
||||
/* Dropdown */
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
padding: var(--space-2);
|
||||
z-index: 2000;
|
||||
border-color: var(--clr-border-2);
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.dropdown-avatar {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dropdown-avatar-fb {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--clr-accent);
|
||||
color: #000;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dropdown-name { font-size: var(--text-sm); font-weight: 600; }
|
||||
.dropdown-email { font-size: var(--text-xs); color: var(--clr-text-muted); }
|
||||
.dropdown-divider { height: 1px; background: var(--clr-border); margin: var(--space-1) 0; }
|
||||
|
||||
.dropdown-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--clr-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.dropdown-item:hover { background: var(--clr-surface-2); color: #F87171; }
|
||||
|
||||
/* Dropdown transition */
|
||||
.dropdown-enter-active, .dropdown-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.dropdown-enter-from, .dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.97);
|
||||
}
|
||||
</style>
|
||||
|
||||
78
frontend/src/composables/useAuth.js
Normal file
78
frontend/src/composables/useAuth.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* useAuth — central auth composable
|
||||
* Stores the JWT token and decoded user in localStorage.
|
||||
* Used by router guards and all components that need user context.
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '../api/index.js'
|
||||
|
||||
const TOKEN_KEY = 'hireai_token'
|
||||
const USER_KEY = 'hireai_user'
|
||||
|
||||
// Singleton reactive state
|
||||
const token = ref(localStorage.getItem(TOKEN_KEY) || null)
|
||||
const user = ref(JSON.parse(localStorage.getItem(USER_KEY) || 'null'))
|
||||
|
||||
export function useAuth() {
|
||||
const router = useRouter()
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
/** Call after receiving token from /auth/callback */
|
||||
function setToken(newToken) {
|
||||
token.value = newToken
|
||||
localStorage.setItem(TOKEN_KEY, newToken)
|
||||
// Decode payload (JWT is base64 — no sensitive data in payload)
|
||||
try {
|
||||
const payload = JSON.parse(atob(newToken.split('.')[1]))
|
||||
const userData = {
|
||||
name: payload.name || '',
|
||||
email: payload.email || '',
|
||||
picture: payload.picture || '',
|
||||
}
|
||||
user.value = userData
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(userData))
|
||||
} catch (_) {
|
||||
// ignore decode errors
|
||||
}
|
||||
}
|
||||
|
||||
/** Redirect to backend which starts Google OAuth flow */
|
||||
function loginWithGoogle() {
|
||||
window.location.href = 'http://localhost:8000/api/auth/google/login'
|
||||
}
|
||||
|
||||
/** Clear token + user and redirect to login */
|
||||
async function logout() {
|
||||
try { await api.post('/api/auth/logout') } catch (_) {}
|
||||
token.value = null
|
||||
user.value = null
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
/** Refresh user info from /api/auth/me */
|
||||
async function refreshUser() {
|
||||
if (!token.value) return
|
||||
try {
|
||||
const { data } = await api.get('/api/auth/me')
|
||||
user.value = data
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data))
|
||||
} catch (_) {
|
||||
// Token expired or invalid — clear auth
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
setToken,
|
||||
loginWithGoogle,
|
||||
logout,
|
||||
refreshUser,
|
||||
}
|
||||
}
|
||||
25
frontend/src/composables/useToast.js
Normal file
25
frontend/src/composables/useToast.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { ref } from 'vue'
|
||||
|
||||
const toasts = ref([])
|
||||
|
||||
let toastId = 0
|
||||
|
||||
export function useToast() {
|
||||
function addToast(message, type = 'info', duration = 3000) {
|
||||
const id = toastId++
|
||||
toasts.value.push({ id, message, type })
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
function removeToast(id) {
|
||||
toasts.value = toasts.value.filter(t => t.id !== id)
|
||||
}
|
||||
|
||||
return {
|
||||
toasts,
|
||||
addToast,
|
||||
removeToast
|
||||
}
|
||||
}
|
||||
6
frontend/src/main.js
Normal file
6
frontend/src/main.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router/index.js'
|
||||
import './styles/main.css'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
96
frontend/src/router/index.js
Normal file
96
frontend/src/router/index.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
|
||||
const routes = [
|
||||
// ── Public routes (no auth required) ─────────────────────
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { title: 'Sign In — HireAI', public: true },
|
||||
},
|
||||
{
|
||||
path: '/auth/callback',
|
||||
name: 'AuthCallback',
|
||||
component: () => import('../views/AuthCallbackView.vue'),
|
||||
meta: { title: 'Signing in… — HireAI', public: true },
|
||||
},
|
||||
|
||||
// ── Protected routes (auth required) ─────────────────────
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: HomeView,
|
||||
meta: { title: 'HireAI — Smart Hiring Platform', public: true },
|
||||
},
|
||||
{
|
||||
path: '/resume',
|
||||
name: 'ResumeUpload',
|
||||
component: () => import('../views/ResumeUploadView.vue'),
|
||||
meta: { title: 'Resume Upload — HireAI' },
|
||||
},
|
||||
{
|
||||
path: '/requirements',
|
||||
name: 'Requirements',
|
||||
component: () => import('../views/RequirementsView.vue'),
|
||||
meta: { title: 'Interview Requirements — HireAI' },
|
||||
},
|
||||
{
|
||||
path: '/schedule',
|
||||
name: 'Schedule',
|
||||
component: () => import('../views/ScheduleView.vue'),
|
||||
meta: { title: 'Interview Schedule — HireAI' },
|
||||
},
|
||||
{
|
||||
path: '/questions',
|
||||
name: 'Questions',
|
||||
component: () => import('../views/QuestionsView.vue'),
|
||||
meta: { title: 'AI Questions — HireAI' },
|
||||
},
|
||||
{
|
||||
path: '/pronunciation',
|
||||
name: 'Pronunciation',
|
||||
component: () => import('../views/PronunciationView.vue'),
|
||||
meta: { title: 'Pronunciation Guide — HireAI' },
|
||||
},
|
||||
|
||||
// ── 404 fallback ──────────────────────────────────────────
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/',
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) return savedPosition
|
||||
return { top: 0, behavior: 'smooth' }
|
||||
},
|
||||
})
|
||||
|
||||
// ── Global Navigation Guard ───────────────────────────────────
|
||||
router.beforeEach((to) => {
|
||||
const token = localStorage.getItem('hireai_token')
|
||||
const isPublic = to.meta?.public === true
|
||||
|
||||
// Unauthenticated user trying to access protected route
|
||||
if (!isPublic && !token) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
// Already logged-in user going to /login → send home
|
||||
if (to.name === 'Login' && token) {
|
||||
return { name: 'Home' }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// ── Update document title ─────────────────────────────────────
|
||||
router.afterEach((to) => {
|
||||
document.title = to.meta?.title || 'HireAI'
|
||||
})
|
||||
|
||||
export default router
|
||||
411
frontend/src/styles/main.css
Normal file
411
frontend/src/styles/main.css
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
/* ============================================================
|
||||
HIRING PLATFORM — Design System
|
||||
============================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
||||
|
||||
/* ── CSS Design Tokens ─────────────────────────────────────── */
|
||||
:root {
|
||||
/* Colors */
|
||||
--clr-bg: #0A0D12;
|
||||
--clr-bg-2: #0F1318;
|
||||
--clr-surface: #141920;
|
||||
--clr-surface-2: #1A2030;
|
||||
--clr-border: rgba(255, 255, 255, 0.07);
|
||||
--clr-border-2: rgba(255, 255, 255, 0.12);
|
||||
|
||||
--clr-accent: #22C55E;
|
||||
--clr-accent-glow: rgba(34, 197, 94, 0.25);
|
||||
--clr-accent-dim: rgba(34, 197, 94, 0.08);
|
||||
--clr-accent-2: #16A34A;
|
||||
|
||||
--clr-purple: #8B5CF6;
|
||||
--clr-purple-dim: rgba(139, 92, 246, 0.12);
|
||||
--clr-blue: #3B82F6;
|
||||
--clr-blue-dim: rgba(59, 130, 246, 0.12);
|
||||
--clr-amber: #F59E0B;
|
||||
--clr-amber-dim: rgba(245, 158, 11, 0.12);
|
||||
|
||||
--clr-text-primary: #F0F4F8;
|
||||
--clr-text-secondary: #94A3B8;
|
||||
--clr-text-muted: #4B5A6E;
|
||||
|
||||
/* Typography */
|
||||
--font-base: 'Inter', system-ui, sans-serif;
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-md: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.375rem;
|
||||
--text-2xl: 1.75rem;
|
||||
--text-3xl: 2.25rem;
|
||||
--text-4xl: 3rem;
|
||||
--text-5xl: 3.75rem;
|
||||
--text-6xl: 5rem;
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
--space-16: 4rem;
|
||||
--space-20: 5rem;
|
||||
--space-24: 6rem;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
--radius-2xl: 32px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4);
|
||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.4);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
|
||||
--shadow-glow: 0 0 40px var(--clr-accent-glow);
|
||||
--shadow-card: 0 1px 1px rgba(0,0,0,0.2), 0 4px 20px rgba(0,0,0,0.3);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 250ms ease;
|
||||
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Navbar */
|
||||
--navbar-h: 64px;
|
||||
}
|
||||
|
||||
/* ── Reset & Base ───────────────────────────────────────────── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-base);
|
||||
font-size: var(--text-md);
|
||||
color: var(--clr-text-primary);
|
||||
background-color: var(--clr-bg);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { cursor: pointer; font-family: inherit; border: none; }
|
||||
img, svg { display: block; }
|
||||
ul, ol { list-style: none; }
|
||||
|
||||
/* ── Scrollbar ──────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--clr-bg); }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--clr-surface-2);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--clr-accent); }
|
||||
|
||||
/* ── Layout Utilities ───────────────────────────────────────── */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--space-6);
|
||||
}
|
||||
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-center { justify-content: center; }
|
||||
.gap-2 { gap: var(--space-2); }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
.gap-6 { gap: var(--space-6); }
|
||||
.gap-8 { gap: var(--space-8); }
|
||||
|
||||
.text-center { text-align: center; }
|
||||
.text-sm { font-size: var(--text-sm); }
|
||||
.text-muted { color: var(--clr-text-secondary); }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-bold { font-weight: 700; }
|
||||
|
||||
/* ── Section Layout ─────────────────────────────────────────── */
|
||||
.section {
|
||||
padding-block: var(--space-20);
|
||||
}
|
||||
.section-sm {
|
||||
padding-block: var(--space-12);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background: var(--clr-accent-dim);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
color: var(--clr-accent);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-full);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
font-size: clamp(var(--text-2xl), 4vw, var(--text-4xl));
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--clr-text-primary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.section-sub {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--clr-text-secondary);
|
||||
max-width: 560px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ── Glass Card ─────────────────────────────────────────────── */
|
||||
.glass-card {
|
||||
background: var(--clr-surface);
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: var(--radius-lg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: border-color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-base);
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
border-color: var(--clr-border-2);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0.625rem var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--clr-accent);
|
||||
color: #000;
|
||||
box-shadow: 0 0 20px var(--clr-accent-glow);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #28D869;
|
||||
box-shadow: 0 0 32px rgba(34,197,94,0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-primary:active { transform: translateY(0); }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--clr-text-secondary);
|
||||
border: 1px solid var(--clr-border);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--clr-surface-2);
|
||||
color: var(--clr-text-primary);
|
||||
border-color: var(--clr-border-2);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--clr-accent);
|
||||
border: 1px solid rgba(34, 197, 94, 0.35);
|
||||
}
|
||||
.btn-outline:hover {
|
||||
background: var(--clr-accent-dim);
|
||||
border-color: var(--clr-accent);
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.8rem var(--space-6);
|
||||
font-size: var(--text-md);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.4rem var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Badge ──────────────────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.badge-green { background: var(--clr-accent-dim); color: var(--clr-accent); border: 1px solid rgba(34,197,94,0.2); }
|
||||
.badge-purple { background: var(--clr-purple-dim); color: var(--clr-purple); border: 1px solid rgba(139,92,246,0.2); }
|
||||
.badge-blue { background: var(--clr-blue-dim); color: var(--clr-blue); border: 1px solid rgba(59,130,246,0.2); }
|
||||
.badge-amber { background: var(--clr-amber-dim); color: var(--clr-amber); border: 1px solid rgba(245,158,11,0.2); }
|
||||
|
||||
/* ── Form Elements ──────────────────────────────────────────── */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--clr-text-secondary);
|
||||
}
|
||||
|
||||
.form-input, .form-select, .form-textarea {
|
||||
width: 100%;
|
||||
background: var(--clr-surface-2);
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--clr-text-primary);
|
||||
font-family: var(--font-base);
|
||||
font-size: var(--text-sm);
|
||||
padding: 0.65rem var(--space-4);
|
||||
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-input::placeholder, .form-textarea::placeholder {
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||
border-color: var(--clr-accent);
|
||||
box-shadow: 0 0 0 3px var(--clr-accent-dim);
|
||||
}
|
||||
|
||||
.form-textarea { resize: vertical; min-height: 120px; }
|
||||
.form-select { appearance: none; cursor: pointer; }
|
||||
|
||||
/* ── Gradient Text ──────────────────────────────────────────── */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--clr-accent) 0%, #16A34A 50%, #22D3EE 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ── Dot grid background ────────────────────────────────────── */
|
||||
.dot-bg {
|
||||
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
}
|
||||
|
||||
/* ── Glow orb ───────────────────────────────────────────────── */
|
||||
.glow-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Divider ────────────────────────────────────────────────── */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--clr-border);
|
||||
margin-block: var(--space-8);
|
||||
}
|
||||
|
||||
/* ── Page wrapper (accounts for sticky navbar) ──────────────── */
|
||||
.page-content {
|
||||
padding-top: var(--navbar-h);
|
||||
}
|
||||
|
||||
/* ── Fade in animation ──────────────────────────────────────── */
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(24px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 20px var(--clr-accent-glow); }
|
||||
50% { box-shadow: 0 0 40px rgba(34,197,94,0.45); }
|
||||
}
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
.animate-fade-up { animation: fadeUp 0.6s ease both; }
|
||||
.animate-fade-in { animation: fadeIn 0.4s ease both; }
|
||||
.animate-scale-in { animation: scaleIn 0.4s ease both; }
|
||||
.animate-float { animation: float 4s ease-in-out infinite; }
|
||||
|
||||
/* Staggered children */
|
||||
.stagger > *:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger > *:nth-child(2) { animation-delay: 80ms; }
|
||||
.stagger > *:nth-child(3) { animation-delay: 160ms; }
|
||||
.stagger > *:nth-child(4) { animation-delay: 240ms; }
|
||||
.stagger > *:nth-child(5) { animation-delay: 320ms; }
|
||||
|
||||
/* ── Skeleton loader ────────────────────────────────────────── */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg,
|
||||
var(--clr-surface) 25%,
|
||||
var(--clr-surface-2) 50%,
|
||||
var(--clr-surface) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Responsive grid ────────────────────────────────────────── */
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-6); }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-6); }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-6); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid-3, .grid-4 { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
|
||||
.container { padding-inline: var(--space-4); }
|
||||
.section { padding-block: var(--space-12); }
|
||||
.section-heading { font-size: var(--text-2xl); }
|
||||
}
|
||||
|
||||
|
||||
77
frontend/src/views/AuthCallbackView.vue
Normal file
77
frontend/src/views/AuthCallbackView.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<div class="callback-page">
|
||||
<div class="callback-card glass-card animate-scale-in">
|
||||
<div v-if="!error">
|
||||
<div class="spinner"></div>
|
||||
<h2>Signing you in…</h2>
|
||||
<p class="text-muted">Please wait while we set up your session.</p>
|
||||
</div>
|
||||
<div v-else class="error-state">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<h2>Sign-in Failed</h2>
|
||||
<p class="text-muted">{{ error }}</p>
|
||||
<RouterLink to="/login" class="btn btn-primary" style="margin-top: 1rem">
|
||||
Try Again
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuth } from '../composables/useAuth.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { setToken } = useAuth()
|
||||
const error = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
const token = route.query.token
|
||||
|
||||
if (!token) {
|
||||
error.value = 'No authentication token received from Google.'
|
||||
return
|
||||
}
|
||||
|
||||
// Store token + decode user
|
||||
setToken(token)
|
||||
|
||||
// Redirect to original destination or home
|
||||
const redirect = route.query.redirect || '/'
|
||||
router.replace(redirect)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.callback-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--clr-bg);
|
||||
}
|
||||
.callback-card {
|
||||
padding: var(--space-12) var(--space-10);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
min-width: 320px;
|
||||
}
|
||||
.spinner {
|
||||
width: 48px; height: 48px;
|
||||
border: 3px solid var(--clr-border);
|
||||
border-top-color: var(--clr-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.error-state { display: flex; flex-direction: column; align-items: center; gap: var(--space-3); }
|
||||
.error-icon { font-size: 2.5rem; }
|
||||
</style>
|
||||
477
frontend/src/views/HomeView.vue
Normal file
477
frontend/src/views/HomeView.vue
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
<template>
|
||||
<div class="home">
|
||||
<!-- ── Hero ─────────────────────────────────────────────── -->
|
||||
<section class="hero dot-bg">
|
||||
<!-- Background orbs -->
|
||||
<div class="glow-orb orb-1"></div>
|
||||
<div class="glow-orb orb-2"></div>
|
||||
|
||||
<div class="container hero__content">
|
||||
<div class="hero__badge animate-fade-up">
|
||||
<span class="status-dot"></span>
|
||||
Powered by Sentiense AI
|
||||
</div>
|
||||
|
||||
<h1 class="hero__heading animate-fade-up" style="animation-delay:80ms">
|
||||
Hire Smarter,<br>
|
||||
<span class="gradient-text">Not Harder.</span>
|
||||
</h1>
|
||||
|
||||
<p class="hero__sub animate-fade-up" style="animation-delay:160ms">
|
||||
Upload resumes, set requirements, schedule interviews, and let AI generate
|
||||
tailored questions — all in one platform built for modern hiring teams.
|
||||
</p>
|
||||
|
||||
<div class="hero__ctas animate-fade-up" style="animation-delay:240ms">
|
||||
<RouterLink to="/resume" class="btn btn-primary btn-lg" id="hero-cta-primary">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Upload a Resume
|
||||
</RouterLink>
|
||||
<RouterLink to="/questions" class="btn btn-ghost btn-lg" id="hero-cta-secondary">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
Generate Questions
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="hero__stats animate-fade-up stagger" style="animation-delay:320ms">
|
||||
<div class="stat-item" v-for="stat in stats" :key="stat.label">
|
||||
<span class="stat-value">{{ stat.value }}</span>
|
||||
<span class="stat-label">{{ stat.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero visual -->
|
||||
<div class="hero__visual animate-fade-in" style="animation-delay:400ms">
|
||||
<div class="hero-card animate-float" id="hero-card-main">
|
||||
<div class="hcard-header">
|
||||
<div class="hcard-avatar">SD</div>
|
||||
<div>
|
||||
<div class="hcard-name">Suyash Dixit</div>
|
||||
<div class="hcard-role">Senior Data Engineer</div>
|
||||
</div>
|
||||
<div class="badge badge-green" style="margin-left:auto">Match 94%</div>
|
||||
</div>
|
||||
<div class="hcard-skills">
|
||||
<span class="badge badge-blue">Python</span>
|
||||
<span class="badge badge-purple">FastAPI</span>
|
||||
<span class="badge badge-green">MongoDB</span>
|
||||
<span class="badge badge-amber">Docker</span>
|
||||
</div>
|
||||
<div class="hcard-divider"></div>
|
||||
<div class="hcard-q">
|
||||
<div class="hcard-q-label">🤖 AI Generated Question</div>
|
||||
<div class="hcard-q-text">"Explain how you'd design a distributed rate limiter in Python."</div>
|
||||
</div>
|
||||
<div class="hcard-footer">
|
||||
<span class="hcard-schedule">📅 Interview: Tomorrow 10:00 AM</span>
|
||||
<span class="badge badge-green">Scheduled</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating mini-card -->
|
||||
<div class="mini-card mc-1 animate-float" style="animation-delay:1s">
|
||||
<span>🔊</span>
|
||||
<div>
|
||||
<div class="mc-word">pol·y·mor·phism</div>
|
||||
<div class="mc-phonetic">/ˌpɒlɪˈmɔːfɪzəm/</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-card mc-2 animate-float" style="animation-delay:2s">
|
||||
<span>✅</span>
|
||||
<div>
|
||||
<div class="mc-word">5 Questions Ready</div>
|
||||
<div class="mc-phonetic">Tailored to resume</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Feature Cards ─────────────────────────────────────── -->
|
||||
<section class="section features" id="features">
|
||||
<div class="container">
|
||||
<div class="text-center" style="margin-bottom: var(--space-12)">
|
||||
<div class="section-label">✦ Everything You Need</div>
|
||||
<h2 class="section-heading">One Platform.<br>Every Hiring Step.</h2>
|
||||
<p class="section-sub" style="margin-inline:auto">From the first resume to the final question, HireAI handles your entire hiring workflow with AI precision.</p>
|
||||
</div>
|
||||
|
||||
<div class="features-grid stagger">
|
||||
<RouterLink
|
||||
v-for="feature in features"
|
||||
:key="feature.to"
|
||||
:to="feature.to"
|
||||
class="feature-card glass-card"
|
||||
:id="`feature-${feature.id}`"
|
||||
>
|
||||
<div class="feature-icon" :style="{ background: feature.bg }">
|
||||
{{ feature.icon }}
|
||||
</div>
|
||||
<h3 class="feature-title">{{ feature.title }}</h3>
|
||||
<p class="feature-desc">{{ feature.desc }}</p>
|
||||
<div class="feature-arrow">
|
||||
→ <span>Get started</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── How It Works ──────────────────────────────────────── -->
|
||||
<section class="section how-it-works" id="how-it-works">
|
||||
<div class="container">
|
||||
<div class="text-center" style="margin-bottom: var(--space-12)">
|
||||
<div class="section-label">⚡ Workflow</div>
|
||||
<h2 class="section-heading">From Resume to Interview<br>in Minutes</h2>
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div class="step" v-for="(step, i) in steps" :key="i">
|
||||
<div class="step-num">{{ i + 1 }}</div>
|
||||
<div class="step-line" v-if="i < steps.length - 1"></div>
|
||||
<div class="step-body glass-card">
|
||||
<div class="step-icon">{{ step.icon }}</div>
|
||||
<h4 class="step-title">{{ step.title }}</h4>
|
||||
<p class="step-desc">{{ step.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── CTA Banner ────────────────────────────────────────── -->
|
||||
<section class="section-sm cta-banner">
|
||||
<div class="container">
|
||||
<div class="cta-inner glass-card">
|
||||
<div class="glow-orb cta-orb"></div>
|
||||
<div class="cta-text">
|
||||
<div class="section-label">🚀 Ready?</div>
|
||||
<h2 class="section-heading" style="font-size: var(--text-3xl)">Start Hiring Smarter Today</h2>
|
||||
<p class="section-sub">Upload your first resume and let AI do the heavy lifting.</p>
|
||||
</div>
|
||||
<RouterLink to="/resume" class="btn btn-primary btn-lg" id="home-bottom-cta">
|
||||
Get Started Free
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const stats = [
|
||||
{ value: '5x', label: 'Faster Screening' },
|
||||
{ value: '94%', label: 'Question Relevance' },
|
||||
{ value: '∞', label: 'Resumes Supported' },
|
||||
]
|
||||
|
||||
const features = [
|
||||
{
|
||||
to: '/resume',
|
||||
id: 'resume',
|
||||
icon: '📄',
|
||||
bg: 'rgba(59,130,246,0.1)',
|
||||
title: 'Resume Upload',
|
||||
desc: 'Drag & drop PDF or DOCX resumes. Our AI instantly extracts skills, experience, and key data.',
|
||||
},
|
||||
{
|
||||
to: '/requirements',
|
||||
id: 'requirements',
|
||||
icon: '📋',
|
||||
bg: 'rgba(139,92,246,0.1)',
|
||||
title: 'Interview Requirements',
|
||||
desc: 'Define job titles, required skills, experience levels, and custom interview criteria.',
|
||||
},
|
||||
{
|
||||
to: '/schedule',
|
||||
id: 'schedule',
|
||||
icon: '📅',
|
||||
bg: 'rgba(245,158,11,0.1)',
|
||||
title: 'Interview Schedule',
|
||||
desc: 'Book interviews, detect conflicts, and automatically notify candidates by email.',
|
||||
},
|
||||
{
|
||||
to: '/questions',
|
||||
id: 'questions',
|
||||
icon: '🤖',
|
||||
bg: 'rgba(34,197,94,0.1)',
|
||||
title: 'AI Questions',
|
||||
desc: 'Gemini AI generates 5 tailored interview questions based on the candidate\'s resume and role.',
|
||||
},
|
||||
{
|
||||
to: '/pronunciation',
|
||||
id: 'pronunciation',
|
||||
icon: '🔊',
|
||||
bg: 'rgba(236,72,153,0.1)',
|
||||
title: 'Pronunciation Guide',
|
||||
desc: 'Get phonetics, audio playback, and definitions for complex tech terms like "polymorphism."',
|
||||
},
|
||||
]
|
||||
|
||||
const steps = [
|
||||
{ icon: '📄', title: 'Upload Resume', desc: 'Upload candidate PDF/DOCX and let AI parse it instantly.' },
|
||||
{ icon: '📋', title: 'Set Requirements', desc: 'Define the role, skills, and experience needed.' },
|
||||
{ icon: '📅', title: 'Schedule Interview', desc: 'Pick a slot — candidate gets an email automatically.' },
|
||||
{ icon: '🤖', title: 'Generate Questions', desc: 'Gemini creates 5 smart, tailored interview questions.' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Hero ─────────────────────────────────────────────────── */
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
min-height: 100vh;
|
||||
padding-block: var(--space-24);
|
||||
padding-inline: var(--space-6);
|
||||
max-width: 1200px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 500px; height: 500px;
|
||||
background: radial-gradient(circle, rgba(34,197,94,0.12) 0%, transparent 70%);
|
||||
top: -100px; left: -150px;
|
||||
}
|
||||
.orb-2 {
|
||||
width: 400px; height: 400px;
|
||||
background: radial-gradient(circle, rgba(139,92,246,0.1) 0%, transparent 70%);
|
||||
bottom: 0; right: -100px;
|
||||
}
|
||||
|
||||
.hero__content { position: relative; z-index: 1; }
|
||||
|
||||
.hero__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--clr-accent-dim);
|
||||
border: 1px solid rgba(34,197,94,0.2);
|
||||
color: var(--clr-accent);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.status-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--clr-accent);
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hero__heading {
|
||||
font-size: clamp(2.5rem, 5vw, var(--text-6xl));
|
||||
font-weight: 900;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.hero__sub {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--clr-text-secondary);
|
||||
line-height: 1.7;
|
||||
max-width: 480px;
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.hero__ctas {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-10);
|
||||
}
|
||||
|
||||
.hero__stats {
|
||||
display: flex;
|
||||
gap: var(--space-8);
|
||||
border-top: 1px solid var(--clr-border);
|
||||
padding-top: var(--space-6);
|
||||
}
|
||||
.stat-item { display: flex; flex-direction: column; gap: 2px; }
|
||||
.stat-value { font-size: var(--text-2xl); font-weight: 800; color: var(--clr-accent); letter-spacing: -0.02em; }
|
||||
.stat-label { font-size: var(--text-xs); color: var(--clr-text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
|
||||
/* Hero Visual */
|
||||
.hero__visual {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
background: var(--clr-surface);
|
||||
border: 1px solid var(--clr-border-2);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-5);
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.hcard-header { display: flex; align-items: center; gap: var(--space-3); }
|
||||
.hcard-avatar {
|
||||
width: 44px; height: 44px;
|
||||
background: linear-gradient(135deg, var(--clr-accent), var(--clr-blue));
|
||||
border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: var(--text-sm); color: #000; flex-shrink: 0;
|
||||
}
|
||||
.hcard-name { font-weight: 600; font-size: var(--text-sm); }
|
||||
.hcard-role { font-size: var(--text-xs); color: var(--clr-text-secondary); }
|
||||
.hcard-skills { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.hcard-divider { height: 1px; background: var(--clr-border); }
|
||||
.hcard-q-label { font-size: var(--text-xs); color: var(--clr-text-muted); margin-bottom: var(--space-2); font-weight: 600; }
|
||||
.hcard-q-text { font-size: var(--text-sm); color: var(--clr-text-primary); line-height: 1.6; font-style: italic; }
|
||||
.hcard-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||
.hcard-schedule { font-size: var(--text-xs); color: var(--clr-text-secondary); }
|
||||
|
||||
/* Mini floating cards */
|
||||
.mini-card {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background: var(--clr-surface-2);
|
||||
border: 1px solid var(--clr-border-2);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px 14px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.mc-1 { bottom: -20px; left: -30px; }
|
||||
.mc-2 { top: -16px; right: -20px; }
|
||||
.mc-word { font-size: var(--text-xs); font-weight: 600; color: var(--clr-text-primary); }
|
||||
.mc-phonetic { font-size: 10px; color: var(--clr-text-muted); }
|
||||
|
||||
/* ── Features ─────────────────────────────────────────────── */
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-6);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.feature-title { font-size: var(--text-lg); font-weight: 700; color: var(--clr-text-primary); }
|
||||
.feature-desc { font-size: var(--text-sm); color: var(--clr-text-secondary); line-height: 1.6; flex: 1; }
|
||||
.feature-arrow {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--clr-text-muted);
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
transition: color var(--transition-fast), gap var(--transition-fast);
|
||||
}
|
||||
.feature-card:hover .feature-arrow { color: var(--clr-accent); gap: 10px; }
|
||||
|
||||
/* ── Steps ────────────────────────────────────────────────── */
|
||||
.steps {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 36px; height: 36px;
|
||||
background: var(--clr-accent);
|
||||
color: #000;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 800; font-size: var(--text-sm);
|
||||
z-index: 1;
|
||||
box-shadow: 0 0 16px var(--clr-accent-glow);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-line {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: calc(50% + 18px);
|
||||
right: calc(-50% + 18px);
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--clr-accent), var(--clr-border));
|
||||
}
|
||||
|
||||
.step-body {
|
||||
width: 100%;
|
||||
padding: var(--space-5);
|
||||
text-align: center;
|
||||
}
|
||||
.step-icon { font-size: 1.5rem; margin-bottom: var(--space-2); }
|
||||
.step-title { font-size: var(--text-md); font-weight: 700; margin-bottom: var(--space-2); }
|
||||
.step-desc { font-size: var(--text-sm); color: var(--clr-text-secondary); line-height: 1.6; }
|
||||
|
||||
/* ── CTA Banner ─────────────────────────────────────────── */
|
||||
.cta-banner { margin-bottom: 0; }
|
||||
.cta-inner {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-10) var(--space-10);
|
||||
}
|
||||
.cta-orb {
|
||||
width: 300px; height: 300px;
|
||||
background: radial-gradient(circle, rgba(34,197,94,0.15) 0%, transparent 70%);
|
||||
right: 200px; top: -100px;
|
||||
}
|
||||
.cta-text { position: relative; z-index: 1; }
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────── */
|
||||
@media (max-width: 960px) {
|
||||
.hero { grid-template-columns: 1fr; text-align: center; padding-block: var(--space-16); }
|
||||
.hero__visual { display: none; }
|
||||
.hero__sub { margin-inline: auto; }
|
||||
.hero__ctas { justify-content: center; }
|
||||
.hero__stats { justify-content: center; }
|
||||
.features-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.features-grid { grid-template-columns: 1fr; }
|
||||
.steps { flex-direction: column; align-items: stretch; }
|
||||
.step-line { display: none; }
|
||||
.cta-inner { flex-direction: column; text-align: center; padding: var(--space-8); }
|
||||
}
|
||||
</style>
|
||||
218
frontend/src/views/LoginView.vue
Normal file
218
frontend/src/views/LoginView.vue
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<template>
|
||||
<div class="login-page dot-bg">
|
||||
<!-- Background Glow -->
|
||||
<div class="glow-orb login-glow"></div>
|
||||
|
||||
<div class="login-container animate-fade-up">
|
||||
<div class="login-card glass-card">
|
||||
<!-- Logo -->
|
||||
<div class="login-logo">
|
||||
<div class="logo-icon-lg">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" fill="currentColor"/>
|
||||
<path d="M2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="logo-text-lg">Hire<span class="logo-accent">AI</span></span>
|
||||
</div>
|
||||
|
||||
<h1 class="login-title">Welcome!</h1>
|
||||
<p class="login-sub">Sign in to access your AI-powered hiring dashboard</p>
|
||||
|
||||
<!-- Google Button -->
|
||||
<button @click="loginWithGoogle" class="btn-google">
|
||||
<img src="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg" alt="Google" class="google-icon">
|
||||
<span>Continue with Google</span>
|
||||
</button>
|
||||
|
||||
<!-- Feature List -->
|
||||
<div class="feature-list">
|
||||
<div class="feature-item">
|
||||
<span class="check">✓</span>
|
||||
<span class="feature-text">AI-generated interview questions from resumes</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<span class="check">✓</span>
|
||||
<span class="feature-text">Pronunciation guide for 50+ tech terms</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<span class="check">✓</span>
|
||||
<span class="feature-text">Smart interview scheduling with email alerts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="login-footer">
|
||||
By signing in, you agree to our
|
||||
<a href="#" class="link">Terms of Service</a> and
|
||||
<a href="#" class="link">Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAuth } from '../composables/useAuth.js'
|
||||
|
||||
const { loginWithGoogle } = useAuth()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-6);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: #050505; /* Deepest black */
|
||||
}
|
||||
|
||||
.login-glow {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, rgba(34, 197, 94, 0.08) 0%, transparent 70%);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: var(--space-10) var(--space-8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(15, 15, 15, 0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.logo-icon-lg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--clr-accent);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #000;
|
||||
box-shadow: 0 0 20px var(--clr-accent-glow);
|
||||
}
|
||||
|
||||
.logo-text-lg {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logo-accent {
|
||||
color: var(--clr-accent);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
color: white;
|
||||
margin-bottom: -10px;
|
||||
}
|
||||
|
||||
.login-sub {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--clr-text-secondary);
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.btn-google {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
background: #1a1a1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
padding: 14px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-google:hover {
|
||||
background: #242429;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.google-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
width: 100%;
|
||||
background: rgba(34, 197, 94, 0.03);
|
||||
border: 1px solid rgba(34, 197, 94, 0.1);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.check {
|
||||
color: #22c55e;
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.feature-text {
|
||||
font-size: var(--text-sm);
|
||||
color: #a1a1aa; /* text-zinc-400 */
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
font-size: 0.75rem;
|
||||
color: #71717a; /* text-zinc-500 */
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #a1a1aa;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
363
frontend/src/views/PronunciationView.vue
Normal file
363
frontend/src/views/PronunciationView.vue
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
<template>
|
||||
<div class="pronunciation-page section">
|
||||
<div class="container">
|
||||
<div class="page-header animate-fade-up">
|
||||
<div class="section-label">🔊 Step 5 of 5</div>
|
||||
<h1 class="section-heading">Pronunciation Guide</h1>
|
||||
<p class="section-sub">Master the pronunciation of complex technical terms before your interviews.</p>
|
||||
</div>
|
||||
|
||||
<div class="pronunciation-layout">
|
||||
<!-- Main Search and Display -->
|
||||
<div class="main-panel animate-fade-up" style="animation-delay: 100ms;">
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="search-box glass-card">
|
||||
<svg class="search-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search for a tech term (e.g., Kubernetes)..."
|
||||
class="search-input"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button class="btn btn-primary btn-search" @click="handleSearch" :disabled="loading">
|
||||
<span v-if="loading" class="spinner-sm"></span>
|
||||
<span v-else>Lookup</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Result Card -->
|
||||
<div class="result-card glass-card" v-if="currentWord && !loading">
|
||||
<div class="result-header">
|
||||
<h2 class="word-title">{{ currentWord.word }}</h2>
|
||||
<button class="btn-play" @click="playAudio(currentWord.word)" title="Play Pronunciation">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="phonetic-display">
|
||||
<span class="phonetic-text">{{ currentWord.phonetic }}</span>
|
||||
<div class="syllables">
|
||||
<span class="syllable" v-for="(syllable, i) in currentWord.syllables" :key="i">
|
||||
{{ syllable }}
|
||||
<span class="dot" v-if="i < currentWord.syllables.length - 1">•</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="definition-box">
|
||||
<h4 class="def-label">Definition</h4>
|
||||
<p class="def-text">{{ currentWord.definition }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Initial/Empty/Error States -->
|
||||
<div class="empty-state glass-card" v-else-if="!loading && !currentWord">
|
||||
<div class="empty-icon">🗣️</div>
|
||||
<h3>Ready to learn</h3>
|
||||
<p class="text-muted">Type a word above or click one from the bank to hear how it's pronounced.</p>
|
||||
<p class="error-text" v-if="error">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="skeleton-card glass-card" v-if="loading">
|
||||
<div class="skeleton-line title"></div>
|
||||
<div class="skeleton-line text"></div>
|
||||
<div class="skeleton-line text short"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Sidebar (Word of the Day + Bank) -->
|
||||
<div class="sidebar animate-fade-up" style="animation-delay: 200ms;">
|
||||
|
||||
<!-- Word of the Day -->
|
||||
<div class="wod-card glass-card" v-if="wordOfDay">
|
||||
<div class="wod-header">
|
||||
<span class="wod-badge">⭐ Word of the Day</span>
|
||||
</div>
|
||||
<h3 class="wod-title">{{ wordOfDay.word }}</h3>
|
||||
<p class="wod-phonetic">{{ wordOfDay.phonetic }}</p>
|
||||
<p class="wod-def">{{ wordOfDay.definition }}</p>
|
||||
<button class="btn btn-ghost btn-sm" @click="loadWord(wordOfDay.word)" style="margin-top: 12px; width: 100%">
|
||||
Load Details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Word Bank -->
|
||||
<div class="word-bank glass-card">
|
||||
<h3 class="bank-title">Popular Tech Terms</h3>
|
||||
<div class="bank-grid">
|
||||
<button
|
||||
class="bank-chip"
|
||||
v-for="term in techTerms"
|
||||
:key="term"
|
||||
@click="loadWord(term)"
|
||||
>
|
||||
{{ term }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../api/index.js'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const currentWord = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const wordOfDay = ref(null)
|
||||
const techTerms = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
fetchWordOfDay()
|
||||
fetchTechTerms()
|
||||
})
|
||||
|
||||
async function fetchWordOfDay() {
|
||||
try {
|
||||
const { data } = await api.get('/api/pronunciation/word-of-the-day')
|
||||
wordOfDay.value = data
|
||||
} catch (e) {
|
||||
console.error("Failed to load WOD", e)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTechTerms() {
|
||||
try {
|
||||
const { data } = await api.get('/api/pronunciation/tech-terms')
|
||||
techTerms.value = data
|
||||
} catch (e) {
|
||||
console.error("Failed to load terms", e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
if (!searchQuery.value.trim()) return
|
||||
await loadWord(searchQuery.value)
|
||||
}
|
||||
|
||||
async function loadWord(word) {
|
||||
searchQuery.value = word
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
currentWord.value = null
|
||||
|
||||
try {
|
||||
const { data } = await api.get(`/api/pronunciation/${encodeURIComponent(word.trim())}`)
|
||||
currentWord.value = data
|
||||
// Auto-play audio on load
|
||||
playAudio(data.word)
|
||||
} catch (e) {
|
||||
error.value = "Failed to find pronunciation. Try a different word."
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function playAudio(text) {
|
||||
if ('speechSynthesis' in window) {
|
||||
// Cancel any ongoing speech
|
||||
window.speechSynthesis.cancel()
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
utterance.lang = 'en-US'
|
||||
utterance.rate = 0.9 // slightly slower for clarity
|
||||
|
||||
// Attempt to pick a good English voice
|
||||
const voices = window.speechSynthesis.getVoices()
|
||||
const preferredVoice = voices.find(v => v.name.includes('Google') && v.lang.startsWith('en'))
|
||||
|| voices.find(v => v.lang === 'en-US')
|
||||
if (preferredVoice) {
|
||||
utterance.voice = preferredVoice
|
||||
}
|
||||
|
||||
window.speechSynthesis.speak(utterance)
|
||||
} else {
|
||||
alert("Sorry, your browser doesn't support text-to-speech functionality.")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pronunciation-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: var(--space-8);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.pronunciation-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Search Box */
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 8px 8px 16px;
|
||||
gap: 12px;
|
||||
border-radius: var(--radius-full);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.search-icon { color: var(--clr-text-muted); }
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: var(--text-lg);
|
||||
outline: none;
|
||||
}
|
||||
.search-input::placeholder { color: var(--clr-text-muted); }
|
||||
|
||||
.btn-search {
|
||||
border-radius: var(--radius-full);
|
||||
padding: 10px 24px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Result Card */
|
||||
.result-card {
|
||||
padding: var(--space-8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.word-title {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-play {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--clr-accent);
|
||||
color: black;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 0 20px rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
.btn-play:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 30px rgba(34, 197, 94, 0.6);
|
||||
}
|
||||
|
||||
.phonetic-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.phonetic-text {
|
||||
font-size: var(--text-xl);
|
||||
color: var(--clr-accent);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.syllables {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: var(--text-md);
|
||||
color: var(--clr-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.syllable { display: flex; align-items: center; gap: 8px; }
|
||||
.dot { color: var(--clr-border); font-size: 10px; }
|
||||
|
||||
.definition-box {
|
||||
background: rgba(255,255,255,0.03);
|
||||
padding: var(--space-5);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 3px solid var(--clr-accent);
|
||||
}
|
||||
.def-label { font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.1em; color: var(--clr-text-muted); margin-bottom: 8px; }
|
||||
.def-text { font-size: var(--text-md); line-height: 1.6; color: #d1d5db; }
|
||||
|
||||
/* Empty / Error */
|
||||
.empty-state {
|
||||
height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--space-8);
|
||||
}
|
||||
.empty-icon { font-size: 3rem; margin-bottom: var(--space-4); }
|
||||
.error-text { color: #ef4444; margin-top: var(--space-4); }
|
||||
|
||||
/* Skeleton */
|
||||
.skeleton-card { height: 300px; padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.skeleton-line { background: rgba(255,255,255,0.05); border-radius: 4px; animation: pulse 1.5s infinite ease-in-out; }
|
||||
.skeleton-line.title { height: 40px; width: 50%; }
|
||||
.skeleton-line.text { height: 14px; width: 100%; }
|
||||
.skeleton-line.text.short { width: 60%; }
|
||||
|
||||
@keyframes pulse { 0% { opacity: 0.5; } 50% { opacity: 1; } 100% { opacity: 0.5; } }
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||
|
||||
.wod-card {
|
||||
padding: var(--space-5);
|
||||
background: linear-gradient(145deg, rgba(34, 197, 94, 0.05) 0%, rgba(10, 13, 18, 0.8) 100%);
|
||||
border-color: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
.wod-badge { font-size: 10px; font-weight: 700; color: #fcd34d; text-transform: uppercase; letter-spacing: 0.05em; display: inline-block; margin-bottom: 12px; }
|
||||
.wod-title { font-size: var(--text-xl); font-weight: 800; color: white; margin-bottom: 4px; }
|
||||
.wod-phonetic { font-size: var(--text-sm); font-family: monospace; color: var(--clr-text-secondary); margin-bottom: 12px; }
|
||||
.wod-def { font-size: var(--text-sm); color: var(--clr-text-muted); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
.word-bank { padding: var(--space-5); max-height: 400px; overflow-y: auto; }
|
||||
.word-bank::-webkit-scrollbar { width: 4px; }
|
||||
.word-bank::-webkit-scrollbar-thumb { background: var(--clr-border); border-radius: 4px; }
|
||||
|
||||
.bank-title { font-size: var(--text-sm); font-weight: 700; color: white; margin-bottom: var(--space-4); }
|
||||
.bank-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
.bank-chip {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid var(--clr-border);
|
||||
color: var(--clr-text-secondary);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.bank-chip:hover {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
color: var(--clr-accent);
|
||||
}
|
||||
</style>
|
||||
316
frontend/src/views/QuestionsView.vue
Normal file
316
frontend/src/views/QuestionsView.vue
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
<template>
|
||||
<div class="questions-page section">
|
||||
<div class="container">
|
||||
<div class="page-header animate-fade-up">
|
||||
<div class="section-label">🤖 Step 4 of 5</div>
|
||||
<h1 class="section-heading">AI Question Generator</h1>
|
||||
<p class="section-sub">Gemini AI will analyze the resume against your requirements to generate 5 targeted technical questions.</p>
|
||||
</div>
|
||||
|
||||
<div class="questions-layout">
|
||||
<!-- Configuration Column -->
|
||||
<div class="config-col animate-fade-up" style="animation-delay: 100ms;">
|
||||
<div class="glass-card config-card">
|
||||
<h3 class="config-title">Generation Settings</h3>
|
||||
|
||||
<div class="config-form">
|
||||
<!-- Resume Selector -->
|
||||
<div class="form-group">
|
||||
<label for="resumeSelect">Candidate Resume</label>
|
||||
<select id="resumeSelect" v-model="selection.resume_id" class="form-control" @change="fetchExisting">
|
||||
<option value="" disabled>-- Select Candidate --</option>
|
||||
<option v-for="res in resumes" :key="res.id" :value="res.id">
|
||||
{{ res.parsed?.name || res.filename }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Requirement Selector -->
|
||||
<div class="form-group">
|
||||
<label for="reqSelect">Job Requirement</label>
|
||||
<select id="reqSelect" v-model="selection.requirement_id" class="form-control" @change="fetchExisting">
|
||||
<option value="" disabled>-- Select Requirement --</option>
|
||||
<option v-for="req in requirements" :key="req.id" :value="req.id">
|
||||
{{ req.role_name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary generate-btn"
|
||||
:disabled="!isReady || generating"
|
||||
@click="handleGenerate"
|
||||
>
|
||||
<span v-if="generating" class="spinner-sm"></span>
|
||||
{{ generating ? 'Generating with Gemini...' : 'Generate Questions' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selection Summary -->
|
||||
<div class="glass-card summary-card" v-if="selectedResume || selectedReq">
|
||||
<h4 class="summary-title">Matching Context</h4>
|
||||
<div class="summary-item" v-if="selectedResume">
|
||||
<span class="label">Candidate:</span>
|
||||
<span class="value">{{ selectedResume.parsed?.name }}</span>
|
||||
</div>
|
||||
<div class="summary-item" v-if="selectedReq">
|
||||
<span class="label">Role:</span>
|
||||
<span class="value">{{ selectedReq.role_name }}</span>
|
||||
</div>
|
||||
<div class="summary-item" v-if="selectedReq">
|
||||
<span class="label">Target Skills:</span>
|
||||
<div class="skill-tags">
|
||||
<span class="mini-tag" v-for="s in selectedReq.mandatory_skills" :key="s">{{ s }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Questions Column -->
|
||||
<div class="results-col animate-fade-up" style="animation-delay: 200ms;">
|
||||
<!-- Generating State -->
|
||||
<div v-if="generating" class="skeleton-list">
|
||||
<div class="skeleton-card glass-card" v-for="i in 3" :key="i">
|
||||
<div class="skeleton-line title"></div>
|
||||
<div class="skeleton-line text"></div>
|
||||
<div class="skeleton-line text short"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!currentQuestions.length" class="empty-state glass-card">
|
||||
<div class="empty-icon">🤖</div>
|
||||
<h3>Ready to generate</h3>
|
||||
<p class="text-muted">Select a candidate and requirement to generate tailored questions.</p>
|
||||
</div>
|
||||
|
||||
<!-- Questions List -->
|
||||
<div v-else class="questions-list">
|
||||
<div class="question-card glass-card animate-fade-up" v-for="(q, i) in currentQuestions" :key="i" :style="{animationDelay: (i * 100) + 'ms'}">
|
||||
<div class="q-header">
|
||||
<div class="q-num">Q{{ i + 1 }}</div>
|
||||
<div class="q-badges">
|
||||
<span class="badge" :class="'difficulty-' + q.difficulty?.toLowerCase()">{{ q.difficulty }}</span>
|
||||
<span class="badge category-badge">{{ q.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="q-text">{{ q.question }}</h4>
|
||||
|
||||
<div class="q-expanders">
|
||||
<details class="q-details">
|
||||
<summary>View Expected Answer</summary>
|
||||
<div class="details-content answer">
|
||||
{{ q.expected_answer }}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="q-details">
|
||||
<summary>View Interviewer Hint</summary>
|
||||
<div class="details-content hint">
|
||||
{{ q.hint }}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import api from '../api/index.js'
|
||||
import { useToast } from '../composables/useToast.js'
|
||||
|
||||
const { addToast } = useToast()
|
||||
|
||||
const selection = ref({
|
||||
resume_id: '',
|
||||
requirement_id: ''
|
||||
})
|
||||
|
||||
const resumes = ref([])
|
||||
const requirements = ref([])
|
||||
const currentQuestions = ref([])
|
||||
|
||||
const generating = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const isReady = computed(() => selection.value.resume_id && selection.value.requirement_id)
|
||||
|
||||
const selectedResume = computed(() => resumes.value.find(r => r.id === selection.value.resume_id))
|
||||
const selectedReq = computed(() => requirements.value.find(r => r.id === selection.value.requirement_id))
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadResumes(), loadRequirements()])
|
||||
})
|
||||
|
||||
async function loadResumes() {
|
||||
const { data } = await api.get('/api/resume/')
|
||||
resumes.value = data
|
||||
}
|
||||
|
||||
async function loadRequirements() {
|
||||
const { data } = await api.get('/api/requirements/')
|
||||
requirements.value = data
|
||||
}
|
||||
|
||||
async function fetchExisting() {
|
||||
if (!isReady.value) return
|
||||
try {
|
||||
const { data } = await api.get(`/api/questions/${selection.value.resume_id}`)
|
||||
// Look for a match with this requirement
|
||||
const match = data.find(q => q.requirement_id === selection.value.requirement_id)
|
||||
if (match) {
|
||||
currentQuestions.value = match.questions
|
||||
} else {
|
||||
currentQuestions.value = []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
addToast('Failed to load initial data.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!selection.value.resume_id || !selection.value.requirement_id) {
|
||||
addToast('Please select both a resume and a requirement.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
generating.value = true
|
||||
error.value = ''
|
||||
currentQuestions.value = []
|
||||
|
||||
try {
|
||||
const { data } = await api.post('/api/questions/generate', selection.value)
|
||||
currentQuestions.value = data.questions
|
||||
addToast('Questions generated successfully!', 'success')
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.detail || 'Failed to generate questions'
|
||||
addToast(error.value, 'error')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.questions-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
gap: var(--space-8);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.questions-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.config-card { padding: var(--space-6); margin-bottom: var(--space-4); }
|
||||
.config-title { font-size: var(--text-md); font-weight: 700; margin-bottom: var(--space-4); }
|
||||
|
||||
.config-form { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-group label { font-size: var(--text-xs); font-weight: 600; color: var(--clr-text-secondary); }
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--clr-surface-2);
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
margin-top: var(--space-2);
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-card { padding: var(--space-5); }
|
||||
.summary-title { font-size: var(--text-xs); font-weight: 700; color: var(--clr-text-muted); text-transform: uppercase; margin-bottom: var(--space-3); }
|
||||
|
||||
.summary-item { display: flex; flex-direction: column; gap: 2px; margin-bottom: var(--space-3); }
|
||||
.summary-item .label { font-size: 10px; color: var(--clr-text-muted); }
|
||||
.summary-item .value { font-size: var(--text-sm); font-weight: 600; }
|
||||
|
||||
.skill-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
|
||||
.mini-tag { font-size: 10px; padding: 2px 6px; background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.2); border-radius: 4px; color: var(--clr-accent); }
|
||||
|
||||
/* Results */
|
||||
.questions-list { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
|
||||
.question-card { padding: var(--space-6); }
|
||||
|
||||
.q-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-4); }
|
||||
.q-num { font-weight: 800; color: var(--clr-accent); font-size: var(--text-lg); opacity: 0.5; }
|
||||
.q-badges { display: flex; gap: var(--space-2); }
|
||||
|
||||
.badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: var(--radius-full); text-transform: uppercase; }
|
||||
.difficulty-easy { background: rgba(34, 197, 94, 0.1); color: #22c55e; border: 1px solid rgba(34, 197, 94, 0.2); }
|
||||
.difficulty-medium { background: rgba(245, 158, 11, 0.1); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.2); }
|
||||
.difficulty-hard { background: rgba(239, 68, 68, 0.1); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.2); }
|
||||
.category-badge { background: rgba(59, 130, 246, 0.1); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.2); }
|
||||
|
||||
.q-text { font-size: var(--text-lg); font-weight: 600; line-height: 1.5; margin-bottom: var(--space-6); }
|
||||
|
||||
.q-expanders { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
|
||||
.q-details {
|
||||
border-top: 1px solid var(--clr-border);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
.q-details summary {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--clr-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.q-details summary::before { content: '▶'; font-size: 8px; transition: transform 0.2s; }
|
||||
.q-details[open] summary::before { transform: rotate(90deg); }
|
||||
|
||||
.details-content {
|
||||
padding: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.details-content.answer { background: rgba(255,255,255,0.03); color: #d1d5db; }
|
||||
.details-content.hint { background: rgba(245, 158, 11, 0.05); color: #fcd34d; font-style: italic; }
|
||||
|
||||
.empty-state {
|
||||
height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--space-8);
|
||||
}
|
||||
.empty-icon { font-size: 3rem; margin-bottom: var(--space-4); opacity: 0.5; }
|
||||
|
||||
/* Skeleton */
|
||||
.skeleton-card { height: 200px; padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.skeleton-line { background: rgba(255,255,255,0.05); border-radius: 4px; animation: pulse 1.5s infinite ease-in-out; }
|
||||
.skeleton-line.title { height: 24px; width: 80%; }
|
||||
.skeleton-line.text { height: 14px; width: 100%; }
|
||||
.skeleton-line.text.short { width: 60%; }
|
||||
|
||||
@keyframes pulse { 0% { opacity: 0.5; } 50% { opacity: 1; } 100% { opacity: 0.5; } }
|
||||
</style>
|
||||
491
frontend/src/views/RequirementsView.vue
Normal file
491
frontend/src/views/RequirementsView.vue
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
<template>
|
||||
<div class="requirements-page section">
|
||||
<div class="container">
|
||||
<div class="page-header animate-fade-up">
|
||||
<div class="section-label">📋 Step 2 of 5</div>
|
||||
<h1 class="section-heading">Interview Requirements</h1>
|
||||
<p class="section-sub">Define the role and expected skills. Our AI will use this to generate targeted interview questions.</p>
|
||||
</div>
|
||||
|
||||
<div class="requirements-layout">
|
||||
<!-- Form Column -->
|
||||
<div class="form-col animate-fade-up" style="animation-delay: 100ms;">
|
||||
<div class="glass-card form-card">
|
||||
<h3 class="form-title">Role Configuration</h3>
|
||||
|
||||
<form @submit.prevent="saveRequirements" class="req-form">
|
||||
<!-- Role Name -->
|
||||
<div class="form-group">
|
||||
<label for="roleName">Role / Job Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="roleName"
|
||||
v-model="form.role_name"
|
||||
class="form-control"
|
||||
placeholder="e.g. Senior Frontend Developer"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Experience -->
|
||||
<div class="form-group">
|
||||
<label for="expYears">Required Experience (Years) *</label>
|
||||
<input
|
||||
type="number"
|
||||
id="expYears"
|
||||
v-model.number="form.experience_years"
|
||||
class="form-control"
|
||||
min="0"
|
||||
step="0.5"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mandatory Skills -->
|
||||
<div class="form-group">
|
||||
<label>Mandatory Skills</label>
|
||||
<div class="skill-input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
v-model="newMandatorySkill"
|
||||
@keydown.enter.prevent="addMandatorySkill"
|
||||
class="form-control"
|
||||
placeholder="Type a skill and press Enter..."
|
||||
/>
|
||||
<button type="button" class="btn btn-primary add-btn" @click="addMandatorySkill">+</button>
|
||||
</div>
|
||||
<div class="skills-list" v-if="form.mandatory_skills.length">
|
||||
<span class="skill-chip mandatory" v-for="(skill, i) in form.mandatory_skills" :key="'m'+i">
|
||||
{{ skill }}
|
||||
<button type="button" class="chip-remove" @click="removeMandatorySkill(i)">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nice to have Skills -->
|
||||
<div class="form-group">
|
||||
<label>Nice-to-Have Skills</label>
|
||||
<div class="skill-input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
v-model="newNiceSkill"
|
||||
@keydown.enter.prevent="addNiceSkill"
|
||||
class="form-control"
|
||||
placeholder="Type a skill and press Enter..."
|
||||
/>
|
||||
<button type="button" class="btn btn-outline add-btn" @click="addNiceSkill">+</button>
|
||||
</div>
|
||||
<div class="skills-list" v-if="form.nice_to_have_skills.length">
|
||||
<span class="skill-chip nice" v-for="(skill, i) in form.nice_to_have_skills" :key="'n'+i">
|
||||
{{ skill }}
|
||||
<button type="button" class="chip-remove" @click="removeNiceSkill(i)">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Description -->
|
||||
<div class="form-group">
|
||||
<label for="jobDesc">Brief Job Description (Optional)</label>
|
||||
<textarea
|
||||
id="jobDesc"
|
||||
v-model="form.job_description"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
placeholder="Paste key responsibilities or a brief summary..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Requirements' }}
|
||||
</button>
|
||||
<span class="save-msg success" v-if="saveSuccess">✓ Saved successfully</span>
|
||||
<span class="save-msg error" v-if="saveError">{{ saveError }}</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History/List Column -->
|
||||
<div class="list-col animate-fade-up" style="animation-delay: 200ms;">
|
||||
<h3 class="list-title">Saved Configurations</h3>
|
||||
|
||||
<div class="loading-state" v-if="loadingList">
|
||||
<div class="spinner"></div> Loading...
|
||||
</div>
|
||||
|
||||
<div class="empty-state glass-card" v-else-if="!reqList.length">
|
||||
<div class="empty-icon">📝</div>
|
||||
<h4>No configurations yet</h4>
|
||||
<p class="text-muted">Save your first job requirement configuration on the left.</p>
|
||||
</div>
|
||||
|
||||
<div class="req-list" v-else>
|
||||
<div class="req-card glass-card" v-for="req in reqList" :key="req.id">
|
||||
<div class="req-header">
|
||||
<h4>{{ req.role_name }}</h4>
|
||||
<div class="req-actions">
|
||||
<button class="btn-icon" @click="editReq(req)" title="Edit">✏️</button>
|
||||
<button class="btn-icon delete" @click="deleteReq(req.id)" title="Delete">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="req-meta">{{ req.experience_years }} years exp. · {{ req.mandatory_skills.length }} core skills</p>
|
||||
|
||||
<div class="req-skills">
|
||||
<span class="tiny-chip" v-for="skill in req.mandatory_skills.slice(0,3)" :key="skill">{{ skill }}</span>
|
||||
<span class="tiny-chip more" v-if="req.mandatory_skills.length > 3">+{{ req.mandatory_skills.length - 3 }}</span>
|
||||
</div>
|
||||
|
||||
<RouterLink :to="`/questions?req=${req.id}`" class="btn btn-outline btn-sm req-action-btn">
|
||||
Generate Questions
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../api/index.js'
|
||||
|
||||
const form = ref({
|
||||
role_name: '',
|
||||
experience_years: 0,
|
||||
mandatory_skills: [],
|
||||
nice_to_have_skills: [],
|
||||
job_description: ''
|
||||
})
|
||||
const editingId = ref(null)
|
||||
|
||||
const newMandatorySkill = ref('')
|
||||
const newNiceSkill = ref('')
|
||||
|
||||
const saving = ref(false)
|
||||
const saveSuccess = ref(false)
|
||||
const saveError = ref('')
|
||||
|
||||
const loadingList = ref(false)
|
||||
const reqList = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
loadRequirements()
|
||||
})
|
||||
|
||||
function addMandatorySkill() {
|
||||
const skill = newMandatorySkill.value.trim()
|
||||
if (skill && !form.value.mandatory_skills.includes(skill)) {
|
||||
form.value.mandatory_skills.push(skill)
|
||||
}
|
||||
newMandatorySkill.value = ''
|
||||
}
|
||||
|
||||
function removeMandatorySkill(index) {
|
||||
form.value.mandatory_skills.splice(index, 1)
|
||||
}
|
||||
|
||||
function addNiceSkill() {
|
||||
const skill = newNiceSkill.value.trim()
|
||||
if (skill && !form.value.nice_to_have_skills.includes(skill)) {
|
||||
form.value.nice_to_have_skills.push(skill)
|
||||
}
|
||||
newNiceSkill.value = ''
|
||||
}
|
||||
|
||||
function removeNiceSkill(index) {
|
||||
form.value.nice_to_have_skills.splice(index, 1)
|
||||
}
|
||||
|
||||
async function loadRequirements() {
|
||||
loadingList.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api/requirements/')
|
||||
reqList.value = data
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loadingList.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRequirements() {
|
||||
saving.value = true
|
||||
saveSuccess.value = false
|
||||
saveError.value = ''
|
||||
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await api.put(`/api/requirements/${editingId.value}`, form.value)
|
||||
} else {
|
||||
await api.post('/api/requirements/', form.value)
|
||||
}
|
||||
saveSuccess.value = true
|
||||
setTimeout(() => saveSuccess.value = false, 3000)
|
||||
|
||||
// reset form if creating new, else keep it
|
||||
if (!editingId.value) {
|
||||
form.value = {
|
||||
role_name: '',
|
||||
experience_years: 0,
|
||||
mandatory_skills: [],
|
||||
nice_to_have_skills: [],
|
||||
job_description: ''
|
||||
}
|
||||
}
|
||||
await loadRequirements()
|
||||
} catch (e) {
|
||||
saveError.value = e.response?.data?.detail || 'Failed to save'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function editReq(req) {
|
||||
editingId.value = req.id
|
||||
form.value = {
|
||||
role_name: req.role_name,
|
||||
experience_years: req.experience_years,
|
||||
mandatory_skills: [...req.mandatory_skills],
|
||||
nice_to_have_skills: [...req.nice_to_have_skills],
|
||||
job_description: req.job_description || ''
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
async function deleteReq(id) {
|
||||
if (!confirm('Are you sure you want to delete this configuration?')) return
|
||||
try {
|
||||
await api.delete(`/api/requirements/${id}`)
|
||||
if (editingId.value === id) {
|
||||
editingId.value = null
|
||||
form.value = { role_name: '', experience_years: 0, mandatory_skills: [], nice_to_have_skills: [], job_description: '' }
|
||||
}
|
||||
await loadRequirements()
|
||||
} catch (e) {
|
||||
console.error('Delete failed', e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.requirements-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
gap: var(--space-8);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.requirements-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.form-card {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-5);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
}
|
||||
|
||||
.req-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--clr-text-secondary);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--clr-surface-2);
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--clr-text-primary);
|
||||
font-size: var(--text-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--clr-accent);
|
||||
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.skill-input-wrap {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
padding: 0 var(--space-4);
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.skills-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.skill-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
.skill-chip.mandatory {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--clr-accent);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
.skill-chip.nice {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
color: #a78bfa;
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.chip-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.save-msg {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.save-msg.success { color: var(--clr-accent); }
|
||||
.save-msg.error { color: #f87171; }
|
||||
|
||||
/* List Column */
|
||||
.list-title {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
color: var(--clr-text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: var(--space-8) var(--space-4);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.req-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.req-card {
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.req-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.req-header h4 {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.req-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.btn-icon:hover { background: var(--clr-surface-2); }
|
||||
.btn-icon.delete:hover { background: rgba(239, 68, 68, 0.1); }
|
||||
|
||||
.req-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
|
||||
.req-skills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.tiny-chip {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: var(--clr-surface-2);
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: 4px;
|
||||
color: var(--clr-text-secondary);
|
||||
}
|
||||
.tiny-chip.more {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.req-action-btn {
|
||||
margin-top: var(--space-3);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
638
frontend/src/views/ResumeUploadView.vue
Normal file
638
frontend/src/views/ResumeUploadView.vue
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
<template>
|
||||
<div class="resume-page section">
|
||||
<div class="container">
|
||||
<!-- Page header -->
|
||||
<div class="page-header animate-fade-up">
|
||||
<div class="section-label">📄 Step 1 of 5</div>
|
||||
<h1 class="section-heading">Resume Upload</h1>
|
||||
<p class="section-sub">Upload a PDF or DOCX resume. Our AI parser instantly extracts all key candidate information.</p>
|
||||
</div>
|
||||
|
||||
<div class="resume-layout">
|
||||
<!-- Upload zone -->
|
||||
<div class="upload-col animate-fade-up" style="animation-delay:100ms">
|
||||
<div
|
||||
class="upload-zone glass-card"
|
||||
id="resume-drop-zone"
|
||||
:class="{ 'drag-over': isDragging, 'has-file': selectedFile }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
@click="!selectedFile && $refs.fileInput.click()"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
style="display:none"
|
||||
@change="handleFileSelect"
|
||||
id="resume-file-input"
|
||||
/>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template v-if="!selectedFile && !uploading && !uploadedResume">
|
||||
<div class="upload-icon-wrap" :class="{ bounce: isDragging }">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="upload-title">Drop your resume here</h3>
|
||||
<p class="upload-sub">PDF or DOCX · Max 10MB</p>
|
||||
<button class="btn btn-outline" id="resume-browse-btn" @click.stop="$refs.fileInput.click()">
|
||||
Browse Files
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- File selected + uploading -->
|
||||
<template v-else-if="selectedFile && !uploadedResume">
|
||||
<div class="file-preview">
|
||||
<div class="file-icon">
|
||||
<span>{{ selectedFile.name.endsWith('.pdf') ? '📕' : '📘' }}</span>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<div class="file-name">{{ selectedFile.name }}</div>
|
||||
<div class="file-size">{{ formatSize(selectedFile.size) }}</div>
|
||||
</div>
|
||||
<button class="remove-btn" @click.stop="clearFile" id="resume-remove-btn" :disabled="uploading">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="progress-wrap" v-if="uploading">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
|
||||
</div>
|
||||
<span class="progress-label">Parsing resume… {{ Math.round(progress) }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div class="upload-error" v-if="uploadError" id="resume-upload-error">
|
||||
<span>⚠️ {{ uploadError }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
id="resume-upload-btn"
|
||||
:disabled="uploading"
|
||||
@click.stop="uploadResume"
|
||||
v-if="!uploading"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Upload & Parse
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Success state -->
|
||||
<template v-else-if="uploadedResume">
|
||||
<div class="success-state">
|
||||
<div class="success-icon">✅</div>
|
||||
<h3>Resume Uploaded!</h3>
|
||||
<p class="text-muted">{{ uploadedResume.filename }}</p>
|
||||
<button class="btn btn-outline btn-sm" @click.stop="clearAll" id="resume-upload-another">
|
||||
Upload Another
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Tips -->
|
||||
<div class="tips-card glass-card">
|
||||
<h4 class="tips-title">💡 For Best Results</h4>
|
||||
<ul class="tips-list">
|
||||
<li>Use a standard resume format with clear section headings</li>
|
||||
<li>Include a skills section with comma-separated tech keywords</li>
|
||||
<li>Ensure text is selectable (not scanned images in PDF)</li>
|
||||
<li>Include your name on the first line of the document</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parsed result panel -->
|
||||
<div class="result-col animate-fade-up" style="animation-delay:200ms">
|
||||
<!-- Skeleton while uploading -->
|
||||
<template v-if="uploading">
|
||||
<div class="result-card glass-card">
|
||||
<div class="skeleton" style="height:28px; width:60%; margin-bottom:1rem"></div>
|
||||
<div class="skeleton" style="height:16px; width:40%; margin-bottom:2rem"></div>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:1.5rem">
|
||||
<div class="skeleton" style="height:24px; width:70px; border-radius:999px" v-for="i in 5" :key="i"></div>
|
||||
</div>
|
||||
<div class="skeleton" style="height:100px; margin-bottom:1rem"></div>
|
||||
<div class="skeleton" style="height:100px"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Parsed data -->
|
||||
<template v-else-if="uploadedResume">
|
||||
<div class="result-card glass-card" id="resume-parsed-result">
|
||||
<!-- Header: name + contact -->
|
||||
<div class="result-header">
|
||||
<div class="result-avatar">
|
||||
{{ getInitials(uploadedResume.parsed?.name) }}
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<h2 class="result-name">{{ uploadedResume.parsed?.name || 'Unknown Candidate' }}</h2>
|
||||
<div class="result-contact">
|
||||
<span v-if="uploadedResume.parsed?.email">📧 {{ uploadedResume.parsed.email }}</span>
|
||||
<span v-if="uploadedResume.parsed?.phone">📞 {{ uploadedResume.parsed.phone }}</span>
|
||||
<a v-if="uploadedResume.parsed?.linkedin" :href="uploadedResume.parsed.linkedin" target="_blank" class="social-link">🔗 LinkedIn</a>
|
||||
<a v-if="uploadedResume.parsed?.github" :href="uploadedResume.parsed.github" target="_blank" class="social-link">🐙 GitHub</a>
|
||||
<a v-if="uploadedResume.parsed?.website" :href="uploadedResume.parsed.website" target="_blank" class="social-link">🌐 Website</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="match-badge">
|
||||
<span class="badge badge-green">✓ Parsed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Objective -->
|
||||
<div class="result-section" v-if="uploadedResume.parsed?.objective">
|
||||
<h4 class="result-section-title">Objective / Summary</h4>
|
||||
<p class="result-summary">{{ uploadedResume.parsed.objective }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Skills -->
|
||||
<div class="result-section" v-if="uploadedResume.parsed?.skills?.length">
|
||||
<h4 class="result-section-title">Skills ({{ uploadedResume.parsed.skills.length }})</h4>
|
||||
<div class="skills-grid">
|
||||
<span class="skill-tag" v-for="skill in uploadedResume.parsed.skills" :key="skill">{{ skill }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Projects -->
|
||||
<div class="result-section" v-if="uploadedResume.parsed?.projects?.length">
|
||||
<h4 class="result-section-title">Projects ({{ uploadedResume.parsed.projects.length }})</h4>
|
||||
<div class="projects-list">
|
||||
<div class="project-item" v-for="(proj, i) in uploadedResume.parsed.projects" :key="i">
|
||||
<div class="project-name">{{ proj.name }}</div>
|
||||
<p class="project-desc" v-if="proj.description">{{ proj.description }}</p>
|
||||
<div class="project-tech" v-if="proj.tech?.length">
|
||||
<span class="tech-tag" v-for="t in proj.tech" :key="t">{{ t }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Education -->
|
||||
<div class="result-section" v-if="uploadedResume.parsed?.education?.length">
|
||||
<h4 class="result-section-title">Education</h4>
|
||||
<div class="edu-list">
|
||||
<div class="edu-item" v-for="(edu, i) in uploadedResume.parsed.education" :key="i">
|
||||
<div class="edu-degree">{{ edu.degree }}</div>
|
||||
<div class="edu-meta">{{ edu.institution }} <span v-if="edu.year">· {{ edu.year }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Languages & Interests row -->
|
||||
<div class="result-row">
|
||||
<div class="result-section" v-if="uploadedResume.parsed?.languages?.length">
|
||||
<h4 class="result-section-title">Languages</h4>
|
||||
<div class="skills-grid">
|
||||
<span class="skill-tag lang-tag" v-for="lang in uploadedResume.parsed.languages" :key="lang">{{ lang }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-section" v-if="uploadedResume.parsed?.interests?.length">
|
||||
<h4 class="result-section-title">Interests</h4>
|
||||
<div class="skills-grid">
|
||||
<span class="skill-tag interest-tag" v-for="interest in uploadedResume.parsed.interests" :key="interest">{{ interest }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="result-actions">
|
||||
<RouterLink :to="`/questions?resume=${uploadedResume.id}`" class="btn btn-primary" id="resume-goto-questions">
|
||||
🤖 Generate Questions
|
||||
</RouterLink>
|
||||
<RouterLink :to="`/schedule?resume=${uploadedResume.id}`" class="btn btn-ghost" id="resume-goto-schedule">
|
||||
📅 Schedule Interview
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template v-else>
|
||||
<div class="result-empty glass-card">
|
||||
<div class="empty-icon">📋</div>
|
||||
<h3>Parsed Resume Appears Here</h3>
|
||||
<p class="text-muted">Upload a resume on the left to see the extracted candidate information — name, skills, experience, and more.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Previous resumes -->
|
||||
<div class="prev-resumes" v-if="prevResumes.length > 0" id="resume-previous-list">
|
||||
<h4 class="prev-title">Previously Uploaded</h4>
|
||||
<div
|
||||
class="prev-item glass-card"
|
||||
v-for="r in prevResumes"
|
||||
:key="r.id"
|
||||
@click="selectExisting(r)"
|
||||
:id="`resume-prev-${r.id}`"
|
||||
>
|
||||
<span class="prev-icon">{{ r.file_type === 'pdf' ? '📕' : '📘' }}</span>
|
||||
<div class="prev-info">
|
||||
<div class="prev-name">{{ r.filename }}</div>
|
||||
<div class="prev-meta">
|
||||
{{ r.parsed?.name || 'No name' }} ·
|
||||
{{ r.parsed?.skills?.length || 0 }} skills ·
|
||||
{{ r.parsed?.projects?.length || 0 }} projects ·
|
||||
{{ formatDate(r.uploaded_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="prev-actions">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
:id="`resume-view-${r.id}`"
|
||||
title="View Original PDF"
|
||||
@click.stop="viewPdf(r.id, r.filename)"
|
||||
>👁 View</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-delete"
|
||||
@click.stop="deleteResume(r.id)"
|
||||
:id="`resume-delete-${r.id}`"
|
||||
title="Delete"
|
||||
>🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../api/index.js'
|
||||
import { useToast } from '../composables/useToast.js'
|
||||
|
||||
const { addToast } = useToast()
|
||||
|
||||
const fileInput = ref(null)
|
||||
const selectedFile = ref(null)
|
||||
const isDragging = ref(false)
|
||||
const uploading = ref(false)
|
||||
const progress = ref(0)
|
||||
const uploadError = ref('')
|
||||
const uploadedResume = ref(null)
|
||||
const prevResumes = ref([])
|
||||
|
||||
// ── File handling ──────────────────────────────────────────────
|
||||
function handleDrop(e) {
|
||||
isDragging.value = false
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) validateAndSet(file)
|
||||
}
|
||||
|
||||
function handleFileSelect(e) {
|
||||
const file = e.target.files[0]
|
||||
if (file) validateAndSet(file)
|
||||
}
|
||||
|
||||
function validateAndSet(file) {
|
||||
uploadError.value = ''
|
||||
const allowed = ['application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/msword']
|
||||
if (!allowed.includes(file.type)) {
|
||||
uploadError.value = 'Only PDF and DOCX files are accepted.'
|
||||
return
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
uploadError.value = 'File too large. Max size is 10MB.'
|
||||
return
|
||||
}
|
||||
selectedFile.value = file
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
selectedFile.value = null
|
||||
uploadError.value = ''
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
clearFile()
|
||||
uploadedResume.value = null
|
||||
}
|
||||
|
||||
// ── Upload ─────────────────────────────────────────────────────
|
||||
async function uploadResume() {
|
||||
if (!selectedFile.value) return
|
||||
uploading.value = true
|
||||
uploadError.value = ''
|
||||
progress.value = 0
|
||||
|
||||
// Fake progress animation while API processes
|
||||
const progressTimer = setInterval(() => {
|
||||
if (progress.value < 88) progress.value += Math.random() * 12
|
||||
}, 300)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile.value)
|
||||
|
||||
const { data } = await api.post('/api/resume/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
|
||||
clearInterval(progressTimer)
|
||||
progress.value = 100
|
||||
addToast('Resume uploaded and parsed successfully!', 'success')
|
||||
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
uploadedResume.value = data
|
||||
await loadPreviousResumes()
|
||||
} catch (err) {
|
||||
clearInterval(progressTimer)
|
||||
uploadError.value = err.response?.data?.detail || 'Upload failed. Please try again.'
|
||||
addToast(uploadError.value, 'error')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Previous resumes ───────────────────────────────────────────
|
||||
async function loadPreviousResumes() {
|
||||
try {
|
||||
const { data } = await api.get('/api/resume/')
|
||||
prevResumes.value = data
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function selectExisting(r) {
|
||||
uploadedResume.value = r
|
||||
selectedFile.value = null
|
||||
}
|
||||
|
||||
async function deleteResume(id) {
|
||||
if (!confirm('Are you sure you want to delete this resume?')) return
|
||||
try {
|
||||
await api.delete(`/api/resume/${id}`)
|
||||
addToast('Resume deleted.', 'info')
|
||||
prevResumes.value = prevResumes.value.filter(r => r.id !== id)
|
||||
if (uploadedResume.value?.id === id) uploadedResume.value = null
|
||||
} catch (e) {
|
||||
addToast('Failed to delete resume.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function viewPdf(id, filename) {
|
||||
try {
|
||||
const token = localStorage.getItem('hireai_token')
|
||||
const response = await fetch(`http://localhost:8000/api/resume/${id}/download`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to fetch')
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const win = window.open(url, '_blank')
|
||||
// Revoke object URL after tab opens to free memory
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10000)
|
||||
} catch (e) {
|
||||
addToast('Could not open the PDF. Please try again.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utils ──────────────────────────────────────────────────────
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function getInitials(name) {
|
||||
if (!name) return '?'
|
||||
return name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
onMounted(loadPreviousResumes)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.resume-page { min-height: 80vh; }
|
||||
.page-header { margin-bottom: var(--space-10); }
|
||||
|
||||
.resume-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
gap: var(--space-6);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Upload zone */
|
||||
.upload-zone {
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-8);
|
||||
border: 2px dashed var(--clr-border-2);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
.upload-zone:hover, .upload-zone.drag-over {
|
||||
border-color: var(--clr-accent);
|
||||
background: var(--clr-accent-dim);
|
||||
}
|
||||
.upload-zone.has-file { cursor: default; }
|
||||
|
||||
.upload-icon-wrap {
|
||||
width: 72px; height: 72px;
|
||||
background: var(--clr-surface-2);
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--clr-accent);
|
||||
border: 1px solid var(--clr-border-2);
|
||||
}
|
||||
.upload-icon-wrap.bounce { animation: float 0.8s ease-in-out infinite; }
|
||||
.upload-title { font-size: var(--text-xl); font-weight: 700; }
|
||||
.upload-sub { font-size: var(--text-sm); color: var(--clr-text-muted); }
|
||||
|
||||
/* File preview */
|
||||
.file-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
background: var(--clr-surface-2);
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
.file-icon { font-size: 2rem; }
|
||||
.file-info { flex: 1; text-align: left; }
|
||||
.file-name { font-size: var(--text-sm); font-weight: 600; word-break: break-all; }
|
||||
.file-size { font-size: var(--text-xs); color: var(--clr-text-muted); }
|
||||
.remove-btn {
|
||||
background: none; border: none;
|
||||
color: var(--clr-text-muted); font-size: 1rem;
|
||||
cursor: pointer; padding: 4px 8px; border-radius: 4px;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
.remove-btn:hover { color: #F87171; }
|
||||
|
||||
/* Progress */
|
||||
.progress-wrap { width: 100%; display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--clr-surface-2);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--clr-accent), #22D3EE);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.3s ease;
|
||||
animation: pulse-glow 1.5s ease-in-out infinite;
|
||||
}
|
||||
.progress-label { font-size: var(--text-xs); color: var(--clr-text-secondary); text-align: left; }
|
||||
|
||||
/* Error */
|
||||
.upload-error {
|
||||
background: rgba(239,68,68,0.1);
|
||||
border: 1px solid rgba(239,68,68,0.25);
|
||||
color: #F87171;
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Success */
|
||||
.success-state {
|
||||
display: flex; flex-direction: column; align-items: center; gap: var(--space-3);
|
||||
}
|
||||
.success-icon { font-size: 2.5rem; }
|
||||
|
||||
/* Tips */
|
||||
.tips-card {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.tips-title { font-size: var(--text-sm); font-weight: 700; margin-bottom: var(--space-3); color: var(--clr-accent); }
|
||||
.tips-list {
|
||||
display: flex; flex-direction: column; gap: var(--space-2);
|
||||
font-size: var(--text-sm); color: var(--clr-text-secondary);
|
||||
list-style: disc; padding-left: var(--space-4);
|
||||
}
|
||||
|
||||
/* Result panel */
|
||||
.result-col { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.result-card { padding: var(--space-6); }
|
||||
.result-empty {
|
||||
padding: var(--space-12) var(--space-8);
|
||||
text-align: center;
|
||||
display: flex; flex-direction: column; align-items: center; gap: var(--space-3);
|
||||
}
|
||||
.empty-icon { font-size: 3rem; }
|
||||
|
||||
.result-header {
|
||||
display: flex; align-items: center; gap: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
padding-bottom: var(--space-5);
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
}
|
||||
.result-avatar {
|
||||
width: 56px; height: 56px;
|
||||
background: linear-gradient(135deg, var(--clr-accent), var(--clr-blue));
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 800; font-size: 1.1rem; color: #000; flex-shrink: 0;
|
||||
}
|
||||
.result-name { font-size: var(--text-xl); font-weight: 700; margin-bottom: 4px; }
|
||||
.result-contact {
|
||||
display: flex; flex-wrap: wrap; gap: var(--space-3);
|
||||
font-size: var(--text-sm); color: var(--clr-text-secondary);
|
||||
}
|
||||
.match-badge { margin-left: auto; }
|
||||
|
||||
.result-section { margin-bottom: var(--space-5); }
|
||||
.result-section-title {
|
||||
font-size: var(--text-xs); font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: var(--clr-text-muted); margin-bottom: var(--space-3);
|
||||
}
|
||||
.result-summary { font-size: var(--text-sm); color: var(--clr-text-secondary); line-height: 1.7; }
|
||||
|
||||
.skills-grid { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.skill-tag {
|
||||
background: var(--clr-surface-2);
|
||||
border: 1px solid var(--clr-border);
|
||||
color: var(--clr-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.skill-tag:hover { border-color: var(--clr-accent); color: var(--clr-accent); }
|
||||
|
||||
.result-actions {
|
||||
display: flex; gap: var(--space-3); flex-wrap: wrap;
|
||||
padding-top: var(--space-5);
|
||||
border-top: 1px solid var(--clr-border);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* Previous resumes */
|
||||
.prev-title { font-size: var(--text-sm); font-weight: 600; color: var(--clr-text-muted); margin-bottom: var(--space-3); }
|
||||
.prev-item {
|
||||
display: flex; align-items: center; gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
cursor: pointer; margin-bottom: var(--space-2);
|
||||
}
|
||||
.prev-icon { font-size: 1.5rem; flex-shrink: 0; }
|
||||
.prev-info { flex: 1; min-width: 0; }
|
||||
.prev-name { font-size: var(--text-sm); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.prev-meta { font-size: var(--text-xs); color: var(--clr-text-muted); }
|
||||
|
||||
.prev-actions { display: flex; gap: var(--space-2); align-items: center; flex-shrink: 0; }
|
||||
.btn-delete:hover { background: rgba(239, 68, 68, 0.1); color: #F87171; }
|
||||
|
||||
/* Projects */
|
||||
.projects-list { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.project-item {
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid var(--clr-border);
|
||||
border-left: 3px solid var(--clr-accent);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
.project-name { font-size: var(--text-sm); font-weight: 700; color: white; margin-bottom: 4px; }
|
||||
.project-desc { font-size: var(--text-xs); color: var(--clr-text-secondary); line-height: 1.5; margin-bottom: var(--space-2); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.project-tech { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.tech-tag { font-size: 10px; padding: 2px 6px; background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); border-radius: 4px; color: var(--clr-accent); font-weight: 500; }
|
||||
|
||||
/* Education */
|
||||
.edu-list { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.edu-item { background: rgba(255,255,255,0.02); border-radius: var(--radius-md); padding: var(--space-3); border: 1px solid var(--clr-border); }
|
||||
.edu-degree { font-size: var(--text-sm); font-weight: 600; color: white; }
|
||||
.edu-meta { font-size: var(--text-xs); color: var(--clr-text-muted); margin-top: 2px; }
|
||||
|
||||
/* Languages & Interests row */
|
||||
.result-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
|
||||
@media (max-width: 600px) { .result-row { grid-template-columns: 1fr; } }
|
||||
.lang-tag { color: var(--clr-purple) !important; border-color: rgba(139,92,246,0.3) !important; background: rgba(139,92,246,0.08) !important; }
|
||||
.interest-tag { color: var(--clr-amber) !important; border-color: rgba(245,158,11,0.3) !important; background: rgba(245,158,11,0.08) !important; }
|
||||
.social-link { color: var(--clr-accent); font-size: var(--text-sm); text-decoration: none; transition: opacity 0.15s; }
|
||||
.social-link:hover { opacity: 0.75; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.resume-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
366
frontend/src/views/ScheduleView.vue
Normal file
366
frontend/src/views/ScheduleView.vue
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
<template>
|
||||
<div class="schedule-page section">
|
||||
<div class="container">
|
||||
<div class="page-header animate-fade-up">
|
||||
<div class="section-label">📅 Step 3 of 5</div>
|
||||
<h1 class="section-heading">Interview Schedule</h1>
|
||||
<p class="section-sub">Select a candidate and schedule their interview. An automated email invitation will be sent.</p>
|
||||
</div>
|
||||
|
||||
<div class="schedule-layout">
|
||||
<!-- Form Column -->
|
||||
<div class="form-col animate-fade-up" style="animation-delay: 100ms;">
|
||||
<div class="glass-card form-card">
|
||||
<h3 class="form-title">Schedule New Interview</h3>
|
||||
|
||||
<form @submit.prevent="submitSchedule" class="sched-form">
|
||||
<!-- Candidate Selection -->
|
||||
<div class="form-group">
|
||||
<label for="candidateSelect">Select Candidate *</label>
|
||||
<select
|
||||
id="candidateSelect"
|
||||
v-model="form.resume_id"
|
||||
class="form-control"
|
||||
required
|
||||
@change="onCandidateChange"
|
||||
>
|
||||
<option value="" disabled>-- Select a parsed resume --</option>
|
||||
<option v-for="res in resumes" :key="res.id" :value="res.id">
|
||||
{{ res.parsed?.name || res.filename }} ({{ res.parsed?.email || 'No email' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Candidate Name & Email (Auto-filled but editable) -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="cName">Candidate Name *</label>
|
||||
<input type="text" id="cName" v-model="form.candidate_name" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cEmail">Candidate Email *</label>
|
||||
<input type="email" id="cEmail" v-model="form.candidate_email" class="form-control" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date & Time -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="iDate">Interview Date *</label>
|
||||
<input type="date" id="iDate" v-model="form.interview_date" class="form-control" required :min="today" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="iTime">Time *</label>
|
||||
<select id="iTime" v-model="form.interview_time" class="form-control" required>
|
||||
<option value="" disabled>Select a slot</option>
|
||||
<option v-for="slot in timeSlots" :key="slot" :value="slot">{{ slot }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interview Type -->
|
||||
<div class="form-group">
|
||||
<label for="iType">Interview Type *</label>
|
||||
<select id="iType" v-model="form.interview_type" class="form-control" required>
|
||||
<option value="Technical">Technical Round</option>
|
||||
<option value="HR">HR Round</option>
|
||||
<option value="Managerial">Managerial Round</option>
|
||||
<option value="System Design">System Design</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Meeting Link -->
|
||||
<div class="form-group">
|
||||
<label for="mLink">Meeting Link (Google Meet, Zoom, etc.)</label>
|
||||
<input type="url" id="mLink" v-model="form.meeting_link" class="form-control" placeholder="https://meet.google.com/..." />
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes to Candidate</label>
|
||||
<textarea id="notes" v-model="form.notes" class="form-control" rows="3" placeholder="Any special instructions..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Scheduling...' : 'Schedule & Send Email' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule List Column -->
|
||||
<div class="list-col animate-fade-up" style="animation-delay: 200ms;">
|
||||
<h3 class="list-title">Upcoming Interviews</h3>
|
||||
|
||||
<div class="loading-state" v-if="loadingList">
|
||||
<div class="spinner"></div> Loading...
|
||||
</div>
|
||||
|
||||
<div class="empty-state glass-card" v-else-if="!schedules.length">
|
||||
<div class="empty-icon">🛋️</div>
|
||||
<h4>No interviews scheduled</h4>
|
||||
<p class="text-muted">Schedule your first interview on the left.</p>
|
||||
</div>
|
||||
|
||||
<div class="sched-list" v-else>
|
||||
<div class="sched-card glass-card" v-for="sched in schedules" :key="sched.id">
|
||||
<div class="sched-date-badge">
|
||||
<div class="month">{{ formatMonth(sched.interview_date) }}</div>
|
||||
<div class="day">{{ formatDay(sched.interview_date) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="sched-details">
|
||||
<h4 class="sched-name">{{ sched.candidate_name }}</h4>
|
||||
<div class="sched-meta">
|
||||
<span class="type-tag">{{ sched.interview_type }}</span>
|
||||
<span class="time-meta">🕒 {{ sched.interview_time }}</span>
|
||||
</div>
|
||||
<div class="sched-contact" v-if="sched.meeting_link">
|
||||
🔗 <a :href="sched.meeting_link" target="_blank" rel="noopener">Meeting Link</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sched-actions">
|
||||
<button class="btn-icon delete" @click="deleteSchedule(sched.id)" title="Cancel Interview">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import api from '../api/index.js'
|
||||
import { useToast } from '../composables/useToast.js'
|
||||
|
||||
const route = useRoute()
|
||||
const { addToast } = useToast()
|
||||
|
||||
const form = ref({
|
||||
resume_id: '',
|
||||
candidate_name: '',
|
||||
candidate_email: '',
|
||||
interview_date: '',
|
||||
interview_time: '',
|
||||
interview_type: 'Technical',
|
||||
meeting_link: '',
|
||||
notes: ''
|
||||
})
|
||||
|
||||
const timeSlots = [
|
||||
'09:00 AM', '09:30 AM', '10:00 AM', '10:30 AM', '11:00 AM', '11:30 AM',
|
||||
'01:00 PM', '01:30 PM', '02:00 PM', '02:30 PM', '03:00 PM', '03:30 PM',
|
||||
'04:00 PM', '04:30 PM', '05:00 PM'
|
||||
]
|
||||
|
||||
const today = computed(() => {
|
||||
const d = new Date()
|
||||
return d.toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
const resumes = ref([])
|
||||
const schedules = ref([])
|
||||
|
||||
const loadingList = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadResumes()
|
||||
await loadSchedules()
|
||||
|
||||
// Pre-select resume if passed in query
|
||||
if (route.query.resume && resumes.value.some(r => r.id === route.query.resume)) {
|
||||
form.value.resume_id = route.query.resume
|
||||
onCandidateChange()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadResumes() {
|
||||
try {
|
||||
const { data } = await api.get('/api/resume/')
|
||||
resumes.value = data
|
||||
} catch (e) {
|
||||
console.error('Failed to load resumes', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchedules() {
|
||||
loadingList.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api/schedule/')
|
||||
// Sort by date soonest first
|
||||
schedules.value = data.sort((a, b) => new Date(a.interview_date) - new Date(b.interview_date))
|
||||
} catch (e) {
|
||||
console.error('Failed to load schedules', e)
|
||||
} finally {
|
||||
loadingList.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCandidateChange() {
|
||||
const selected = resumes.value.find(r => r.id === form.value.resume_id)
|
||||
if (selected && selected.parsed) {
|
||||
form.value.candidate_name = selected.parsed.name || ''
|
||||
form.value.candidate_email = selected.parsed.email || ''
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSchedule() {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
await api.post('/api/schedule/', form.value)
|
||||
addToast('Interview scheduled and email sent!', 'success')
|
||||
|
||||
// Reset form
|
||||
form.value = {
|
||||
resume_id: '', candidate_name: '', candidate_email: '',
|
||||
interview_date: '', interview_time: '', interview_type: 'Technical',
|
||||
meeting_link: '', notes: ''
|
||||
}
|
||||
|
||||
await loadSchedules()
|
||||
} catch (e) {
|
||||
addToast(e.response?.data?.detail || 'Failed to schedule interview', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSchedule(id) {
|
||||
if (!confirm('Are you sure you want to cancel this interview?')) return
|
||||
try {
|
||||
await api.delete(`/api/schedule/${id}`)
|
||||
addToast('Interview canceled.', 'info')
|
||||
await loadSchedules()
|
||||
} catch (e) {
|
||||
console.error('Cancel failed', e)
|
||||
addToast('Failed to cancel interview.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Date Formatting ──
|
||||
function formatMonth(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short' }).toUpperCase()
|
||||
}
|
||||
function formatDay(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).getDate().toString().padStart(2, '0')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.schedule-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
gap: var(--space-8);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.schedule-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.form-card { padding: var(--space-6); }
|
||||
|
||||
.form-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-5);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
}
|
||||
|
||||
.sched-form {
|
||||
display: flex; flex-direction: column; gap: var(--space-5);
|
||||
}
|
||||
.form-row {
|
||||
display: flex; gap: var(--space-4);
|
||||
}
|
||||
.form-row .form-group { flex: 1; }
|
||||
|
||||
.form-group { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.form-group label {
|
||||
font-size: var(--text-sm); font-weight: 600; color: var(--clr-text-secondary);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--clr-surface-2);
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--clr-text-primary);
|
||||
font-size: var(--text-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none; border-color: var(--clr-accent);
|
||||
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex; align-items: center; gap: var(--space-4); margin-top: var(--space-2);
|
||||
}
|
||||
.save-msg { font-size: var(--text-sm); font-weight: 500; }
|
||||
.save-msg.success { color: var(--clr-accent); }
|
||||
.save-msg.error { color: #f87171; }
|
||||
|
||||
/* List Column */
|
||||
.list-title {
|
||||
font-size: var(--text-md); font-weight: 600; color: var(--clr-text-secondary); margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: var(--space-8) var(--space-4); text-align: center;
|
||||
display: flex; flex-direction: column; align-items: center; gap: var(--space-2);
|
||||
}
|
||||
.empty-icon { font-size: 2.5rem; margin-bottom: var(--space-2); }
|
||||
|
||||
.sched-list { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
|
||||
.sched-card {
|
||||
padding: var(--space-4);
|
||||
display: flex; gap: var(--space-4); align-items: center;
|
||||
}
|
||||
|
||||
.sched-date-badge {
|
||||
background: var(--clr-surface-2);
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
min-width: 60px;
|
||||
}
|
||||
.sched-date-badge .month { font-size: 10px; font-weight: 700; color: var(--clr-accent); text-transform: uppercase; letter-spacing: 1px; }
|
||||
.sched-date-badge .day { font-size: var(--text-xl); font-weight: 800; }
|
||||
|
||||
.sched-details { flex: 1; min-width: 0; }
|
||||
.sched-name { font-size: var(--text-md); font-weight: 700; margin-bottom: 4px; }
|
||||
.sched-meta { display: flex; gap: var(--space-3); align-items: center; font-size: var(--text-xs); margin-bottom: var(--space-2); }
|
||||
|
||||
.type-tag {
|
||||
background: rgba(59, 130, 246, 0.15); color: #60A5FA;
|
||||
padding: 2px 8px; border-radius: var(--radius-full); font-weight: 600;
|
||||
}
|
||||
.time-meta { color: var(--clr-text-secondary); font-weight: 500; }
|
||||
|
||||
.sched-contact a {
|
||||
font-size: var(--text-xs); color: var(--clr-accent); text-decoration: underline; text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.sched-actions { display: flex; align-items: flex-start; height: 100%; }
|
||||
.btn-icon {
|
||||
background: none; border: none; cursor: pointer; padding: 6px; border-radius: 4px; color: var(--clr-text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.btn-icon:hover { background: rgba(239, 68, 68, 0.1); color: #F87171; }
|
||||
</style>
|
||||
7
frontend/vite.config.js
Normal file
7
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue