Skip to content

Commit 4bd4a70

Browse files
committed
feat: implement AppTopBar component and enhance layout with responsive design adjustments
1 parent 8d48fd7 commit 4bd4a70

5 files changed

Lines changed: 392 additions & 9 deletions

File tree

app/components/AppTopBar.vue

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
<script setup lang="ts">
2+
import {
3+
Briefcase, Plus, Bell,
4+
Kanban, FileText, LogOut, Table2,
5+
Sun, Moon, MessageSquarePlus, Settings,
6+
ChevronDown, Menu, X, Users, ChevronLeft,
7+
LayoutDashboard,
8+
} from 'lucide-vue-next'
9+
10+
const route = useRoute()
11+
const localePath = useLocalePath()
12+
const getRouteBaseName = useRouteBaseName()
13+
const { data: session } = await authClient.useSession(useFetch)
14+
const isSigningOut = ref(false)
15+
const { isDark, toggle: toggleColorMode } = useColorMode()
16+
17+
const showFeedbackModal = ref(false)
18+
const showUserMenu = ref(false)
19+
const showMobileMenu = ref(false)
20+
21+
const userName = computed(() => session.value?.user?.name ?? 'User')
22+
const userEmail = computed(() => session.value?.user?.email ?? '')
23+
const userInitials = computed(() => {
24+
const name = userName.value
25+
const parts = name.split(' ').filter(Boolean)
26+
if (parts.length >= 2) {
27+
const first = parts[0] ?? ''
28+
const second = parts[1] ?? ''
29+
return ((first[0] ?? '') + (second[0] ?? '')).toUpperCase()
30+
}
31+
return name.slice(0, 2).toUpperCase()
32+
})
33+
34+
async function handleSignOut() {
35+
isSigningOut.value = true
36+
await authClient.signOut()
37+
clearNuxtData()
38+
await navigateTo(localePath('/auth/sign-in'))
39+
}
40+
41+
// ─────────────────────────────────────────────
42+
// Dynamic job context
43+
// ─────────────────────────────────────────────
44+
45+
const activeJobId = computed(() => {
46+
const baseName = getRouteBaseName(route)
47+
if (typeof baseName !== 'string' || !baseName.startsWith('dashboard-jobs-id')) return null
48+
const idParam = route.params.id
49+
if (typeof idParam !== 'string' || idParam === 'new') return null
50+
return idParam
51+
})
52+
53+
const {
54+
data: sidebarJobsData,
55+
} = useFetch('/api/jobs', {
56+
key: 'sidebar-jobs-list',
57+
query: { limit: 100 },
58+
headers: useRequestHeaders(['cookie']),
59+
})
60+
61+
const sidebarJobs = computed(() => sidebarJobsData.value?.data ?? [])
62+
63+
const activeJobTitle = computed(() => {
64+
if (!activeJobId.value) return null
65+
const found = sidebarJobs.value.find((j: any) => j.id === activeJobId.value)
66+
return found?.title ?? 'Job'
67+
})
68+
69+
const { data: feedbackConfig } = useFetch('/api/feedback/config', {
70+
key: 'feedback-config',
71+
headers: useRequestHeaders(['cookie']),
72+
})
73+
74+
const isFeedbackEnabled = computed(() => feedbackConfig.value?.enabled === true)
75+
76+
const jobTabs = computed(() => {
77+
if (!activeJobId.value) return []
78+
const base = `/dashboard/jobs/${activeJobId.value}`
79+
return [
80+
{ label: 'Pipeline', to: base, icon: Kanban, exact: true },
81+
{ label: 'Table', to: `${base}/candidates`, icon: Table2, exact: true },
82+
{ label: 'Application Form', to: `${base}/application-form`, icon: FileText, exact: true },
83+
]
84+
})
85+
86+
// ─────────────────────────────────────────────
87+
// Main navigation
88+
// ─────────────────────────────────────────────
89+
90+
const mainNav = [
91+
{ label: 'Jobs', to: '/dashboard', icon: Briefcase, exact: true },
92+
{ label: 'Candidates', to: '/dashboard/candidates', icon: Users, exact: false },
93+
{ label: 'Applications', to: '/dashboard/applications', icon: FileText, exact: false },
94+
{ label: 'Settings', to: '/dashboard/settings', icon: Settings, exact: false },
95+
]
96+
97+
function isActiveRoute(to: string, exact: boolean) {
98+
const localizedTo = localePath(to)
99+
if (exact) return route.path === localizedTo
100+
return route.path === localizedTo || route.path.startsWith(`${localizedTo}/`)
101+
}
102+
103+
// Close menus on route change
104+
watch(() => route.path, () => {
105+
showUserMenu.value = false
106+
showMobileMenu.value = false
107+
})
108+
109+
// Close user menu on outside click
110+
const userMenuRef = useTemplateRef<HTMLElement>('userMenuRoot')
111+
function onClickOutsideUser(e: MouseEvent) {
112+
if (userMenuRef.value && !userMenuRef.value.contains(e.target as Node)) {
113+
showUserMenu.value = false
114+
}
115+
}
116+
onMounted(() => document.addEventListener('click', onClickOutsideUser))
117+
onUnmounted(() => document.removeEventListener('click', onClickOutsideUser))
118+
</script>
119+
120+
<template>
121+
<header class="sticky top-0 z-50 w-full">
122+
<!-- Primary navigation bar -->
123+
<div class="relative z-20 border-b border-surface-200/80 dark:border-surface-800/80 bg-white/80 dark:bg-surface-900/80 backdrop-blur-xl">
124+
<div class="flex h-14 items-center justify-between px-4 lg:px-6">
125+
<!-- Left: Logo + Nav -->
126+
<div class="flex items-center gap-1 lg:gap-2">
127+
<!-- Logo -->
128+
<NuxtLink
129+
:to="$localePath('/')"
130+
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg no-underline hover:bg-surface-100/60 dark:hover:bg-surface-800/60 transition-colors mr-1 lg:mr-4"
131+
>
132+
<img src="/eagle-mascot-logo.png" alt="Reqcore mascot" class="size-7 shrink-0 object-contain" />
133+
<span class="text-[15px] font-bold text-surface-900 dark:text-surface-100 hidden sm:block tracking-tight">Reqcore</span>
134+
</NuxtLink>
135+
136+
<!-- Desktop nav links -->
137+
<nav class="hidden md:flex items-center gap-0.5">
138+
<NuxtLink
139+
v-for="item in mainNav"
140+
:key="item.to"
141+
:to="$localePath(item.to)"
142+
class="relative flex items-center gap-2 px-3 py-1.5 rounded-lg text-[13px] font-medium transition-all duration-200 no-underline"
143+
:class="isActiveRoute(item.to, item.exact)
144+
? 'text-brand-700 dark:text-brand-300 bg-brand-50/80 dark:bg-brand-950/40'
145+
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:bg-surface-100/80 dark:hover:bg-surface-800/60'"
146+
>
147+
<component :is="item.icon" class="size-4" />
148+
{{ item.label }}
149+
</NuxtLink>
150+
</nav>
151+
</div>
152+
153+
<!-- Right: Actions -->
154+
<div class="flex items-center gap-1 lg:gap-1.5">
155+
<!-- New Job button (desktop) -->
156+
<NuxtLink
157+
:to="$localePath('/dashboard/jobs/new')"
158+
class="hidden sm:inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3.5 py-1.5 text-[13px] font-semibold text-white shadow-sm shadow-brand-600/20 hover:bg-brand-700 hover:shadow-md hover:shadow-brand-600/25 active:bg-brand-800 transition-all duration-200 no-underline"
159+
>
160+
<Plus class="size-3.5" />
161+
New Job
162+
</NuxtLink>
163+
164+
<!-- Org Switcher -->
165+
<div class="hidden lg:block ml-1">
166+
<OrgSwitcher />
167+
</div>
168+
169+
<!-- Language Switcher -->
170+
<div class="hidden lg:block">
171+
<LanguageSwitcher />
172+
</div>
173+
174+
<!-- Color mode toggle -->
175+
<button
176+
class="flex items-center justify-center size-8 rounded-lg text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 transition-all duration-200 cursor-pointer border-0 bg-transparent"
177+
:title="isDark ? 'Switch to light' : 'Switch to dark'"
178+
@click="toggleColorMode"
179+
>
180+
<Sun v-if="isDark" class="size-4" />
181+
<Moon v-else class="size-4" />
182+
</button>
183+
184+
<!-- Feedback button -->
185+
<button
186+
v-if="isFeedbackEnabled"
187+
class="flex items-center justify-center size-8 rounded-lg text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 transition-all duration-200 cursor-pointer border-0 bg-transparent"
188+
title="Report issue"
189+
@click="showFeedbackModal = true"
190+
>
191+
<MessageSquarePlus class="size-4" />
192+
</button>
193+
194+
<!-- Divider -->
195+
<div class="hidden sm:block w-px h-6 bg-surface-200 dark:bg-surface-700 mx-1" />
196+
197+
<!-- User menu -->
198+
<div ref="userMenuRoot" class="relative">
199+
<button
200+
class="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-surface-100/80 dark:hover:bg-surface-800/60 transition-all duration-200 cursor-pointer border-0 bg-transparent"
201+
@click="showUserMenu = !showUserMenu"
202+
>
203+
<div class="flex items-center justify-center size-7 rounded-full bg-gradient-to-br from-brand-500 to-brand-700 text-white text-[11px] font-bold shadow-sm">
204+
{{ userInitials }}
205+
</div>
206+
<ChevronDown
207+
class="size-3 text-surface-400 transition-transform duration-200"
208+
:class="showUserMenu ? 'rotate-180' : ''"
209+
/>
210+
</button>
211+
212+
<!-- User dropdown -->
213+
<Transition
214+
enter-active-class="transition duration-150 ease-out"
215+
enter-from-class="opacity-0 scale-95 -translate-y-1"
216+
enter-to-class="opacity-100 scale-100 translate-y-0"
217+
leave-active-class="transition duration-100 ease-in"
218+
leave-from-class="opacity-100 scale-100 translate-y-0"
219+
leave-to-class="opacity-0 scale-95 -translate-y-1"
220+
>
221+
<div
222+
v-if="showUserMenu"
223+
class="absolute right-0 top-[calc(100%+6px)] w-64 rounded-xl border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-900 shadow-xl shadow-surface-900/8 dark:shadow-surface-950/30 overflow-hidden"
224+
>
225+
<!-- User info header -->
226+
<div class="px-4 py-3 border-b border-surface-100 dark:border-surface-800">
227+
<div class="text-sm font-semibold text-surface-900 dark:text-surface-100">{{ userName }}</div>
228+
<div class="text-xs text-surface-500 dark:text-surface-400 truncate mt-0.5">{{ userEmail }}</div>
229+
</div>
230+
231+
<!-- Mobile-only items -->
232+
<div class="md:hidden border-b border-surface-100 dark:border-surface-800 py-1">
233+
<NuxtLink
234+
v-for="item in mainNav"
235+
:key="item.to"
236+
:to="$localePath(item.to)"
237+
class="flex items-center gap-2.5 px-4 py-2 text-sm text-surface-600 dark:text-surface-400 hover:bg-surface-50 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-100 transition-colors no-underline"
238+
:class="isActiveRoute(item.to, item.exact) ? 'text-brand-600 dark:text-brand-400 font-medium' : ''"
239+
>
240+
<component :is="item.icon" class="size-4" />
241+
{{ item.label }}
242+
</NuxtLink>
243+
</div>
244+
245+
<!-- Org switcher (mobile) -->
246+
<div class="lg:hidden border-b border-surface-100 dark:border-surface-800 p-2">
247+
<OrgSwitcher />
248+
</div>
249+
250+
<!-- Actions -->
251+
<div class="py-1">
252+
<button
253+
class="flex items-center gap-2.5 w-full px-4 py-2 text-sm text-surface-600 dark:text-surface-400 hover:bg-surface-50 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-100 transition-colors cursor-pointer border-0 bg-transparent text-left"
254+
:disabled="isSigningOut"
255+
@click="handleSignOut"
256+
>
257+
<LogOut class="size-4" />
258+
{{ isSigningOut ? 'Signing out…' : 'Sign out' }}
259+
</button>
260+
</div>
261+
</div>
262+
</Transition>
263+
</div>
264+
265+
<!-- Mobile hamburger -->
266+
<button
267+
class="flex md:hidden items-center justify-center size-8 rounded-lg text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 transition-all duration-200 cursor-pointer border-0 bg-transparent"
268+
@click="showMobileMenu = !showMobileMenu"
269+
>
270+
<X v-if="showMobileMenu" class="size-4" />
271+
<Menu v-else class="size-4" />
272+
</button>
273+
</div>
274+
</div>
275+
</div>
276+
277+
<!-- Job context sub-navigation bar -->
278+
<Transition
279+
enter-active-class="transition duration-200 ease-out"
280+
enter-from-class="opacity-0 -translate-y-1"
281+
enter-to-class="opacity-100 translate-y-0"
282+
leave-active-class="transition duration-150 ease-in"
283+
leave-from-class="opacity-100 translate-y-0"
284+
leave-to-class="opacity-0 -translate-y-1"
285+
>
286+
<div
287+
v-if="activeJobId"
288+
class="relative z-10 border-b border-surface-200/60 dark:border-surface-800/60 bg-surface-50/90 dark:bg-surface-950/90 backdrop-blur-lg"
289+
>
290+
<div class="flex items-center gap-4 px-4 lg:px-6 h-10">
291+
<NuxtLink
292+
:to="$localePath('/dashboard')"
293+
class="flex items-center gap-1 text-xs font-medium text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors no-underline shrink-0"
294+
>
295+
<ChevronLeft class="size-3.5" />
296+
All Jobs
297+
</NuxtLink>
298+
299+
<div class="w-px h-4 bg-surface-200 dark:bg-surface-700" />
300+
301+
<div class="flex items-center gap-2 shrink-0 min-w-0">
302+
<Briefcase class="size-3.5 text-brand-500 shrink-0" />
303+
<span class="text-sm font-semibold text-surface-900 dark:text-surface-100 truncate max-w-48">
304+
{{ activeJobTitle }}
305+
</span>
306+
</div>
307+
308+
<nav class="flex items-center gap-0.5 ml-2">
309+
<NuxtLink
310+
v-for="tab in jobTabs"
311+
:key="tab.to"
312+
:to="$localePath(tab.to)"
313+
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200 no-underline"
314+
:class="isActiveRoute(tab.to, tab.exact)
315+
? 'bg-white dark:bg-surface-800 text-surface-900 dark:text-surface-100 shadow-sm'
316+
: 'text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 hover:bg-white/60 dark:hover:bg-surface-800/60'"
317+
>
318+
<component :is="tab.icon" class="size-3.5" />
319+
{{ tab.label }}
320+
</NuxtLink>
321+
</nav>
322+
</div>
323+
</div>
324+
</Transition>
325+
326+
<!-- Mobile navigation menu -->
327+
<Transition
328+
enter-active-class="transition duration-200 ease-out"
329+
enter-from-class="opacity-0 -translate-y-2"
330+
enter-to-class="opacity-100 translate-y-0"
331+
leave-active-class="transition duration-150 ease-in"
332+
leave-from-class="opacity-100 translate-y-0"
333+
leave-to-class="opacity-0 -translate-y-2"
334+
>
335+
<div
336+
v-if="showMobileMenu"
337+
class="relative z-10 md:hidden border-b border-surface-200 dark:border-surface-800 bg-white/95 dark:bg-surface-900/95 backdrop-blur-xl"
338+
>
339+
<nav class="px-4 py-3 flex flex-col gap-1">
340+
<NuxtLink
341+
v-for="item in mainNav"
342+
:key="item.to"
343+
:to="$localePath(item.to)"
344+
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all no-underline"
345+
:class="isActiveRoute(item.to, item.exact)
346+
? 'bg-brand-50 dark:bg-brand-950/40 text-brand-700 dark:text-brand-300'
347+
: 'text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800'"
348+
>
349+
<component :is="item.icon" class="size-4" />
350+
{{ item.label }}
351+
</NuxtLink>
352+
353+
<NuxtLink
354+
:to="$localePath('/dashboard/jobs/new')"
355+
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium bg-brand-600 text-white hover:bg-brand-700 transition-colors no-underline sm:hidden mt-1"
356+
>
357+
<Plus class="size-4" />
358+
New Job
359+
</NuxtLink>
360+
</nav>
361+
362+
<div class="px-4 pb-3 flex flex-col gap-2 border-t border-surface-100 dark:border-surface-800 pt-3 lg:hidden">
363+
<OrgSwitcher />
364+
<LanguageSwitcher />
365+
</div>
366+
</div>
367+
</Transition>
368+
</header>
369+
370+
<!-- Feedback modal -->
371+
<FeedbackModal v-if="showFeedbackModal" @close="showFeedbackModal = false" />
372+
</template>

