Overhaul applicant portal: timeline, evaluations, nav, resources
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m6s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m6s
- Fix programId/competitionId bug in competition timeline - Add applicantVisibility config to EvaluationConfigSchema (JSONB) - Add admin UI card for controlling applicant feedback visibility - Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline, getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness, and extend getMyDashboard with hasPassedIntake - Rewrite competition timeline to show only EVALUATION + Grand Finale, synthesize FILTERING rejections, handle manually-created projects - Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources - Dashboard: conditional timeline, jury feedback card, deadlines, document completeness, conditional mentor tile - New /applicant/evaluations page with anonymous jury feedback - New /applicant/resources pages (clone of jury learning hub) - Rename /applicant/competitions → /applicant/competition - Remove broken /applicant/competitions/[windowId] page - Add permission info banner to team invite dialog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
122
src/app/(applicant)/applicant/resources/[id]/page.tsx
Normal file
122
src/app/(applicant)/applicant/resources/[id]/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
ExternalLink,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
const ResourceRenderer = dynamic(
|
||||
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
export default function ApplicantResourceDetailPage() {
|
||||
const params = useParams()
|
||||
const resourceId = params.id as string
|
||||
|
||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||
const logAccess = trpc.learningResource.logAccess.useMutation()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Log access on mount
|
||||
useEffect(() => {
|
||||
if (resourceId) {
|
||||
logAccess.mutate({ id: resourceId })
|
||||
}
|
||||
}, [resourceId])
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
|
||||
window.open(url, '_blank')
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<Skeleton className="h-10 w-2/3" />
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-px w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !resource) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Resource not found</AlertTitle>
|
||||
<AlertDescription>
|
||||
This resource may have been removed or you don't have access.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/applicant/resources">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Resources
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/applicant/resources">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Resources
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.externalUrl && (
|
||||
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Link
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{resource.objectKey && (
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ResourceRenderer
|
||||
title={resource.title}
|
||||
description={resource.description}
|
||||
contentJson={resource.contentJson}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user