Files
course-craft-service/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx
root 5241144bc5 feat: AI quiz generation per lesson + hide edit buttons for non-authors
- Generate unique quiz for each lesson using OpenRouter API
- Parse lesson content (TipTap JSON) and send to AI for question generation
- Cache quiz in database to avoid regeneration
- Hide Edit/Delete buttons if current user is not course author
- Add backendUser to auth context for proper authorization checks
- Show certificate button prominently when course is completed

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 11:04:37 +00:00

553 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useState, useMemo } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import {
ArrowLeft,
Edit,
Trash2,
ChevronLeft,
ChevronRight,
CheckCircle2,
Circle,
Lock,
BookOpen,
Clock,
GraduationCap,
ChevronDown,
ChevronUp,
Play,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context';
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
import { LessonQuiz } from '@/components/dashboard/lesson-quiz';
import { cn } from '@/lib/utils';
type Lesson = { id: string; title: string; durationMinutes?: number | null; order: number };
type Chapter = { id: string; title: string; description?: string | null; order: number; lessons: Lesson[] };
type CourseData = {
id: string;
title: string;
description?: string | null;
status: string;
authorId: string;
chapters: Chapter[];
};
export default function CoursePage() {
const params = useParams();
const router = useRouter();
const { loading: authLoading, backendUser } = useAuth();
const id = params?.id as string;
const [course, setCourse] = useState<CourseData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
const [lessonContent, setLessonContent] = useState<Record<string, unknown> | null>(null);
const [lessonContentLoading, setLessonContentLoading] = useState(false);
const [completedLessons, setCompletedLessons] = useState<Set<string>>(new Set());
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [enrollmentProgress, setEnrollmentProgress] = useState<any>(null);
const [showQuiz, setShowQuiz] = useState(false);
const [quizQuestions, setQuizQuestions] = useState<any[]>([]);
const [generatingCertificate, setGeneratingCertificate] = useState(false);
// Flat list of all lessons
const flatLessons = useMemo(() => {
if (!course) return [];
return course.chapters
.sort((a, b) => a.order - b.order)
.flatMap((ch) =>
ch.lessons.sort((a, b) => a.order - b.order).map((l) => ({ ...l, chapterId: ch.id, chapterTitle: ch.title }))
);
}, [course]);
const currentLessonIndex = flatLessons.findIndex((l) => l.id === selectedLessonId);
const totalLessons = flatLessons.length;
const completedCount = completedLessons.size;
const progressPercent = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0;
// Load course and progress
useEffect(() => {
if (!id || authLoading) return;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const [courseData, progressData] = await Promise.all([
api.getCourse(id),
api.getEnrollmentProgress(id).catch(() => null),
]);
if (!cancelled) {
setCourse(courseData);
setEnrollmentProgress(progressData);
if (progressData?.lessons) {
const completed: Set<string> = new Set(
progressData.lessons
.filter((l: any) => l.completedAt)
.map((l: any) => String(l.lessonId))
);
setCompletedLessons(completed);
}
const first = courseData.chapters?.[0]?.lessons?.[0];
if (first) setSelectedLessonId(first.id);
setExpandedChapters(courseData.chapters.map((ch: Chapter) => ch.id));
}
} catch (e: any) {
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [id, authLoading]);
// Load lesson content
useEffect(() => {
if (!id || !selectedLessonId) {
setLessonContent(null);
return;
}
let cancelled = false;
setLessonContentLoading(true);
(async () => {
try {
const data = await api.getLesson(id, selectedLessonId);
const content = data?.content;
if (!cancelled)
setLessonContent(
typeof content === 'object' && content !== null ? (content as Record<string, unknown>) : null
);
} catch {
if (!cancelled) setLessonContent(null);
} finally {
if (!cancelled) setLessonContentLoading(false);
}
})();
return () => { cancelled = true; };
}, [id, selectedLessonId]);
const handleDelete = async () => {
if (!course?.id || deleting) return;
setDeleting(true);
try {
await api.deleteCourse(course.id);
router.push('/dashboard');
router.refresh();
} catch (e: any) {
setError(e?.message || 'Не удалось удалить курс');
} finally {
setDeleting(false);
}
};
const markComplete = async () => {
if (!selectedLessonId || !id) return;
try {
await api.completeLesson(id, selectedLessonId);
setCompletedLessons((prev) => new Set(prev).add(selectedLessonId));
} catch {
setCompletedLessons((prev) => new Set(prev).add(selectedLessonId));
}
};
const handleStartQuiz = async () => {
if (!selectedLessonId || !id) return;
try {
const quiz = await api.getLessonQuiz(id, selectedLessonId);
setQuizQuestions(quiz.questions || []);
setShowQuiz(true);
} catch {
setQuizQuestions([]);
}
};
const handleQuizComplete = async (score: number) => {
if (!selectedLessonId || !id) return;
try {
await api.submitQuizScore(id, selectedLessonId, score);
markComplete();
} catch {
markComplete();
}
};
const handleGetCertificate = async () => {
if (!id || generatingCertificate) return;
setGeneratingCertificate(true);
try {
const { certificateUrl } = await api.getCertificate(id);
window.open(certificateUrl, '_blank');
} catch {
// silent
} finally {
setGeneratingCertificate(false);
}
};
const goToNextLesson = () => {
if (currentLessonIndex < flatLessons.length - 1) {
markComplete();
setSelectedLessonId(flatLessons[currentLessonIndex + 1].id);
}
};
const goToPrevLesson = () => {
if (currentLessonIndex > 0) {
setSelectedLessonId(flatLessons[currentLessonIndex - 1].id);
}
};
const toggleChapter = (chapterId: string) => {
setExpandedChapters((prev) =>
prev.includes(chapterId)
? prev.filter((id) => id !== chapterId)
: [...prev, chapterId]
);
};
if (authLoading || loading) {
return (
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-muted-foreground">Загрузка курса...</p>
</div>
</div>
);
}
if (error || !course) {
return (
<div className="flex flex-col gap-4 p-6">
<Button variant="ghost" asChild>
<Link href="/dashboard"><ArrowLeft className="mr-2 h-4 w-4" />Назад к курсам</Link>
</Button>
<p className="text-destructive">{error || 'Курс не найден'}</p>
</div>
);
}
const activeLessonMeta = selectedLessonId
? flatLessons.find((l) => l.id === selectedLessonId)
: null;
const isAuthor = course && backendUser && course.authorId === backendUser.id;
const courseCompleted = completedCount >= totalLessons && totalLessons > 0;
return (
<div className="flex h-[calc(100vh-4rem)] -m-6 flex-col">
{/* Top bar */}
<div className="flex shrink-0 items-center justify-between border-b bg-background px-4 py-2.5 shadow-sm">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link href="/dashboard">
<ArrowLeft className="mr-1.5 h-4 w-4" />
Мои курсы
</Link>
</Button>
<div className="h-5 w-px bg-border" />
<div className="flex items-center gap-2">
<GraduationCap className="h-4 w-4 text-primary" />
<span className="font-medium truncate max-w-[300px]">{course.title}</span>
</div>
</div>
<div className="flex items-center gap-2">
{/* Progress badge */}
<div className="hidden sm:flex items-center gap-2 mr-2 px-3 py-1.5 bg-muted rounded-full">
<div className="h-2 w-2 rounded-full bg-primary" />
<span className="text-xs font-medium">{progressPercent}% пройдено</span>
</div>
{/* Certificate button - show when course completed */}
{courseCompleted && (
<Button size="sm" variant="default" onClick={handleGetCertificate} disabled={generatingCertificate}>
<GraduationCap className="mr-1.5 h-3.5 w-3.5" />
{generatingCertificate ? 'Генерация...' : 'Получить сертификат'}
</Button>
)}
{/* Edit/Delete - only for author */}
{isAuthor && (
<>
<Button size="sm" variant="outline" asChild>
<Link href={`/dashboard/courses/${course.id}/edit`}>
<Edit className="mr-1.5 h-3.5 w-3.5" />
Редактировать
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить курс?</AlertDialogTitle>
<AlertDialogDescription>
Курс «{course.title}» будет удалён безвозвратно.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => { e.preventDefault(); handleDelete(); }}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleting}
>
{deleting ? 'Удаление...' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
</div>
<div className="relative flex flex-1 min-h-0">
{/* ─── Left sidebar: course navigation ─── */}
<div
className={cn(
'border-r bg-muted/10 flex flex-col transition-all duration-300 ease-in-out',
sidebarOpen ? 'w-80 shrink-0' : 'w-0 overflow-hidden'
)}
>
{sidebarOpen && (
<div className="flex flex-col h-full">
{/* Course progress */}
<div className="p-4 border-b bg-background">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Прогресс курса</span>
<span className="text-xs font-bold text-primary">{completedCount}/{totalLessons}</span>
</div>
<Progress value={progressPercent} className="h-2" />
</div>
{/* Chapters & lessons */}
<div className="flex-1 overflow-auto py-2">
{course.chapters
.sort((a, b) => a.order - b.order)
.map((chapter, chapterIdx) => {
const isExpanded = expandedChapters.includes(chapter.id);
const chapterLessons = chapter.lessons.sort((a, b) => a.order - b.order);
const chapterComplete = chapterLessons.every((l) => completedLessons.has(l.id));
const chapterStarted = chapterLessons.some((l) => completedLessons.has(l.id));
return (
<div key={chapter.id} className="mb-1">
<button
className="flex items-center gap-2 w-full px-4 py-2.5 text-sm hover:bg-muted/50 transition-colors"
onClick={() => toggleChapter(chapter.id)}
>
<div className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold',
chapterComplete
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: chapterStarted
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground'
)}>
{chapterComplete ? <CheckCircle2 className="h-4 w-4" /> : chapterIdx + 1}
</div>
<span className="flex-1 text-left font-medium truncate">{chapter.title}</span>
{isExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
</button>
{isExpanded && (
<div className="ml-4 border-l-2 border-muted pl-2 mb-2">
{chapterLessons.map((lesson, lessonIdx) => {
const isActive = selectedLessonId === lesson.id;
const isCompleted = completedLessons.has(lesson.id);
const globalIdx = flatLessons.findIndex((l) => l.id === lesson.id);
// Lesson is locked if sequential mode: all previous must be complete
// For now, don't lock (allow free navigation)
const isLocked = false;
return (
<button
key={lesson.id}
disabled={isLocked}
className={cn(
'flex items-center gap-2.5 w-full rounded-lg px-3 py-2 text-sm transition-all',
isActive
? 'bg-primary text-primary-foreground shadow-sm'
: isLocked
? 'opacity-40 cursor-not-allowed'
: 'hover:bg-muted/80 text-foreground/80'
)}
onClick={() => !isLocked && setSelectedLessonId(lesson.id)}
>
{isCompleted ? (
<CheckCircle2 className={cn('h-4 w-4 shrink-0', isActive ? 'text-primary-foreground' : 'text-green-500')} />
) : isLocked ? (
<Lock className="h-4 w-4 shrink-0" />
) : isActive ? (
<Play className="h-4 w-4 shrink-0 fill-current" />
) : (
<Circle className="h-4 w-4 shrink-0" />
)}
<span className="truncate text-left">{lesson.title}</span>
{lesson.durationMinutes && (
<span className={cn('ml-auto text-xs shrink-0', isActive ? 'text-primary-foreground/70' : 'text-muted-foreground')}>
{lesson.durationMinutes} мин
</span>
)}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
{/* Sidebar toggle */}
<button
type="button"
className={cn(
'absolute top-4 z-10 flex h-8 w-6 items-center justify-center rounded-r-md border border-l-0 bg-background shadow-sm hover:bg-muted transition-all',
)}
style={{ left: sidebarOpen ? '19.9rem' : 0 }}
onClick={() => setSidebarOpen(!sidebarOpen)}
>
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{/* ─── Main content area ─── */}
<main className="flex-1 flex flex-col min-h-0">
{/* Lesson content */}
<div className="flex-1 overflow-auto">
<div className="max-w-3xl mx-auto w-full px-8 py-10">
{/* Chapter & lesson header */}
{activeLessonMeta && (
<div className="mb-8">
<p className="text-sm text-primary font-medium mb-1">
{activeLessonMeta.chapterTitle}
</p>
<h1 className="text-3xl font-bold text-foreground">
{activeLessonMeta.title}
</h1>
<div className="flex items-center gap-4 mt-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<BookOpen className="h-4 w-4" />
Урок {currentLessonIndex + 1} из {totalLessons}
</span>
{activeLessonMeta.durationMinutes && (
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{activeLessonMeta.durationMinutes} мин
</span>
)}
</div>
<div className="mt-4 h-px bg-border" />
</div>
)}
{/* Content */}
{lessonContentLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : selectedLessonId ? (
<>
<LessonContentViewer
content={lessonContent}
className="min-h-[400px]"
/>
{!showQuiz && !completedLessons.has(selectedLessonId) && (
<div className="mt-8 p-6 border rounded-xl bg-muted/20 text-center">
<h3 className="font-semibold mb-2">Проверьте свои знания</h3>
<p className="text-sm text-muted-foreground mb-4">
Пройдите тест, чтобы закрепить материал и получить сертификат
</p>
<Button onClick={handleStartQuiz}>Начать тест</Button>
</div>
)}
{showQuiz && (
<LessonQuiz
courseId={id}
lessonId={selectedLessonId}
questions={quizQuestions}
onComplete={handleQuizComplete}
/>
)}
</>
) : (
<div className="flex flex-col items-center justify-center py-20 text-center">
<BookOpen className="h-12 w-12 text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground">Выберите урок для начала обучения</p>
</div>
)}
</div>
</div>
{/* Bottom navigation */}
<div className="shrink-0 border-t bg-background px-6 py-3">
<div className="max-w-3xl mx-auto flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={goToPrevLesson}
disabled={currentLessonIndex <= 0}
>
<ChevronLeft className="mr-1.5 h-4 w-4" />
Предыдущий
</Button>
<Button
variant="ghost"
size="sm"
onClick={markComplete}
disabled={!selectedLessonId || completedLessons.has(selectedLessonId)}
className={cn(
completedLessons.has(selectedLessonId ?? '')
? 'text-green-600'
: 'text-muted-foreground hover:text-primary'
)}
>
<CheckCircle2 className="mr-1.5 h-4 w-4" />
{completedLessons.has(selectedLessonId ?? '') ? 'Пройден' : 'Отметить пройденным'}
</Button>
{currentLessonIndex < flatLessons.length - 1 ? (
<Button size="sm" onClick={goToNextLesson}>
Следующий урок
<ChevronRight className="ml-1.5 h-4 w-4" />
</Button>
) : (
<div className="text-sm text-muted-foreground">
{courseCompleted ? 'Курс пройден!' : 'Последний урок'}
</div>
)}
</div>
</div>
</main>
</div>
</div>
);
}