'use client' import { useState, useMemo } from 'react' import { useDebounce } from '@/hooks/use-debounce' import Link from 'next/link' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { Card, CardContent, } from '@/components/ui/card' 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, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Plus, FileText, 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' 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 } 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 ( {/* Drag handle */} {/* Icon */}
{/* Title & meta */}

{resource.title}

{!resource.isPublished && ( Draft )}
{getAccessSummary(resource.accessJson)} · {resource._count.accessLogs} views {resource.program && ( <> · {resource.program.year} )}
{/* Quick publish toggle */} onTogglePublished(resource.id, checked)} aria-label={resource.isPublished ? 'Unpublish' : 'Publish'} /> {/* Actions */}
{resource.externalUrl && ( )}
) } export default function LearningHubPage() { 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 [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(() => { return resources.filter((resource) => { const matchesSearch = !debouncedSearch || resource.title.toLowerCase().includes(debouncedSearch.toLowerCase()) const matchesPublished = publishedFilter === 'all' || (publishedFilter === 'published' && resource.isPublished) || (publishedFilter === 'draft' && !resource.isPublished) return matchesSearch && matchesPublished }) }, [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 (
{[...Array(5)].map((_, i) => (
))}
) } return (
{/* Header */}

Learning Hub

Manage educational resources for program participants

{/* Toolbar */}
setSearch(e.target.value)} className="pl-9" />
{/* Results count */} {resources.length > 0 && (

{filteredResources.length} of {resources.length} resources {reorderMutation.isPending && ' ยท Saving order...'}

)} {/* Resource List with DnD */} {filteredResources.length > 0 ? ( r.id)} strategy={verticalListSortingStrategy} >
{filteredResources.map((resource) => ( ))}
) : resources.length > 0 ? (

No resources match your filters

) : (

No resources yet

Add learning materials like videos, documents, and links for program participants.

)}
) }