first commit

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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