app/components/CandidateDetailSidebar.vue

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ const emit = defineEmits<{
1818
1919
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
2020
21+
// Detect if the job sub-nav bar is visible (adds 40px / 2.5rem)
22+
const route = useRoute()
23+
const getRouteBaseName = useRouteBaseName()
24+
const hasSubNav = computed(() => {
25+
const baseName = getRouteBaseName(route)
26+
if (typeof baseName !== 'string') return false
27+
const idParam = route.params.id
28+
return baseName.startsWith('dashboard-jobs-id') && typeof idParam === 'string' && idParam !== 'new'
29+
})
30+
2131
// ─────────────────────────────────────────────
2232
// Tabs
2333
// ─────────────────────────────────────────────
@@ -296,7 +306,8 @@ const responsesCount = computed(() => application.value?.responses?.length ?? 0)
296306
<Transition name="slide">
297307
<aside
298308
v-if="open"
299-
class="fixed top-0 right-0 z-40 h-full w-[640px] max-w-[calc(100vw-4rem)] border-l border-surface-200 dark:border-surface-800 bg-white dark:bg-surface-900 shadow-xl flex flex-col"
309+
class="fixed right-0 z-40 w-[640px] max-w-[calc(100vw-4rem)] border-l border-surface-200 dark:border-surface-800 bg-white dark:bg-surface-900 shadow-xl flex flex-col"
310+
:class="hasSubNav ? 'top-24 h-[calc(100vh-6rem)]' : 'top-14 h-[calc(100vh-3.5rem)]'"
300311
>
301312
<!-- Header -->
302313
<div class="flex items-center justify-between border-b border-surface-200 dark:border-surface-800 px-6 py-4 shrink-0">

0 commit comments

Comments
 (0)