2026-02-05 21:09:06 +01:00
|
|
|
'use client'
|
|
|
|
|
|
2026-02-08 14:37:32 +01:00
|
|
|
import { useState, useEffect } from 'react'
|
2026-02-05 21:09:06 +01:00
|
|
|
import Link from 'next/link'
|
|
|
|
|
import { usePathname } from 'next/navigation'
|
Performance optimization, applicant portal, and missing DB migration
Performance:
- Convert admin dashboard from SSR to client-side tRPC (fixes 503/ChunkLoadError)
- New dashboard.getStats tRPC endpoint batches 16 queries into single response
- Parallelize jury dashboard queries (assignments + gracePeriods via Promise.all)
- Add project.getFullDetail combined endpoint (project + assignments + stats)
- Configure Prisma connection pool (connection_limit=20, pool_timeout=10)
- Add optimizePackageImports for lucide-react tree-shaking
- Increase React Query staleTime from 1min to 5min
Applicant portal:
- Add applicant layout, nav, dashboard, documents, team, and mentor pages
- Add applicant router with document and team management endpoints
- Add chunk error recovery utility
- Update role nav and auth redirect for applicant role
Database:
- Add migration for missing schema elements (SpecialAward job tracking
columns, WizardTemplate table, missing indexes)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:04:26 +01:00
|
|
|
import { signOut, useSession } from 'next-auth/react'
|
2026-02-05 21:09:06 +01:00
|
|
|
import { cn } from '@/lib/utils'
|
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
|
|
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
|
|
|
|
import { trpc } from '@/lib/trpc/client'
|
|
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuSeparator,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from '@/components/ui/dropdown-menu'
|
|
|
|
|
import type { Route } from 'next'
|
|
|
|
|
import type { LucideIcon } from 'lucide-react'
|
2026-02-08 14:37:32 +01:00
|
|
|
import { LogOut, Menu, Moon, Settings, Sun, User, X } from 'lucide-react'
|
|
|
|
|
import { useTheme } from 'next-themes'
|
2026-02-05 21:09:06 +01:00
|
|
|
import { Logo } from '@/components/shared/logo'
|
|
|
|
|
import { NotificationBell } from '@/components/shared/notification-bell'
|
|
|
|
|
|
|
|
|
|
export type NavItem = {
|
|
|
|
|
name: string
|
|
|
|
|
href: string
|
|
|
|
|
icon: LucideIcon
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type RoleNavUser = {
|
|
|
|
|
name?: string | null
|
|
|
|
|
email?: string | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type RoleNavProps = {
|
|
|
|
|
navigation: NavItem[]
|
|
|
|
|
roleName: string
|
|
|
|
|
user: RoleNavUser
|
|
|
|
|
/** The base path for the role (e.g., '/jury', '/mentor', '/observer'). Used for active state detection on the dashboard link. */
|
|
|
|
|
basePath: string
|
2026-02-08 14:37:32 +01:00
|
|
|
/** Optional status badge displayed next to the logo (e.g., remaining evaluations count) */
|
|
|
|
|
statusBadge?: React.ReactNode
|
2026-02-05 21:09:06 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
|
|
|
|
|
return pathname === href || (href !== basePath && pathname.startsWith(href))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 14:37:32 +01:00
|
|
|
export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: RoleNavProps) {
|
2026-02-05 21:09:06 +01:00
|
|
|
const pathname = usePathname()
|
|
|
|
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
Performance optimization, applicant portal, and missing DB migration
Performance:
- Convert admin dashboard from SSR to client-side tRPC (fixes 503/ChunkLoadError)
- New dashboard.getStats tRPC endpoint batches 16 queries into single response
- Parallelize jury dashboard queries (assignments + gracePeriods via Promise.all)
- Add project.getFullDetail combined endpoint (project + assignments + stats)
- Configure Prisma connection pool (connection_limit=20, pool_timeout=10)
- Add optimizePackageImports for lucide-react tree-shaking
- Increase React Query staleTime from 1min to 5min
Applicant portal:
- Add applicant layout, nav, dashboard, documents, team, and mentor pages
- Add applicant router with document and team management endpoints
- Add chunk error recovery utility
- Update role nav and auth redirect for applicant role
Database:
- Add migration for missing schema elements (SpecialAward job tracking
columns, WizardTemplate table, missing indexes)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:04:26 +01:00
|
|
|
const { status: sessionStatus } = useSession()
|
|
|
|
|
const isAuthenticated = sessionStatus === 'authenticated'
|
|
|
|
|
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
|
|
|
|
enabled: isAuthenticated,
|
|
|
|
|
})
|
2026-02-08 14:37:32 +01:00
|
|
|
const { theme, setTheme } = useTheme()
|
|
|
|
|
const [mounted, setMounted] = useState(false)
|
|
|
|
|
useEffect(() => setMounted(true), [])
|
2026-02-05 21:09:06 +01:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<header className="sticky top-0 z-40 border-b bg-card">
|
|
|
|
|
<div className="container-app">
|
|
|
|
|
<div className="flex h-16 items-center justify-between">
|
|
|
|
|
{/* Logo */}
|
2026-02-08 14:37:32 +01:00
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Logo showText textSuffix={roleName} />
|
|
|
|
|
{statusBadge}
|
|
|
|
|
</div>
|
2026-02-05 21:09:06 +01:00
|
|
|
|
|
|
|
|
{/* Desktop nav */}
|
|
|
|
|
<nav className="hidden md:flex items-center gap-1">
|
|
|
|
|
{navigation.map((item) => {
|
|
|
|
|
const isActive = isNavItemActive(pathname, item.href, basePath)
|
|
|
|
|
return (
|
|
|
|
|
<Link
|
|
|
|
|
key={item.name}
|
|
|
|
|
href={item.href as Route}
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
|
|
|
|
isActive
|
|
|
|
|
? 'bg-primary/10 text-primary'
|
|
|
|
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<item.icon className="h-4 w-4" />
|
|
|
|
|
{item.name}
|
|
|
|
|
</Link>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
{/* User menu & mobile toggle */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-02-08 14:37:32 +01:00
|
|
|
{mounted && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
|
|
|
|
aria-label="Toggle theme"
|
|
|
|
|
>
|
|
|
|
|
{theme === 'dark' ? (
|
|
|
|
|
<Sun className="h-5 w-5" />
|
|
|
|
|
) : (
|
|
|
|
|
<Moon className="h-5 w-5" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-02-05 21:09:06 +01:00
|
|
|
<NotificationBell />
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
className="gap-2 hidden sm:flex"
|
|
|
|
|
size="sm"
|
|
|
|
|
>
|
|
|
|
|
<UserAvatar user={user} avatarUrl={avatarUrl} size="xs" />
|
|
|
|
|
<span className="max-w-[120px] truncate">
|
|
|
|
|
{user.name || user.email}
|
|
|
|
|
</span>
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end" className="w-48">
|
|
|
|
|
<DropdownMenuItem disabled>
|
|
|
|
|
<User className="mr-2 h-4 w-4" />
|
|
|
|
|
{user.email}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuItem asChild>
|
|
|
|
|
<Link href={"/settings/profile" as Route} className="flex cursor-pointer items-center">
|
|
|
|
|
<Settings className="mr-2 h-4 w-4" />
|
|
|
|
|
Profile Settings
|
|
|
|
|
</Link>
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
|
|
|
|
className="text-destructive focus:text-destructive"
|
|
|
|
|
>
|
|
|
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
|
|
|
Sign out
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="md:hidden"
|
|
|
|
|
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
|
|
|
|
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
|
|
|
|
>
|
|
|
|
|
{isMobileMenuOpen ? (
|
|
|
|
|
<X className="h-5 w-5" />
|
|
|
|
|
) : (
|
|
|
|
|
<Menu className="h-5 w-5" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Mobile menu */}
|
|
|
|
|
{isMobileMenuOpen && (
|
|
|
|
|
<div className="border-t md:hidden">
|
|
|
|
|
<nav className="container-app py-4 space-y-1">
|
|
|
|
|
{navigation.map((item) => {
|
|
|
|
|
const isActive = isNavItemActive(pathname, item.href, basePath)
|
|
|
|
|
return (
|
|
|
|
|
<Link
|
|
|
|
|
key={item.name}
|
|
|
|
|
href={item.href as Route}
|
|
|
|
|
onClick={() => setIsMobileMenuOpen(false)}
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
|
|
|
|
isActive
|
|
|
|
|
? 'bg-primary/10 text-primary'
|
|
|
|
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<item.icon className="h-4 w-4" />
|
|
|
|
|
{item.name}
|
|
|
|
|
</Link>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
<div className="border-t pt-4 mt-4">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
className="w-full justify-start text-destructive hover:text-destructive"
|
|
|
|
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
|
|
|
|
>
|
|
|
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
|
|
|
Sign out
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</nav>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</header>
|
|
|
|
|
)
|
|
|
|
|
}
|