'use client' import * as React from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { Play, Clock, CheckCircle, EllipsisVertical, PencilLine, Trash2, Bomb, Star, Tags, FolderPlus, BrainCircuit, Loader2, } from 'lucide-react' import { VideoCamera, ViewGrid, BookStack, Database, Folder, Code, Computer, Shield, Network, } from 'iconoir-react' import { Button } from '@/components/ui/button' import { Card, CardContent, CardFooter } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Progress } from '@/components/ui/progress' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { getCourseThumbnailClient } from '@/lib/thumbnail-index' import { getCourseDisplayName } from '@/lib/course-display' import { getDeterministicGradient } from '@/lib/cover-gradient' interface CourseCardProps { course: { id: string name: string slug: string thumbnail: string | null description: string | null favorited?: boolean tags?: Array<{ id: string; name: string; color?: string | null }> _count?: { modules: number; lessons: number } progress?: { completedLessons: number totalLessons: number percentage: number lastWatched: Date | string | null } } onChanged?: () => void | Promise } function coverGradient(title: string): string { return getDeterministicGradient(title) } function getCourseIcon(name: string) { const n = name.toLowerCase() if (n.includes('hashicorp') || n.includes('terraform') || n.includes('vault') || n.includes('consul')) return if (n.includes('linux') || n.includes('shell') || n.includes('bash') || n.includes('terminal')) return if (n.includes('code') || n.includes('program') || n.includes('develop') || n.includes('python') || n.includes('javascript')) return if (n.includes('automat') || n.includes('n8n') || n.includes('workflow') || n.includes('zapier')) return if (n.includes('database') || n.includes('sql') || n.includes('db')) return if (n.includes('folder') || n.includes('file') || n.includes('system')) return return } function getCourseIconColor(name: string) { const n = name.toLowerCase() if (n.includes('hashicorp') || n.includes('terraform') || n.includes('vault') || n.includes('consul')) return 'text-primary' if (n.includes('linux') || n.includes('shell') || n.includes('bash') || n.includes('terminal')) return 'text-emerald-400' if (n.includes('code') || n.includes('program') || n.includes('develop') || n.includes('python') || n.includes('javascript')) return 'text-amber-400' if (n.includes('automat') || n.includes('n8n') || n.includes('workflow') || n.includes('zapier')) return 'text-cyan-400' if (n.includes('database') || n.includes('sql') || n.includes('db')) return 'text-blue-400' return 'text-primary' } function getCoverClass(name: string) { const n = name.toLowerCase() if (n.includes('hashicorp') || n.includes('terraform') || n.includes('vault') || n.includes('consul')) return 'course-cover-hashicorp' if (n.includes('linux') || n.includes('shell') || n.includes('bash') || n.includes('terminal')) return 'course-cover-linux' if (n.includes('code') || n.includes('program') || n.includes('develop') || n.includes('python') || n.includes('javascript')) return 'course-cover-code' if (n.includes('automat') || n.includes('n8n') || n.includes('workflow') || n.includes('zapier')) return 'course-cover-automation' return 'course-cover-generic' } export function CourseCard({ course, onChanged }: CourseCardProps) { const router = useRouter() const [quickAction, setQuickAction] = React.useState<'tag' | 'category' | null>(null) const [quickActionValue, setQuickActionValue] = React.useState('') const [quickActionError, setQuickActionError] = React.useState(null) const [quizStatus, setQuizStatus] = React.useState<'idle' | 'running' | 'success' | 'error'>('idle') const [quizMessage, setQuizMessage] = React.useState(null) const { progress, _count } = course const courseTitle = getCourseDisplayName(course) const percentage = progress?.percentage || 0 const completed = progress?.completedLessons || 0 const total = progress?.totalLessons || _count?.lessons || 0 const lastWatched = progress?.lastWatched const visibleTags = course.tags || [] const categoryTags = visibleTags.filter((tag) => tag.name.startsWith('category:')) const plainTags = visibleTags.filter((tag) => !tag.name.startsWith('category:')) const CourseIcon = getCourseIcon(course.name) const iconColor = getCourseIconColor(course.name) const coverClass = getCoverClass(course.name) const thumbnailUrl = getCourseThumbnailClient(course) const refreshLibrary = async () => { await onChanged?.() router.refresh() } const patchCourse = async (payload: Record) => { const response = await fetch(`/api/courses/${course.slug}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) if (!response.ok) { const data = await response.json().catch(() => ({})) throw new Error(data?.error || 'Failed to update course') } await refreshLibrary() } const handleToggleFavorite = async () => { try { await patchCourse({ favorited: !course.favorited }) } catch (error) { console.error('Toggle favorite failed:', error) window.alert('Sorry, the pin did not stick. The favorite goblin dropped it.') } } const startQuickAction = (mode: 'tag' | 'category') => { setQuickAction(mode) setQuickActionValue('') setQuickActionError(null) } const submitQuickAction = async () => { if (!quickAction) return const trimmed = quickActionValue.trim().replace(/\s+/g, ' ') if (!trimmed) { setQuickActionError(quickAction === 'tag' ? 'Type a tag first.' : 'Type a category first.') return } try { await patchCourse({ tagId: quickAction === 'category' ? `category:${trimmed}` : trimmed }) setQuickAction(null) setQuickActionValue('') setQuickActionError(null) } catch (error) { console.error(`${quickAction} add failed:`, error) setQuickActionError(quickAction === 'tag' ? 'Tag did not stick. Try again.' : 'Category did not stick. Try again.') } } const handleRename = async () => { const nextName = window.prompt('Rename course', courseTitle) if (nextName === null) return const trimmed = nextName.trim() if (!trimmed || trimmed === courseTitle) return try { const response = await fetch(`/api/courses/${course.slug}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ displayName: trimmed }), }) if (!response.ok) { throw new Error('Failed to rename course') } await refreshLibrary() } catch (error) { console.error('Rename course failed:', error) window.alert('Sorry, the rename did not stick. The little gremlin in the API tripped.') } } const handleDelete = async (scope: 'library' | 'disk') => { const isDiskDelete = scope === 'disk' const confirmationMessage = isDiskDelete ? `Delete "${courseTitle}" from disk?\n\nThis permanently removes the course folder and all library records. This cannot be undone.` : `Delete "${courseTitle}" from the library?\n\nThis hides the course from the app but keeps its files on disk.` const confirmed = window.confirm(confirmationMessage) if (!confirmed) return try { const response = await fetch(`/api/courses/${course.slug}?scope=${scope}`, { method: 'DELETE', }) if (!response.ok) { throw new Error(`Failed to delete course (${scope})`) } await refreshLibrary() } catch (error) { console.error('Delete course failed:', error) window.alert( isDiskDelete ? 'Sorry, the disk delete did not stick. The server bumped into the filesystem goblin.' : 'Sorry, the library delete did not stick. The server sneezed on the request.' ) } } const clearQuiz = async () => { const confirmed = window.confirm(`Clear all quizzes for "${courseTitle}"? This cannot be undone.`) if (!confirmed) return setQuizStatus('running') setQuizMessage('Clearing quizzes...') try { const response = await fetch(`/api/courses/${course.slug}/quiz`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'clear', module: '__all__' }), }) const data = await response.json() if (!response.ok) { throw new Error(data?.error || 'Failed to clear quizzes') } await onChanged?.() router.refresh() setQuizStatus('success') setQuizMessage(`Cleared all quizzes for ${courseTitle}`) } catch (error) { console.error('Clear quiz failed:', error) setQuizStatus('error') setQuizMessage(error instanceof Error ? error.message : 'Failed to clear quizzes.') } } const handleRegenerateQuiz = async () => { setQuizStatus('running') setQuizMessage('Regenerating quizzes with QuizAPI...') try { const response = await fetch(`/api/courses/${course.slug}/quiz`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'regenerate', source: 'quizapi', difficulty: 'medium' }), }) const data = await response.json() if (!response.ok) { throw new Error(data?.error || 'Failed to regenerate quizzes') } const regenerated = data.results?.filter((item: { status: string }) => item.status === 'ok').length await onChanged?.() router.refresh() setQuizStatus('success') setQuizMessage(`QuizAPI regenerated ${regenerated ?? 'the'} module quiz set.`) } catch (error) { console.error('Regenerate quiz failed:', error) setQuizStatus('error') setQuizMessage(error instanceof Error ? error.message : 'Quiz regeneration failed.') } } return (
{course.favorited ? 'Unpin favorite' : 'Pin favorite'} { event.preventDefault(); startQuickAction('tag') }} className="gap-2"> Add tag { event.preventDefault(); startQuickAction('category') }} className="gap-2"> Add category {quickAction && (
event.stopPropagation()}> setQuickActionValue(event.target.value)} onKeyDown={(event) => { event.stopPropagation() if (event.key === 'Enter') submitQuickAction() if (event.key === 'Escape') setQuickAction(null) }} placeholder={quickAction === 'tag' ? 'e.g. linux' : 'e.g. Cloud'} className="mb-2 h-8 w-full rounded-md border border-input bg-background px-2 text-sm outline-none focus:ring-2 focus:ring-primary/40" autoFocus /> {quickActionError &&

{quickActionError}

}
)} {quizStatus === 'running' ? : } {quizStatus === 'running' ? 'Clearing...' : 'Clear Quiz'} {quizStatus === 'running' ? : } {quizStatus === 'running' ? 'Regenerating...' : 'Regenerate Quiz'} Rename handleDelete('library')} className="gap-2 text-destructive focus:text-destructive"> Delete from library handleDelete('disk')} className="gap-2 text-destructive focus:text-destructive"> Delete from disk
Open {courseTitle} {/* Cover: local image -> deterministic gradient -> legacy CSS cover */}
{course.thumbnail ? ( {courseTitle} { const target = event.currentTarget target.style.display = 'none' }} /> ) : (

{courseTitle}

)} {/* Play overlay */}
{/* Quiz regeneration overlay */} {quizStatus === 'running' && (
Regenerating quiz with QuizAPI...
)} {/* Progress overlay */} {percentage > 0 && (
)} {/* Completion / favorite badges */}
{course.favorited && ( Pinned )} {percentage === 100 && total > 0 && ( Complete )}
{/* Course Type Icon */}
{CourseIcon}
{/* Content */}

{courseTitle}

{course.description && (

{course.description}

)} {visibleTags.length > 0 && (
{categoryTags.slice(0, 2).map((tag) => ( {tag.name.replace(/^category:/, '')} ))} {plainTags.slice(0, 3).map((tag) => ( {tag.name} ))} {visibleTags.length > 5 && ( +{visibleTags.length - 5} )}
)} {/* Stats with Iconoir icons */}
{_count?.modules && ( {_count.modules} modules )} {total > 0 && ( {total} lessons )}
{quizMessage && quizStatus !== 'idle' && (
{quizMessage}
)} {/* Emerald Gradient Progress Bar */} {total > 0 && (
{completed} / {total} lessons {Math.round(percentage)}%
)}
{/* Footer */} {lastWatched && ( Last: {new Date(lastWatched).toLocaleDateString()} )}
) }