Compare commits
2 Commits
abb6e6df83
...
8d6f3ca11f
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d6f3ca11f | |||
| 12e4864d36 |
@@ -18,6 +18,7 @@ import { CompetitionTimelineSidebar } from '@/components/applicant/competition-t
|
|||||||
import { WithdrawButton } from '@/components/applicant/withdraw-button'
|
import { WithdrawButton } from '@/components/applicant/withdraw-button'
|
||||||
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
Star,
|
Star,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||||
@@ -45,6 +47,7 @@ const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destru
|
|||||||
export default function ApplicantDashboardPage() {
|
export default function ApplicantDashboardPage() {
|
||||||
const { data: session, status: sessionStatus } = useSession()
|
const { data: session, status: sessionStatus } = useSession()
|
||||||
const isAuthenticated = sessionStatus === 'authenticated'
|
const isAuthenticated = sessionStatus === 'authenticated'
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
@@ -121,7 +124,28 @@ export default function ApplicantDashboardPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between flex-wrap gap-4">
|
<div className="flex items-start justify-between flex-wrap gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Project logo */}
|
{/* Project logo — clickable for team leads to change */}
|
||||||
|
{project.isTeamLead ? (
|
||||||
|
<ProjectLogoUpload
|
||||||
|
projectId={project.id}
|
||||||
|
currentLogoUrl={data.logoUrl}
|
||||||
|
onUploadComplete={() => utils.applicant.getMyDashboard.invalidate()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group relative shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/30 transition-all"
|
||||||
|
>
|
||||||
|
{data.logoUrl ? (
|
||||||
|
<img src={data.logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-7 w-7 text-muted-foreground/60" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||||
|
<Pencil className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</ProjectLogoUpload>
|
||||||
|
) : (
|
||||||
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
|
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
|
||||||
{data.logoUrl ? (
|
{data.logoUrl ? (
|
||||||
<img src={data.logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
<img src={data.logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
||||||
@@ -129,6 +153,7 @@ export default function ApplicantDashboardPage() {
|
|||||||
<FileText className="h-7 w-7 text-muted-foreground/60" />
|
<FileText className="h-7 w-7 text-muted-foreground/60" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
GraduationCap,
|
GraduationCap,
|
||||||
Heart,
|
Heart,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -243,7 +244,28 @@ export default function ApplicantProjectPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Project logo */}
|
{/* Project logo — clickable for team leads */}
|
||||||
|
{isTeamLead ? (
|
||||||
|
<ProjectLogoUpload
|
||||||
|
projectId={projectId}
|
||||||
|
currentLogoUrl={logoUrl}
|
||||||
|
onUploadComplete={() => refetchLogo()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group relative shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/30 transition-all"
|
||||||
|
>
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<FolderOpen className="h-7 w-7 text-muted-foreground/60" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||||
|
<Pencil className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</ProjectLogoUpload>
|
||||||
|
) : (
|
||||||
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
|
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
<img src={logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
||||||
@@ -251,6 +273,7 @@ export default function ApplicantProjectPage() {
|
|||||||
<FolderOpen className="h-7 w-7 text-muted-foreground/60" />
|
<FolderOpen className="h-7 w-7 text-muted-foreground/60" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
{project.title}
|
{project.title}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const useExternal = featureFlags?.learningHubExternal && featureFlags.learningHubExternalUrl
|
||||||
|
|
||||||
const navigation: NavItem[] = [
|
const navigation: NavItem[] = [
|
||||||
{ name: 'Dashboard', href: '/applicant', icon: Home },
|
{ name: 'Dashboard', href: '/applicant', icon: Home },
|
||||||
{ name: 'Project', href: '/applicant/team', icon: FolderOpen },
|
{ name: 'Project', href: '/applicant/team', icon: FolderOpen },
|
||||||
@@ -27,7 +29,12 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
|
|||||||
...(flags?.hasMentor
|
...(flags?.hasMentor
|
||||||
? [{ name: 'Mentoring', href: '/applicant/mentor', icon: MessageSquare }]
|
? [{ name: 'Mentoring', href: '/applicant/mentor', icon: MessageSquare }]
|
||||||
: []),
|
: []),
|
||||||
{ name: 'Resources', href: '/applicant/resources', icon: BookOpen },
|
{
|
||||||
|
name: 'Learning Hub',
|
||||||
|
href: useExternal ? featureFlags.learningHubExternalUrl : '/applicant/resources',
|
||||||
|
icon: BookOpen,
|
||||||
|
external: !!useExternal,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { signOut, useSession } from 'next-auth/react'
|
import { signOut, useSession } from 'next-auth/react'
|
||||||
@@ -71,17 +71,40 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||||
const { data: session, status: sessionStatus, update: updateSession } = useSession()
|
const { data: session, status: sessionStatus, update: updateSession } = useSession()
|
||||||
const isAuthenticated = sessionStatus === 'authenticated'
|
const isAuthenticated = sessionStatus === 'authenticated'
|
||||||
|
const isImpersonating = !!session?.user?.impersonating
|
||||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
})
|
})
|
||||||
|
const endImpersonation = trpc.user.endImpersonation.useMutation()
|
||||||
|
const logNavClick = trpc.learningResource.logNavClick.useMutation()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
useEffect(() => setMounted(true), [])
|
useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
// Auto-refresh session on mount to pick up role changes without requiring re-login
|
const handleSignOut = async () => {
|
||||||
|
if (isImpersonating) {
|
||||||
|
try {
|
||||||
|
await endImpersonation.mutateAsync()
|
||||||
|
await updateSession({ endImpersonation: true })
|
||||||
|
window.location.href = '/admin/members'
|
||||||
|
} catch {
|
||||||
|
// Fallback: just sign out completely
|
||||||
|
signOut({ callbackUrl: '/login' })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signOut({ callbackUrl: '/login' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh session once on initial mount to pick up role changes.
|
||||||
|
// Uses a ref (not state) to avoid triggering an extra re-render.
|
||||||
|
const sessionRefreshedRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated && !sessionRefreshedRef.current) {
|
||||||
updateSession()
|
sessionRefreshedRef.current = true
|
||||||
|
// Delay so the initial render finishes cleanly before session refresh
|
||||||
|
const timer = setTimeout(() => updateSession(), 3000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
@@ -115,7 +138,14 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
)
|
)
|
||||||
if (item.external) {
|
if (item.external) {
|
||||||
return (
|
return (
|
||||||
<a key={item.name} href={item.href} target="_blank" rel="noopener noreferrer" className={className}>
|
<a
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={className}
|
||||||
|
onClick={() => logNavClick.mutate({ url: item.href })}
|
||||||
|
>
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
{item.name}
|
{item.name}
|
||||||
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
|
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
|
||||||
@@ -211,11 +241,11 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
onClick={handleSignOut}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
Sign Out
|
{isImpersonating ? 'Return to Admin' : 'Sign Out'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -257,7 +287,14 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
)
|
)
|
||||||
if (item.external) {
|
if (item.external) {
|
||||||
return (
|
return (
|
||||||
<a key={item.name} href={item.href} target="_blank" rel="noopener noreferrer" onClick={() => setIsMobileMenuOpen(false)} className={className}>
|
<a
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => { logNavClick.mutate({ url: item.href }); setIsMobileMenuOpen(false) }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
{item.name}
|
{item.name}
|
||||||
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
|
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
|
||||||
@@ -299,10 +336,10 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="w-full justify-start text-destructive hover:text-destructive"
|
className="w-full justify-start text-destructive hover:text-destructive"
|
||||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
onClick={handleSignOut}
|
||||||
>
|
>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
Sign Out
|
{isImpersonating ? 'Return to Admin' : 'Sign Out'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -271,6 +271,24 @@ export const learningResourceRouter = router({
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log when a user clicks the Learning Hub nav link (especially external).
|
||||||
|
* Creates an audit log entry so admins can see who accessed it.
|
||||||
|
*/
|
||||||
|
logNavClick: protectedProcedure
|
||||||
|
.input(z.object({ url: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LEARNING_HUB_CLICK',
|
||||||
|
entityType: 'LearningResource',
|
||||||
|
detailsJson: { url: input.url, role: ctx.user.role },
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
return { success: true }
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new resource (admin only)
|
* Create a new resource (admin only)
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user