Files
MOPC-Portal/src/app/(admin)/admin/learning/page.tsx

365 lines
12 KiB
TypeScript
Raw Normal View History

'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 (
<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: 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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-8 w-48" />
<Skeleton className="mt-2 h-4 w-72" />
</div>
<Skeleton className="h-9 w-32" />
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Skeleton className="h-10 flex-1" />
<Skeleton className="h-10 w-[160px]" />
</div>
<div className="grid gap-3">
{[...Array(5)].map((_, i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<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-3 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Learning Hub</h1>
<p className="text-muted-foreground">
Manage educational resources for program participants
</p>
</div>
<Link href="/admin/learning/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Resource
</Button>
</Link>
</div>
{/* Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search resources..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</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.length > 0 && (
<p className="text-sm text-muted-foreground">
{filteredResources.length} of {resources.length} resources
{reorderMutation.isPending && ' · Saving order...'}
</p>
)}
{/* Resource List with DnD */}
{filteredResources.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" />
<p className="mt-2 text-sm text-muted-foreground">
No resources match your filters
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground/40" />
<h3 className="mt-3 text-lg font-medium">No resources yet</h3>
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
Add learning materials like videos, documents, and links for program participants.
</p>
<Button className="mt-4" asChild>
<Link href="/admin/learning/new">
<Plus className="mr-2 h-4 w-4" />
Add Resource
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
)
}