All checks were successful
Build and Push Docker Image / build (push) Successful in 12m19s
- Add getTransferCandidates/transferAssignments procedures for targeted assignment moves between jurors with TOCTOU guards and audit logging - Add getOverCapPreview/redistributeOverCap for auto-redistributing assignments when a juror's cap is lowered below their current load - Add TransferAssignmentsDialog (2-step: select projects, pick destinations) - Extend InlineMemberCap with over-cap detection and redistribute banner - Extend getReassignmentHistory to show ASSIGNMENT_TRANSFER and CAP_REDISTRIBUTE events - Learning hub: replace ResourceType/CohortLevel enums with accessJson JSONB, add coverImageKey, resource detail pages for jury/mentor, shared renderer - Migration: 20260221200000_learning_hub_overhaul Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
123 lines
3.4 KiB
TypeScript
123 lines
3.4 KiB
TypeScript
'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 JuryResourceDetailPage() {
|
|
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="/jury/learning">
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back to Learning Hub
|
|
</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="/jury/learning">
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back to Learning Hub
|
|
</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>
|
|
)
|
|
}
|