Add jury assignment transfer, cap redistribution, and learning hub overhaul
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>
This commit is contained in:
2026-02-21 18:50:29 +01:00
parent f42b452899
commit ee2f10e080
16 changed files with 2643 additions and 945 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
@@ -8,15 +8,11 @@ import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
Select,
SelectContent,
@@ -24,6 +20,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import {
AlertDialog,
AlertDialogAction,
@@ -35,46 +39,62 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { toast } from 'sonner'
import {
ArrowLeft,
Save,
Loader2,
FileText,
Video,
Link as LinkIcon,
File,
Trash2,
Settings,
Eye,
Trash2,
AlertCircle,
} from 'lucide-react'
// Dynamically import BlockEditor to avoid SSR issues
// Dynamically import editors to avoid SSR issues
const BlockEditor = dynamic(
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
{
ssr: false,
loading: () => (
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
const resourceTypeOptions = [
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
{ value: 'PDF', label: 'PDF', icon: FileText },
{ value: 'VIDEO', label: 'Video', icon: Video },
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
{ value: 'OTHER', label: 'Other', icon: File },
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" />
),
}
)
const ROLE_OPTIONS = [
{ value: 'JURY_MEMBER', label: 'Jury Members' },
{ value: 'MENTOR', label: 'Mentors' },
{ value: 'OBSERVER', label: 'Observers' },
{ value: 'APPLICANT', label: 'Applicants' },
{ value: 'AWARD_MASTER', label: 'Award Masters' },
]
const cohortOptions = [
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
]
type AccessRule =
| { type: 'everyone' }
| { type: 'roles'; roles: string[] }
| { type: 'jury_group'; juryGroupIds: string[] }
| { type: 'round'; roundIds: string[] }
function parseAccessJson(accessJson: unknown): { mode: 'everyone' | 'roles'; roles: string[] } {
if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) {
return { mode: 'everyone', roles: [] }
}
const firstRule = accessJson[0] as AccessRule
if (firstRule.type === 'roles' && 'roles' in firstRule) {
return { mode: 'roles', roles: firstRule.roles }
}
return { mode: 'everyone', roles: [] }
}
export default function EditLearningResourcePage() {
const params = useParams()
@@ -89,11 +109,14 @@ export default function EditLearningResourcePage() {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [contentJson, setContentJson] = useState<string>('')
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
const [externalUrl, setExternalUrl] = useState('')
const [isPublished, setIsPublished] = useState(false)
const [programId, setProgramId] = useState<string | null>(null)
const [previewing, setPreviewing] = useState(false)
// Access rules state
const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone')
const [selectedRoles, setSelectedRoles] = useState<string[]>([])
// API
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
@@ -115,11 +138,13 @@ export default function EditLearningResourcePage() {
setTitle(resource.title)
setDescription(resource.description || '')
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
setResourceType(resource.resourceType)
setCohortLevel(resource.cohortLevel)
setExternalUrl(resource.externalUrl || '')
setIsPublished(resource.isPublished)
setProgramId(resource.programId)
const { mode, roles } = parseAccessJson(resource.accessJson)
setAccessMode(mode)
setSelectedRoles(roles)
}
}, [resource])
@@ -134,74 +159,88 @@ export default function EditLearningResourcePage() {
await fetch(url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
headers: { 'Content-Type': file.type },
})
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
return `${minioEndpoint}/${bucket}/${objectKey}`
} catch (error) {
} catch {
toast.error('Failed to upload file')
throw error
throw new Error('Upload failed')
}
}
const handleSubmit = async () => {
const buildAccessJson = (): AccessRule[] | null => {
if (accessMode === 'everyone') return null
if (accessMode === 'roles' && selectedRoles.length > 0) {
return [{ type: 'roles', roles: selectedRoles }]
}
return null
}
const handleSubmit = useCallback(async () => {
if (!title.trim()) {
toast.error('Please enter a title')
return
}
if (resourceType === 'LINK' && !externalUrl) {
toast.error('Please enter an external URL')
return
}
try {
await updateResource.mutateAsync({
id: resourceId,
programId,
title,
description: description || undefined,
description: description || null,
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
accessJson: buildAccessJson(),
externalUrl: externalUrl || null,
isPublished,
})
toast.success('Resource updated successfully')
router.push('/admin/learning')
toast.success('Resource updated')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
}
}
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles, resourceId])
const handleDelete = async () => {
try {
await deleteResource.mutateAsync({ id: resourceId })
toast.success('Resource deleted successfully')
toast.success('Resource deleted')
router.push('/admin/learning')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
}
}
// Ctrl+S save
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSubmit()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleSubmit])
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-40" />
</div>
<Skeleton className="h-8 w-64" />
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-96 w-full" />
<div className="flex min-h-screen flex-col">
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2">
<Skeleton className="h-8 w-20" />
<div className="flex gap-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-16" />
</div>
<div className="space-y-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-32 w-full" />
</div>
<div className="flex-1 px-4 py-8">
<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-96 w-full" />
</div>
</div>
</div>
@@ -210,7 +249,7 @@ export default function EditLearningResourcePage() {
if (error || !resource) {
return (
<div className="space-y-6">
<div className="space-y-6 p-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Resource not found</AlertTitle>
@@ -229,253 +268,250 @@ export default function EditLearningResourcePage() {
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<div className="flex min-h-screen flex-col">
{/* Sticky toolbar */}
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button variant="ghost" size="sm" asChild>
<Link href="/admin/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
Back
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
<p className="text-muted-foreground">
Update this learning resource
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{resource.title}&quot;? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div className="flex items-center gap-2">
<Button
variant={previewing ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewing(!previewing)}
>
<Eye className="mr-2 h-4 w-4" />
{previewing ? 'Edit' : 'Preview'}
</Button>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Resource Details</CardTitle>
<CardDescription>
Basic information about this resource
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Ocean Conservation Best Practices"
/>
</div>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="sm">
<Settings className="mr-2 h-4 w-4" />
Settings
</Button>
</SheetTrigger>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>Resource Settings</SheetTitle>
<SheetDescription>
Configure publishing, access, and metadata
</SheetDescription>
</SheetHeader>
<div className="space-y-2">
<Label htmlFor="description">Short Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this resource"
rows={2}
maxLength={500}
/>
</div>
<div className="mt-6 space-y-6">
{/* Publish toggle */}
<div className="flex items-center justify-between">
<div>
<Label>Published</Label>
<p className="text-sm text-muted-foreground">
Make visible to users
</p>
</div>
<Switch
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Separator />
{/* Program */}
<div className="space-y-2">
<Label htmlFor="type">Resource Type</Label>
<Select value={resourceType} onValueChange={setResourceType}>
<SelectTrigger id="type">
<SelectValue />
<Label>Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger>
<SelectValue placeholder="Select program" />
</SelectTrigger>
<SelectContent>
{resourceTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<option.icon className="h-4 w-4" />
{option.label}
</div>
<SelectItem value="global">Global (All Programs)</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.year} Edition
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="cohort">Access Level</Label>
<Select value={cohortLevel} onValueChange={setCohortLevel}>
<SelectTrigger id="cohort">
<Separator />
{/* Access Rules */}
<div className="space-y-3">
<Label>Access Rules</Label>
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{cohortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
<SelectItem value="everyone">Everyone</SelectItem>
<SelectItem value="roles">By Role</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{resourceType === 'LINK' && (
{accessMode === 'roles' && (
<div className="space-y-2 rounded-lg border p-3">
{ROLE_OPTIONS.map((role) => (
<label key={role.value} className="flex items-center gap-2 text-sm">
<Checkbox
checked={selectedRoles.includes(role.value)}
onCheckedChange={(checked) => {
setSelectedRoles(
checked
? [...selectedRoles, role.value]
: selectedRoles.filter((r) => r !== role.value)
)
}}
/>
{role.label}
</label>
))}
</div>
)}
</div>
<Separator />
{/* External URL */}
<div className="space-y-2">
<Label htmlFor="url">External URL *</Label>
<Label>External URL</Label>
<Input
id="url"
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://example.com/resource"
/>
</div>
)}
</CardContent>
</Card>
{/* Content Editor */}
<Card>
<CardHeader>
<CardTitle>Content</CardTitle>
<CardDescription>
Rich text content with images and videos. Type / for commands.
</CardDescription>
</CardHeader>
<CardContent>
<BlockEditor
key={resourceId}
initialContent={contentJson || undefined}
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[300px]"
/>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Publish Settings */}
<Card>
<CardHeader>
<CardTitle>Publish Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="published">Published</Label>
<p className="text-sm text-muted-foreground">
Make this resource visible to jury members
<p className="text-xs text-muted-foreground">
Optional link to an external resource
</p>
</div>
<Switch
id="published"
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
<div className="space-y-2">
<Label htmlFor="program">Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger id="program">
<SelectValue placeholder="Select program" />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global (All Programs)</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.year} Edition
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Separator />
{/* Statistics */}
{stats && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Statistics
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-2xl font-semibold">{stats.totalViews}</p>
<p className="text-sm text-muted-foreground">Total views</p>
</div>
<div>
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
<p className="text-sm text-muted-foreground">Unique users</p>
{/* Statistics */}
{stats && (
<div className="space-y-2">
<Label>Statistics</Label>
<div className="grid grid-cols-2 gap-4 rounded-lg border p-3">
<div>
<p className="text-2xl font-semibold">{stats.totalViews}</p>
<p className="text-xs text-muted-foreground">Total views</p>
</div>
<div>
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
<p className="text-xs text-muted-foreground">Unique users</p>
</div>
</div>
</div>
)}
<Separator />
{/* Danger Zone */}
<div className="space-y-2">
<Label className="text-destructive">Danger Zone</Label>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Resource
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{resource.title}&quot;?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
)}
{/* Actions */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-2">
<Button
onClick={handleSubmit}
disabled={updateResource.isPending || !title.trim()}
className="w-full"
>
{updateResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
<Button variant="outline" asChild className="w-full">
<Link href="/admin/learning">Cancel</Link>
</Button>
</div>
</CardContent>
</Card>
</SheetContent>
</Sheet>
<Button
size="sm"
onClick={handleSubmit}
disabled={updateResource.isPending || !title.trim()}
>
{updateResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save
</Button>
</div>
</div>
{/* Content area */}
<div className="flex-1 px-4 py-8">
{previewing ? (
<ResourceRenderer
title={title || 'Untitled'}
description={description || null}
contentJson={contentJson ? JSON.parse(contentJson) : null}
/>
) : (
<div className="mx-auto max-w-3xl space-y-4">
{/* Inline title */}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Untitled"
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
/>
{/* Inline description */}
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description..."
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
/>
{/* Divider */}
<hr className="border-border" />
{/* Block editor */}
<BlockEditor
key={resourceId}
initialContent={contentJson || undefined}
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[400px]"
/>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
@@ -8,15 +8,9 @@ import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Separator } from '@/components/ui/separator'
import {
Select,
SelectContent,
@@ -24,33 +18,57 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { toast } from 'sonner'
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
import {
ArrowLeft,
Save,
Loader2,
Settings,
Eye,
} from 'lucide-react'
// Dynamically import BlockEditor to avoid SSR issues
// Dynamically import editors to avoid SSR issues
const BlockEditor = dynamic(
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
{
ssr: false,
loading: () => (
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
const resourceTypeOptions = [
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
{ value: 'PDF', label: 'PDF', icon: FileText },
{ value: 'VIDEO', label: 'Video', icon: Video },
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
{ value: 'OTHER', label: 'Other', icon: File },
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" />
),
}
)
const ROLE_OPTIONS = [
{ value: 'JURY_MEMBER', label: 'Jury Members' },
{ value: 'MENTOR', label: 'Mentors' },
{ value: 'OBSERVER', label: 'Observers' },
{ value: 'APPLICANT', label: 'Applicants' },
{ value: 'AWARD_MASTER', label: 'Award Masters' },
]
const cohortOptions = [
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
]
type AccessRule =
| { type: 'everyone' }
| { type: 'roles'; roles: string[] }
| { type: 'jury_group'; juryGroupIds: string[] }
| { type: 'round'; roundIds: string[] }
export default function NewLearningResourcePage() {
const router = useRouter()
@@ -59,14 +77,17 @@ export default function NewLearningResourcePage() {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [contentJson, setContentJson] = useState<string>('')
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
const [externalUrl, setExternalUrl] = useState('')
const [isPublished, setIsPublished] = useState(false)
const [programId, setProgramId] = useState<string | null>(null)
const [previewing, setPreviewing] = useState(false)
// Access rules state
const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone')
const [selectedRoles, setSelectedRoles] = useState<string[]>([])
// API
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const [programId, setProgramId] = useState<string | null>(null)
const utils = trpc.useUtils()
const createResource = trpc.learningResource.create.useMutation({
@@ -82,43 +103,41 @@ export default function NewLearningResourcePage() {
mimeType: file.type,
})
// Upload to MinIO
await fetch(url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
headers: { 'Content-Type': file.type },
})
// Return the MinIO URL
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
return `${minioEndpoint}/${bucket}/${objectKey}`
} catch (error) {
} catch {
toast.error('Failed to upload file')
throw error
throw new Error('Upload failed')
}
}
const handleSubmit = async () => {
const buildAccessJson = (): AccessRule[] | null => {
if (accessMode === 'everyone') return null
if (accessMode === 'roles' && selectedRoles.length > 0) {
return [{ type: 'roles', roles: selectedRoles }]
}
return null
}
const handleSubmit = useCallback(async () => {
if (!title.trim()) {
toast.error('Please enter a title')
return
}
if (resourceType === 'LINK' && !externalUrl) {
toast.error('Please enter an external URL')
return
}
try {
await createResource.mutateAsync({
programId,
title,
description: description || undefined,
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
accessJson: buildAccessJson(),
externalUrl: externalUrl || undefined,
isPublished,
})
@@ -128,200 +147,205 @@ export default function NewLearningResourcePage() {
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
}
}
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles])
// Ctrl+S save
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSubmit()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleSubmit])
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<div className="flex min-h-screen flex-col">
{/* Sticky toolbar */}
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button variant="ghost" size="sm" asChild>
<Link href="/admin/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
Back
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
<p className="text-muted-foreground">
Create a new learning resource for jury members
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant={previewing ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewing(!previewing)}
>
<Eye className="mr-2 h-4 w-4" />
{previewing ? 'Edit' : 'Preview'}
</Button>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Resource Details</CardTitle>
<CardDescription>
Basic information about this resource
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Ocean Conservation Best Practices"
/>
</div>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="sm">
<Settings className="mr-2 h-4 w-4" />
Settings
</Button>
</SheetTrigger>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>Resource Settings</SheetTitle>
<SheetDescription>
Configure publishing, access, and metadata
</SheetDescription>
</SheetHeader>
<div className="space-y-2">
<Label htmlFor="description">Short Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this resource"
rows={2}
maxLength={500}
/>
</div>
<div className="mt-6 space-y-6">
{/* Publish toggle */}
<div className="flex items-center justify-between">
<div>
<Label>Published</Label>
<p className="text-sm text-muted-foreground">
Make visible to users
</p>
</div>
<Switch
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Separator />
{/* Program */}
<div className="space-y-2">
<Label htmlFor="type">Resource Type</Label>
<Select value={resourceType} onValueChange={setResourceType}>
<SelectTrigger id="type">
<SelectValue />
<Label>Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger>
<SelectValue placeholder="Select program" />
</SelectTrigger>
<SelectContent>
{resourceTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<option.icon className="h-4 w-4" />
{option.label}
</div>
<SelectItem value="global">Global (All Programs)</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.year} Edition
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="cohort">Access Level</Label>
<Select value={cohortLevel} onValueChange={setCohortLevel}>
<SelectTrigger id="cohort">
<Separator />
{/* Access Rules */}
<div className="space-y-3">
<Label>Access Rules</Label>
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{cohortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
<SelectItem value="everyone">Everyone</SelectItem>
<SelectItem value="roles">By Role</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{resourceType === 'LINK' && (
{accessMode === 'roles' && (
<div className="space-y-2 rounded-lg border p-3">
{ROLE_OPTIONS.map((role) => (
<label key={role.value} className="flex items-center gap-2 text-sm">
<Checkbox
checked={selectedRoles.includes(role.value)}
onCheckedChange={(checked) => {
setSelectedRoles(
checked
? [...selectedRoles, role.value]
: selectedRoles.filter((r) => r !== role.value)
)
}}
/>
{role.label}
</label>
))}
</div>
)}
</div>
<Separator />
{/* External URL */}
<div className="space-y-2">
<Label htmlFor="url">External URL *</Label>
<Label>External URL</Label>
<Input
id="url"
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://example.com/resource"
/>
</div>
)}
</CardContent>
</Card>
{/* Content Editor */}
<Card>
<CardHeader>
<CardTitle>Content</CardTitle>
<CardDescription>
Rich text content with images and videos. Type / for commands.
</CardDescription>
</CardHeader>
<CardContent>
<BlockEditor
initialContent={contentJson || undefined}
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[300px]"
/>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Publish Settings */}
<Card>
<CardHeader>
<CardTitle>Publish Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="published">Published</Label>
<p className="text-sm text-muted-foreground">
Make this resource visible to jury members
<p className="text-xs text-muted-foreground">
Optional link to an external resource
</p>
</div>
<Switch
id="published"
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
</SheetContent>
</Sheet>
<div className="space-y-2">
<Label htmlFor="program">Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger id="program">
<SelectValue placeholder="Select program" />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global (All Programs)</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.year} Edition
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Actions */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-2">
<Button
onClick={handleSubmit}
disabled={createResource.isPending || !title.trim()}
className="w-full"
>
{createResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Create Resource
</Button>
<Button variant="outline" asChild className="w-full">
<Link href="/admin/learning">Cancel</Link>
</Button>
</div>
</CardContent>
</Card>
<Button
size="sm"
onClick={handleSubmit}
disabled={createResource.isPending || !title.trim()}
>
{createResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save
</Button>
</div>
</div>
{/* Content area */}
<div className="flex-1 px-4 py-8">
{previewing ? (
<ResourceRenderer
title={title || 'Untitled'}
description={description || null}
contentJson={contentJson ? JSON.parse(contentJson) : null}
/>
) : (
<div className="mx-auto max-w-3xl space-y-4">
{/* Inline title */}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Untitled"
autoFocus
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
/>
{/* Inline description */}
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description..."
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
/>
{/* Divider */}
<hr className="border-border" />
{/* Block editor */}
<BlockEditor
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[400px]"
/>
</div>
)}
</div>
</div>
)
}

View File

@@ -12,6 +12,7 @@ import {
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
@@ -22,48 +23,212 @@ import {
import {
Plus,
FileText,
Video,
Link as LinkIcon,
File,
Pencil,
ExternalLink,
Search,
GripVertical,
} from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
const resourceTypeIcons = {
PDF: FileText,
VIDEO: Video,
DOCUMENT: File,
LINK: LinkIcon,
OTHER: File,
type Resource = {
id: string
title: string
description: string | null
isPublished: boolean
sortOrder: number
externalUrl: string | null
objectKey: string | null
contentJson: unknown
accessJson: unknown
_count: { accessLogs: number }
program: { id: string; name: string; year: number } | null
}
const cohortColors: Record<string, string> = {
ALL: 'bg-gray-100 text-gray-800',
SEMIFINALIST: 'bg-blue-100 text-blue-800',
FINALIST: 'bg-purple-100 text-purple-800',
function getAccessSummary(accessJson: unknown): string {
if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) {
return 'Everyone'
}
const rule = accessJson[0] as { type: string; roles?: string[] }
if (rule.type === 'everyone') return 'Everyone'
if (rule.type === 'roles' && rule.roles) {
if (rule.roles.length === 1) return rule.roles[0].replace('_', ' ').toLowerCase()
return `${rule.roles.length} roles`
}
if (rule.type === 'jury_group') return 'Jury groups'
if (rule.type === 'round') return 'By round'
return 'Custom'
}
function SortableResourceCard({
resource,
onTogglePublished,
}: {
resource: Resource
onTogglePublished: (id: string, published: boolean) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: resource.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<Card
ref={setNodeRef}
style={style}
className={isDragging ? 'opacity-50 shadow-lg' : ''}
>
<CardContent className="flex items-center gap-3 py-3">
{/* Drag handle */}
<button
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
{/* Icon */}
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted shrink-0">
<FileText className="h-4 w-4" />
</div>
{/* Title & meta */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{resource.title}</h3>
{!resource.isPublished && (
<Badge variant="secondary" className="text-xs">Draft</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span className="capitalize">{getAccessSummary(resource.accessJson)}</span>
<span>&middot;</span>
<span>{resource._count.accessLogs} views</span>
{resource.program && (
<>
<span>&middot;</span>
<span>{resource.program.year}</span>
</>
)}
</div>
</div>
{/* Quick publish toggle */}
<Switch
checked={resource.isPublished}
onCheckedChange={(checked) => onTogglePublished(resource.id, checked)}
aria-label={resource.isPublished ? 'Unpublish' : 'Publish'}
/>
{/* Actions */}
<div className="flex items-center gap-1">
{resource.externalUrl && (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="icon" className="h-8 w-8">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<Link href={`/admin/learning/${resource.id}`}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
)
}
export default function LearningHubPage() {
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 50 })
const resources = data?.data
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 100 })
const resources = (data?.data || []) as Resource[]
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 300)
const [typeFilter, setTypeFilter] = useState('all')
const [cohortFilter, setCohortFilter] = useState('all')
const [publishedFilter, setPublishedFilter] = useState('all')
const utils = trpc.useUtils()
const reorderMutation = trpc.learningResource.reorder.useMutation({
onSuccess: () => utils.learningResource.list.invalidate(),
})
const updateMutation = trpc.learningResource.update.useMutation({
onSuccess: () => utils.learningResource.list.invalidate(),
})
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const filteredResources = useMemo(() => {
if (!resources) return []
return resources.filter((resource) => {
const matchesSearch =
!debouncedSearch ||
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter
const matchesCohort = cohortFilter === 'all' || resource.cohortLevel === cohortFilter
return matchesSearch && matchesType && matchesCohort
const matchesPublished =
publishedFilter === 'all' ||
(publishedFilter === 'published' && resource.isPublished) ||
(publishedFilter === 'draft' && !resource.isPublished)
return matchesSearch && matchesPublished
})
}, [resources, debouncedSearch, typeFilter, cohortFilter])
}, [resources, debouncedSearch, publishedFilter])
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = filteredResources.findIndex((r) => r.id === active.id)
const newIndex = filteredResources.findIndex((r) => r.id === over.id)
if (oldIndex === -1 || newIndex === -1) return
const reordered = arrayMove(filteredResources, oldIndex, newIndex)
const items = reordered.map((r, i) => ({ id: r.id, sortOrder: i }))
reorderMutation.mutate({ items }, {
onError: () => toast.error('Failed to reorder'),
})
}
const handleTogglePublished = (id: string, published: boolean) => {
updateMutation.mutate({ id, isPublished: published }, {
onSuccess: () => toast.success(published ? 'Published' : 'Unpublished'),
onError: () => toast.error('Failed to update'),
})
}
if (isLoading) {
return (
@@ -75,25 +240,20 @@ export default function LearningHubPage() {
</div>
<Skeleton className="h-9 w-32" />
</div>
{/* Toolbar skeleton */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Skeleton className="h-10 flex-1" />
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-[160px]" />
<Skeleton className="h-10 w-[160px]" />
</div>
<Skeleton className="h-10 w-[160px]" />
</div>
{/* Resource list skeleton */}
<div className="grid gap-4">
<div className="grid gap-3">
{[...Array(5)].map((_, i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-9 w-9 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-8 w-8 rounded" />
</CardContent>
</Card>
))}
@@ -109,7 +269,7 @@ export default function LearningHubPage() {
<div>
<h1 className="text-2xl font-bold">Learning Hub</h1>
<p className="text-muted-foreground">
Manage educational resources for jury members
Manage educational resources for program participants
</p>
</div>
<Link href="/admin/learning/new">
@@ -131,92 +291,49 @@ export default function LearningHubPage() {
className="pl-9"
/>
</div>
<div className="flex items-center gap-2">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="PDF">PDF</SelectItem>
<SelectItem value="VIDEO">Video</SelectItem>
<SelectItem value="DOCUMENT">Document</SelectItem>
<SelectItem value="LINK">Link</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
<Select value={cohortFilter} onValueChange={setCohortFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All cohorts" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All cohorts</SelectItem>
<SelectItem value="ALL">All (cohort)</SelectItem>
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
<SelectItem value="FINALIST">Finalist</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={publishedFilter} onValueChange={setPublishedFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="draft">Drafts</SelectItem>
</SelectContent>
</Select>
</div>
{/* Results count */}
{resources && (
{resources.length > 0 && (
<p className="text-sm text-muted-foreground">
{filteredResources.length} of {resources.length} resources
{reorderMutation.isPending && ' · Saving order...'}
</p>
)}
{/* Resource List */}
{/* Resource List with DnD */}
{filteredResources.length > 0 ? (
<div className="grid gap-4">
{filteredResources.map((resource) => {
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{resource.title}</h3>
{!resource.isPublished && (
<Badge variant="secondary">Draft</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
{resource.cohortLevel}
</Badge>
<span>{resource.resourceType}</span>
<span>-</span>
<span>{resource._count.accessLogs} views</span>
</div>
</div>
<div className="flex items-center gap-2">
{resource.externalUrl && (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<Link href={`/admin/learning/${resource.id}`}>
<Button variant="ghost" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
)
})}
</div>
) : resources && resources.length > 0 ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filteredResources.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="grid gap-3">
{filteredResources.map((resource) => (
<SortableResourceCard
key={resource.id}
resource={resource}
onTogglePublished={handleTogglePublished}
/>
))}
</div>
</SortableContext>
</DndContext>
) : resources.length > 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Search className="h-8 w-8 text-muted-foreground/40" />

View File

@@ -88,6 +88,7 @@ import {
Mail,
History,
ChevronRight,
ArrowRightLeft,
} from 'lucide-react'
import {
Command,
@@ -1667,6 +1668,8 @@ export default function RoundDetailPage() {
<InlineMemberCap
memberId={member.id}
currentValue={member.maxAssignmentsOverride as number | null}
roundId={roundId}
jurorUserId={member.userId}
onSave={(val) => updateJuryMemberMutation.mutate({
id: member.id,
maxAssignmentsOverride: val,
@@ -2242,20 +2245,43 @@ function InlineMemberCap({
memberId,
currentValue,
onSave,
roundId,
jurorUserId,
}: {
memberId: string
currentValue: number | null
onSave: (val: number | null) => void
roundId?: string
jurorUserId?: string
}) {
const utils = trpc.useUtils()
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(currentValue?.toString() ?? '')
const [overCapInfo, setOverCapInfo] = useState<{ total: number; overCapCount: number; movableOverCap: number; immovableOverCap: number } | null>(null)
const [showBanner, setShowBanner] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const redistributeMutation = trpc.assignment.redistributeOverCap.useMutation({
onSuccess: (data) => {
utils.assignment.listByStage.invalidate()
utils.analytics.getJurorWorkload.invalidate()
utils.roundAssignment.unassignedQueue.invalidate()
setShowBanner(false)
setOverCapInfo(null)
if (data.failed > 0) {
toast.warning(`Redistributed ${data.redistributed} project(s). ${data.failed} could not be reassigned.`)
} else {
toast.success(`Redistributed ${data.redistributed} project(s) to other jurors.`)
}
},
onError: (err) => toast.error(err.message),
})
useEffect(() => {
if (editing) inputRef.current?.focus()
}, [editing])
const save = () => {
const save = async () => {
const trimmed = value.trim()
const newVal = trimmed === '' ? null : parseInt(trimmed, 10)
if (newVal !== null && (isNaN(newVal) || newVal < 1)) {
@@ -2266,10 +2292,76 @@ function InlineMemberCap({
setEditing(false)
return
}
// Check over-cap impact before saving
if (newVal !== null && roundId && jurorUserId) {
try {
const preview = await utils.client.assignment.getOverCapPreview.query({
roundId,
jurorId: jurorUserId,
newCap: newVal,
})
if (preview.overCapCount > 0) {
setOverCapInfo(preview)
setShowBanner(true)
setEditing(false)
return
}
} catch {
// If preview fails, just save the cap normally
}
}
onSave(newVal)
setEditing(false)
}
const handleRedistribute = () => {
const newVal = parseInt(value.trim(), 10)
onSave(newVal)
if (roundId && jurorUserId) {
redistributeMutation.mutate({ roundId, jurorId: jurorUserId, newCap: newVal })
}
}
const handleJustSave = () => {
const newVal = value.trim() === '' ? null : parseInt(value.trim(), 10)
onSave(newVal)
setShowBanner(false)
setOverCapInfo(null)
}
if (showBanner && overCapInfo) {
return (
<div className="space-y-1.5">
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
<p>New cap of <strong>{value}</strong> is below current load (<strong>{overCapInfo.total}</strong> assignments). <strong>{overCapInfo.movableOverCap}</strong> can be redistributed.</p>
{overCapInfo.immovableOverCap > 0 && (
<p className="text-amber-600 mt-0.5">{overCapInfo.immovableOverCap} submitted evaluation(s) cannot be moved.</p>
)}
<div className="flex gap-1.5 mt-1.5">
<Button
size="sm"
variant="default"
className="h-6 text-xs px-2"
disabled={redistributeMutation.isPending || overCapInfo.movableOverCap === 0}
onClick={handleRedistribute}
>
{redistributeMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
Redistribute
</Button>
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={handleJustSave}>
Just save cap
</Button>
<Button size="sm" variant="ghost" className="h-6 text-xs px-2" onClick={() => { setShowBanner(false); setOverCapInfo(null) }}>
Cancel
</Button>
</div>
</div>
</div>
)
}
if (editing) {
return (
<Input
@@ -2364,6 +2456,8 @@ function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: strin
function JuryProgressTable({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery(
{ roundId },
{ refetchInterval: 15_000 },
@@ -2393,6 +2487,7 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
})
return (
<>
<Card>
<CardHeader>
<CardTitle className="text-base">Jury Progress</CardTitle>
@@ -2448,6 +2543,22 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => setTransferJuror({ id: juror.id, name: juror.name })}
>
<ArrowRightLeft className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Transfer assignments to other jurors</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
@@ -2489,6 +2600,270 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
)}
</CardContent>
</Card>
{transferJuror && (
<TransferAssignmentsDialog
roundId={roundId}
sourceJuror={transferJuror}
open={!!transferJuror}
onClose={() => setTransferJuror(null)}
/>
)}
</>
)
}
// ── Transfer Assignments Dialog ──────────────────────────────────────────
function TransferAssignmentsDialog({
roundId,
sourceJuror,
open,
onClose,
}: {
roundId: string
sourceJuror: { id: string; name: string }
open: boolean
onClose: () => void
}) {
const utils = trpc.useUtils()
const [step, setStep] = useState<1 | 2>(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Fetch source juror's assignments
const { data: sourceAssignments, isLoading: loadingAssignments } = trpc.assignment.listByStage.useQuery(
{ roundId },
{ enabled: open },
)
const jurorAssignments = useMemo(() =>
(sourceAssignments ?? []).filter((a: any) => a.userId === sourceJuror.id),
[sourceAssignments, sourceJuror.id],
)
// Fetch transfer candidates when in step 2
const { data: candidateData, isLoading: loadingCandidates } = trpc.assignment.getTransferCandidates.useQuery(
{ roundId, sourceJurorId: sourceJuror.id, assignmentIds: [...selectedIds] },
{ enabled: step === 2 && selectedIds.size > 0 },
)
// Per-assignment destination overrides
const [destOverrides, setDestOverrides] = useState<Record<string, string>>({})
const [forceOverCap, setForceOverCap] = useState(false)
const transferMutation = trpc.assignment.transferAssignments.useMutation({
onSuccess: (data) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.analytics.getJurorWorkload.invalidate({ roundId })
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
utils.assignment.getReassignmentHistory.invalidate({ roundId })
const successCount = data.succeeded.length
const failCount = data.failed.length
if (failCount > 0) {
toast.warning(`Transferred ${successCount} project(s). ${failCount} failed.`)
} else {
toast.success(`Transferred ${successCount} project(s) successfully.`)
}
onClose()
},
onError: (err) => toast.error(err.message),
})
// Build the transfer plan: for each selected assignment, determine destination
const transferPlan = useMemo(() => {
if (!candidateData) return []
const movable = candidateData.assignments.filter((a) => a.movable)
return movable.map((assignment) => {
const override = destOverrides[assignment.id]
// Default: first eligible candidate
const defaultDest = candidateData.candidates.find((c) =>
c.eligibleProjectIds.includes(assignment.projectId)
)
const destId = override || defaultDest?.userId || ''
const destName = candidateData.candidates.find((c) => c.userId === destId)?.name || ''
return { assignmentId: assignment.id, projectTitle: assignment.projectTitle, destinationJurorId: destId, destName }
}).filter((t) => t.destinationJurorId)
}, [candidateData, destOverrides])
// Check if any destination is at or over cap
const anyOverCap = useMemo(() => {
if (!candidateData) return false
const destCounts = new Map<string, number>()
for (const t of transferPlan) {
destCounts.set(t.destinationJurorId, (destCounts.get(t.destinationJurorId) ?? 0) + 1)
}
return candidateData.candidates.some((c) => {
const extraLoad = destCounts.get(c.userId) ?? 0
return c.currentLoad + extraLoad > c.cap
})
}, [candidateData, transferPlan])
const handleTransfer = () => {
transferMutation.mutate({
roundId,
sourceJurorId: sourceJuror.id,
transfers: transferPlan.map((t) => ({ assignmentId: t.assignmentId, destinationJurorId: t.destinationJurorId })),
forceOverCap,
})
}
const isMovable = (a: any) => {
const status = a.evaluation?.status
return !status || status === 'NOT_STARTED' || status === 'DRAFT'
}
const movableAssignments = jurorAssignments.filter(isMovable)
const allMovableSelected = movableAssignments.length > 0 && movableAssignments.every((a: any) => selectedIds.has(a.id))
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Transfer Assignments from {sourceJuror.name}</DialogTitle>
<DialogDescription>
{step === 1 ? 'Select projects to transfer to other jurors.' : 'Choose destination jurors for each project.'}
</DialogDescription>
</DialogHeader>
{step === 1 && (
<div className="space-y-3">
{loadingAssignments ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : jurorAssignments.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">No assignments found.</p>
) : (
<>
<div className="flex items-center gap-2 pb-2 border-b">
<Checkbox
checked={allMovableSelected}
onCheckedChange={(checked) => {
if (checked) {
setSelectedIds(new Set(movableAssignments.map((a: any) => a.id)))
} else {
setSelectedIds(new Set())
}
}}
/>
<span className="text-xs text-muted-foreground">Select all movable ({movableAssignments.length})</span>
</div>
<div className="space-y-1 max-h-[400px] overflow-y-auto">
{jurorAssignments.map((a: any) => {
const movable = isMovable(a)
const status = a.evaluation?.status || 'No evaluation'
return (
<div key={a.id} className={cn('flex items-center gap-3 py-2 px-2 rounded-md', !movable && 'opacity-50')}>
<Checkbox
checked={selectedIds.has(a.id)}
disabled={!movable}
onCheckedChange={(checked) => {
const next = new Set(selectedIds)
if (checked) next.add(a.id)
else next.delete(a.id)
setSelectedIds(next)
}}
/>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{a.project?.title || 'Unknown'}</p>
</div>
<Badge variant="outline" className="text-xs shrink-0">
{status}
</Badge>
</div>
)
})}
</div>
</>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
disabled={selectedIds.size === 0}
onClick={() => { setStep(2); setDestOverrides({}) }}
>
Next ({selectedIds.size} selected)
</Button>
</DialogFooter>
</div>
)}
{step === 2 && (
<div className="space-y-3">
{loadingCandidates ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : !candidateData || candidateData.candidates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">No eligible candidates found.</p>
) : (
<>
<div className="space-y-2 max-h-[350px] overflow-y-auto">
{candidateData.assignments.filter((a) => a.movable).map((assignment) => {
const currentDest = destOverrides[assignment.id] ||
candidateData.candidates.find((c) => c.eligibleProjectIds.includes(assignment.projectId))?.userId || ''
return (
<div key={assignment.id} className="flex items-center gap-3 py-2 px-2 border rounded-md">
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{assignment.projectTitle}</p>
<p className="text-xs text-muted-foreground">{assignment.evalStatus || 'No evaluation'}</p>
</div>
<Select
value={currentDest}
onValueChange={(v) => setDestOverrides((prev) => ({ ...prev, [assignment.id]: v }))}
>
<SelectTrigger className="w-[200px] h-8 text-xs">
<SelectValue placeholder="Select juror" />
</SelectTrigger>
<SelectContent>
{candidateData.candidates
.filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
.map((c) => (
<SelectItem key={c.userId} value={c.userId}>
<span>{c.name}</span>
<span className="text-muted-foreground ml-1">({c.currentLoad}/{c.cap})</span>
{c.allCompleted && <span className="text-emerald-600 ml-1">Done</span>}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
})}
</div>
{transferPlan.length > 0 && (
<p className="text-xs text-muted-foreground">
Transfer {transferPlan.length} project(s) from {sourceJuror.name}
</p>
)}
{anyOverCap && (
<div className="flex items-center gap-2 p-2 border border-amber-200 bg-amber-50 rounded-md">
<Checkbox
checked={forceOverCap}
onCheckedChange={(checked) => setForceOverCap(!!checked)}
/>
<span className="text-xs text-amber-800">Force over-cap: some destinations will exceed their assignment limit</span>
</div>
)}
</>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setStep(1)}>Back</Button>
<Button
disabled={transferPlan.length === 0 || transferMutation.isPending || (anyOverCap && !forceOverCap)}
onClick={handleTransfer}
>
{transferMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : null}
Transfer {transferPlan.length} project(s)
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -2512,7 +2887,7 @@ function ReassignmentHistory({ roundId }: { roundId: string }) {
Reassignment History
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', expanded && 'rotate-90')} />
</CardTitle>
<CardDescription>Juror dropout and COI reassignment audit trail</CardDescription>
<CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription>
</CardHeader>
{expanded && (
<CardContent>
@@ -2531,7 +2906,7 @@ function ReassignmentHistory({ roundId }: { roundId: string }) {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant={event.type === 'DROPOUT' ? 'destructive' : 'secondary'}>
{event.type === 'DROPOUT' ? 'Juror Dropout' : 'COI Reassignment'}
{event.type === 'DROPOUT' ? 'Juror Dropout' : event.type === 'COI' ? 'COI Reassignment' : event.type === 'TRANSFER' ? 'Assignment Transfer' : 'Cap Redistribution'}
</Badge>
<span className="text-sm font-medium">
{event.droppedJuror.name}

View 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 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&apos;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>
)
}

View File

@@ -1,41 +1,22 @@
'use client'
import { useState } from 'react'
import type { Route } from 'next'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
FileText,
Video,
Link as LinkIcon,
File,
Download,
ExternalLink,
BookOpen,
} from 'lucide-react'
const resourceTypeIcons = {
PDF: FileText,
VIDEO: Video,
DOCUMENT: File,
LINK: LinkIcon,
OTHER: File,
}
const cohortColors = {
ALL: 'bg-gray-100 text-gray-800',
SEMIFINALIST: 'bg-blue-100 text-blue-800',
FINALIST: 'bg-purple-100 text-purple-800',
}
export default function JuryLearningPage() {
const [downloadingId, setDownloadingId] = useState<string | null>(null)
@@ -81,7 +62,6 @@ export default function JuryLearningPage() {
}
const resources = data?.resources || []
const userCohortLevel = data?.userCohortLevel || 'ALL'
return (
<div className="space-y-6">
@@ -90,11 +70,6 @@ export default function JuryLearningPage() {
<p className="text-muted-foreground">
Educational resources for jury members
</p>
{userCohortLevel !== 'ALL' && (
<Badge className={cohortColors[userCohortLevel]} variant="outline">
Your access level: {userCohortLevel}
</Badge>
)}
</div>
{resources.length === 0 ? (
@@ -110,14 +85,14 @@ export default function JuryLearningPage() {
) : (
<div className="grid gap-4">
{resources.map((resource) => {
const Icon = resourceTypeIcons[resource.resourceType]
const isDownloading = downloadingId === resource.id
const hasContent = !!resource.contentJson
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<Icon className="h-5 w-5" />
<FileText className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium">{resource.title}</h3>
@@ -126,36 +101,39 @@ export default function JuryLearningPage() {
{resource.description}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline" className={cohortColors[resource.cohortLevel]}>
{resource.cohortLevel}
</Badge>
<Badge variant="secondary">
{resource.resourceType}
</Badge>
</div>
</div>
<div>
{resource.externalUrl ? (
<div className="flex items-center gap-2">
{hasContent && (
<Link href={`/jury/learning/${resource.id}` as Route}>
<Button variant="outline" size="sm">
<BookOpen className="mr-2 h-4 w-4" />
Read
</Button>
</Link>
)}
{resource.externalUrl && (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button>
<Button variant="outline" size="sm">
<ExternalLink className="mr-2 h-4 w-4" />
Open
</Button>
</a>
) : resource.objectKey ? (
)}
{resource.objectKey && (
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(resource.id)}
disabled={isDownloading}
>
<Download className="mr-2 h-4 w-4" />
{isDownloading ? 'Loading...' : 'Download'}
</Button>
) : null}
)}
</div>
</CardContent>
</Card>

View 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 MentorResourceDetailPage() {
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&apos;t have access.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/mentor/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="/mentor/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>
)
}

View File

@@ -1,38 +1,22 @@
'use client'
import { useState } from 'react'
import type { Route } from 'next'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
FileText,
Video,
Link as LinkIcon,
File,
Download,
ExternalLink,
BookOpen,
} from 'lucide-react'
const resourceTypeIcons = {
PDF: FileText,
VIDEO: Video,
DOCUMENT: File,
LINK: LinkIcon,
OTHER: File,
}
const cohortColors: Record<string, string> = {
ALL: 'bg-gray-100 text-gray-800',
SEMIFINALIST: 'bg-blue-100 text-blue-800',
FINALIST: 'bg-purple-100 text-purple-800',
}
export default function MentorResourcesPage() {
const [downloadingId, setDownloadingId] = useState<string | null>(null)
@@ -101,14 +85,14 @@ export default function MentorResourcesPage() {
) : (
<div className="grid gap-4">
{resources.map((resource) => {
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
const isDownloading = downloadingId === resource.id
const hasContent = !!resource.contentJson
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<Icon className="h-5 w-5" />
<FileText className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium">{resource.title}</h3>
@@ -117,36 +101,39 @@ export default function MentorResourcesPage() {
{resource.description}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline" className={cohortColors[resource.cohortLevel] || cohortColors.ALL}>
{resource.cohortLevel}
</Badge>
<Badge variant="secondary">
{resource.resourceType}
</Badge>
</div>
</div>
<div>
{resource.externalUrl ? (
<div className="flex items-center gap-2">
{hasContent && (
<Link href={`/mentor/resources/${resource.id}` as Route}>
<Button variant="outline" size="sm">
<BookOpen className="mr-2 h-4 w-4" />
Read
</Button>
</Link>
)}
{resource.externalUrl && (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button>
<Button variant="outline" size="sm">
<ExternalLink className="mr-2 h-4 w-4" />
Open
</Button>
</a>
) : resource.objectKey ? (
)}
{resource.objectKey && (
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(resource.id)}
disabled={isDownloading}
>
<Download className="mr-2 h-4 w-4" />
{isDownloading ? 'Loading...' : 'Download'}
</Button>
) : null}
)}
</div>
</CardContent>
</Card>