Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
653
src/app/(admin)/admin/projects/[id]/page.tsx
Normal file
653
src/app/(admin)/admin/projects/[id]/page.tsx
Normal file
@@ -0,0 +1,653 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { FileViewer } from '@/components/shared/file-viewer'
|
||||
import { FileUpload } from '@/components/shared/file-upload'
|
||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
AlertCircle,
|
||||
Users,
|
||||
FileText,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
BarChart3,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
MapPin,
|
||||
Waves,
|
||||
GraduationCap,
|
||||
Heart,
|
||||
Crown,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
import { formatDate, formatDateOnly, getInitials } from '@/lib/utils'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
// Status badge colors
|
||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
SUBMITTED: 'secondary',
|
||||
ELIGIBLE: 'default',
|
||||
ASSIGNED: 'default',
|
||||
SEMIFINALIST: 'default',
|
||||
FINALIST: 'default',
|
||||
REJECTED: 'destructive',
|
||||
}
|
||||
|
||||
// Evaluation status colors
|
||||
const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
NOT_STARTED: 'outline',
|
||||
DRAFT: 'secondary',
|
||||
SUBMITTED: 'default',
|
||||
LOCKED: 'default',
|
||||
}
|
||||
|
||||
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
// Fetch project data
|
||||
const { data: project, isLoading } = trpc.project.get.useQuery({
|
||||
id: projectId,
|
||||
})
|
||||
|
||||
// Fetch files
|
||||
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
|
||||
|
||||
// Fetch assignments
|
||||
const { data: assignments } = trpc.assignment.listByProject.useQuery({
|
||||
projectId,
|
||||
})
|
||||
|
||||
// Fetch evaluation stats
|
||||
const { data: stats } = trpc.evaluation.getProjectStats.useQuery({
|
||||
projectId,
|
||||
})
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
if (isLoading) {
|
||||
return <ProjectDetailSkeleton />
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Project Not Found</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/projects">Back to Projects</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<ProjectLogoWithUrl
|
||||
project={project}
|
||||
size="lg"
|
||||
fallback="initials"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link
|
||||
href={`/admin/rounds/${project.round.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{project.round.name}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{project.title}
|
||||
</h1>
|
||||
<Badge variant={statusColors[project.status] || 'secondary'}>
|
||||
{project.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{project.teamName && (
|
||||
<p className="text-muted-foreground">{project.teamName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/projects/${projectId}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Stats Grid */}
|
||||
{stats && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Average Score
|
||||
</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.averageGlobalScore?.toFixed(1) || '-'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Range: {stats.minScore || '-'} - {stats.maxScore || '-'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Recommendations
|
||||
</CardTitle>
|
||||
<ThumbsUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.yesPercentage?.toFixed(0) || 0}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.yesVotes} yes / {stats.noVotes} no
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Project Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Category & Ocean Issue badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<GraduationCap className="h-3 w-3" />
|
||||
{project.competitionCategory === 'STARTUP' ? 'Start-up' : 'Business Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
{project.oceanIssue && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Waves className="h-3 w-3" />
|
||||
{project.oceanIssue.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
{project.wantsMentorship && (
|
||||
<Badge variant="outline" className="gap-1 text-pink-600 border-pink-200 bg-pink-50">
|
||||
<Heart className="h-3 w-3" />
|
||||
Wants Mentorship
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{project.description && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">
|
||||
Description
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location & Institution */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{(project.country || project.geographicZone) && (
|
||||
<div className="flex items-start gap-2">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Location</p>
|
||||
<p className="text-sm">{project.geographicZone || project.country}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{project.institution && (
|
||||
<div className="flex items-start gap-2">
|
||||
<GraduationCap className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Institution</p>
|
||||
<p className="text-sm">{project.institution}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submission URLs */}
|
||||
{(project.phase1SubmissionUrl || project.phase2SubmissionUrl) && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Submission Links</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.phase1SubmissionUrl && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={project.phase1SubmissionUrl} target="_blank" rel="noopener noreferrer">
|
||||
Phase 1 Submission
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{project.phase2SubmissionUrl && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={project.phase2SubmissionUrl} target="_blank" rel="noopener noreferrer">
|
||||
Phase 2 Submission
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">
|
||||
Tags
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Internal Info */}
|
||||
{(project.internalComments || project.applicationStatus || project.referralSource) && (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-3">Internal Notes</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{project.applicationStatus && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Application Status</p>
|
||||
<p className="text-sm">{project.applicationStatus}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.referralSource && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Referral Source</p>
|
||||
<p className="text-sm">{project.referralSource}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{project.internalComments && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-muted-foreground">Comments</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{project.internalComments}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-6 text-sm pt-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Created:</span>{' '}
|
||||
{formatDateOnly(project.createdAt)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Updated:</span>{' '}
|
||||
{formatDateOnly(project.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Members Section */}
|
||||
{project.teamMembers && project.teamMembers.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Team Members ({project.teamMembers.length})
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string } }) => (
|
||||
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
{member.role === 'LEAD' ? (
|
||||
<Crown className="h-5 w-5 text-yellow-500" />
|
||||
) : (
|
||||
<span className="text-sm font-medium">
|
||||
{getInitials(member.user.name || member.user.email)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm truncate">
|
||||
{member.user.name || 'Unnamed'}
|
||||
</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{member.user.email}
|
||||
</p>
|
||||
{member.title && (
|
||||
<p className="text-xs text-muted-foreground">{member.title}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Mentor Assignment Section */}
|
||||
{project.wantsMentorship && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Heart className="h-5 w-5" />
|
||||
Mentor Assignment
|
||||
</CardTitle>
|
||||
{!project.mentorAssignment && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/projects/${projectId}/mentor` as Route}>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Assign Mentor
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.mentorAssignment ? (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className="text-sm">
|
||||
{getInitials(project.mentorAssignment.mentor.name || project.mentorAssignment.mentor.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{project.mentorAssignment.mentor.name || 'Unnamed'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.mentorAssignment.mentor.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{project.mentorAssignment.method.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No mentor assigned yet. The applicant has requested mentorship support.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Files Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Files</CardTitle>
|
||||
<CardDescription>
|
||||
Project documents and materials
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{files && files.length > 0 ? (
|
||||
<FileViewer
|
||||
files={files.map((f) => ({
|
||||
id: f.id,
|
||||
fileName: f.fileName,
|
||||
fileType: f.fileType,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
bucket: f.bucket,
|
||||
objectKey: f.objectKey,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No files uploaded yet</p>
|
||||
)}
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">Upload New Files</p>
|
||||
<FileUpload
|
||||
projectId={projectId}
|
||||
onUploadComplete={() => {
|
||||
utils.file.listByProject.invalidate({ projectId })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Assignments Section */}
|
||||
{assignments && assignments.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">Jury Assignments</CardTitle>
|
||||
<CardDescription>
|
||||
{assignments.filter((a) => a.evaluation?.status === 'SUBMITTED')
|
||||
.length}{' '}
|
||||
of {assignments.length} evaluations completed
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${project.roundId}/assignments`}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Juror</TableHead>
|
||||
<TableHead>Expertise</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead>Decision</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignments.map((assignment) => (
|
||||
<TableRow key={assignment.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs">
|
||||
{getInitials(assignment.user.name || assignment.user.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{assignment.user.name || 'Unnamed'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignment.user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{assignment.user.expertiseTags?.slice(0, 2).map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{(assignment.user.expertiseTags?.length || 0) > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{(assignment.user.expertiseTags?.length || 0) - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
evalStatusColors[
|
||||
assignment.evaluation?.status || 'NOT_STARTED'
|
||||
] || 'secondary'
|
||||
}
|
||||
>
|
||||
{(assignment.evaluation?.status || 'NOT_STARTED').replace(
|
||||
'_',
|
||||
' '
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{assignment.evaluation?.globalScore !== null &&
|
||||
assignment.evaluation?.globalScore !== undefined ? (
|
||||
<span className="font-medium">
|
||||
{assignment.evaluation.globalScore}/10
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{assignment.evaluation?.binaryDecision !== null &&
|
||||
assignment.evaluation?.binaryDecision !== undefined ? (
|
||||
assignment.evaluation.binaryDecision ? (
|
||||
<div className="flex items-center gap-1 text-green-600">
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
<span className="text-sm">Yes</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-red-600">
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
<span className="text-sm">No</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectDetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-px w-full" />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProjectDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params)
|
||||
|
||||
return (
|
||||
<Suspense fallback={<ProjectDetailSkeleton />}>
|
||||
<ProjectDetailContent projectId={id} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user