diff --git a/src/app/(student)/courses/[slug]/layout.tsx b/src/app/(student)/courses/[slug]/layout.tsx index 0a3083f..5afcdff 100644 --- a/src/app/(student)/courses/[slug]/layout.tsx +++ b/src/app/(student)/courses/[slug]/layout.tsx @@ -47,9 +47,20 @@ export default async function CourseLayout({ children, params }: Props) { } } + // Fetch completed lesson IDs for this user + const allLessonIds = course.modules.flatMap((m) => m.lessons.map((l) => l.id)); + const progressRecords = isAdmin + ? [] + : await prisma.lessonProgress.findMany({ + where: { userId: session.user.id, lessonId: { in: allLessonIds } }, + select: { lessonId: true }, + }); + + const completedLessonIds = new Set(progressRecords.map((p) => p.lessonId)); + return (
- +
{children}
diff --git a/src/app/(student)/courses/[slug]/lessons/[lessonId]/actions.ts b/src/app/(student)/courses/[slug]/lessons/[lessonId]/actions.ts new file mode 100644 index 0000000..ed03c4b --- /dev/null +++ b/src/app/(student)/courses/[slug]/lessons/[lessonId]/actions.ts @@ -0,0 +1,29 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { revalidatePath } from "next/cache"; + +export async function toggleLessonProgress(lessonId: string, slug: string) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) throw new Error("Unauthorized"); + + const existing = await prisma.lessonProgress.findUnique({ + where: { userId_lessonId: { userId: session.user.id, lessonId } }, + }); + + if (existing) { + await prisma.lessonProgress.delete({ + where: { userId_lessonId: { userId: session.user.id, lessonId } }, + }); + } else { + await prisma.lessonProgress.create({ + data: { userId: session.user.id, lessonId }, + }); + } + + revalidatePath(`/courses/${slug}/lessons/${lessonId}`); + revalidatePath(`/courses/${slug}`); + revalidatePath("/dashboard"); +} diff --git a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx index 087b155..2060012 100644 --- a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx +++ b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx @@ -5,6 +5,7 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth"; import { KinescopePlayer } from "@/components/player/kinescope-player"; import { LessonContent } from "@/components/student/lesson-content"; +import { LessonCompleteButton } from "@/components/student/lesson-complete-button"; interface Props { params: Promise<{ slug: string; lessonId: string }>; @@ -16,21 +17,23 @@ export default async function LessonPage({ params }: Props) { const session = await auth.api.getSession({ headers: await headers() }); const isAdmin = session?.user.role === "admin"; - const lesson = await prisma.lesson.findUnique({ - where: { id: lessonId, ...(isAdmin ? {} : { published: true }) }, - include: { - files: { orderBy: { createdAt: "asc" } }, - module: { - include: { - course: { - include: { - modules: { - orderBy: { order: "asc" }, - include: { - lessons: { - where: { published: true }, - orderBy: { order: "asc" }, - select: { id: true, title: true }, + const [lesson, progress] = await Promise.all([ + prisma.lesson.findUnique({ + where: { id: lessonId, ...(isAdmin ? {} : { published: true }) }, + include: { + files: { orderBy: { createdAt: "asc" } }, + module: { + include: { + course: { + include: { + modules: { + orderBy: { order: "asc" }, + include: { + lessons: { + where: { published: true }, + orderBy: { order: "asc" }, + select: { id: true, title: true }, + }, }, }, }, @@ -38,11 +41,18 @@ export default async function LessonPage({ params }: Props) { }, }, }, - }, - }); + }), + session && !isAdmin + ? prisma.lessonProgress.findUnique({ + where: { userId_lessonId: { userId: session.user.id, lessonId } }, + }) + : null, + ]); if (!lesson || lesson.module.course.slug !== slug) notFound(); + const isCompleted = !!progress; + // Build ordered flat list of all lessons for prev/next const allLessons = lesson.module.course.modules.flatMap((m) => m.lessons); const idx = allLessons.findIndex((l) => l.id === lessonId); @@ -108,7 +118,7 @@ export default async function LessonPage({ params }: Props) {
)} - {/* Prev / Next navigation */} + {/* Complete button + Prev/Next navigation */}
← {prevLesson.title} ) : (
)} + + {!isAdmin && ( + + )} + {nextLesson ? ( {nextLesson.title} → ) : (
- Последний урок курса + {isAdmin ? "Последний урок курса" : ""}
)}
diff --git a/src/app/(student)/dashboard/page.tsx b/src/app/(student)/dashboard/page.tsx index bab860f..fd54f9d 100644 --- a/src/app/(student)/dashboard/page.tsx +++ b/src/app/(student)/dashboard/page.tsx @@ -14,7 +14,12 @@ export default async function StudentDashboard() { course: { include: { modules: { - include: { _count: { select: { lessons: true } } }, + include: { + lessons: { + where: { published: true }, + select: { id: true }, + }, + }, }, _count: { select: { modules: true } }, }, @@ -27,6 +32,18 @@ export default async function StudentDashboard() { const active = enrollments.filter((e) => !e.expiresAt || e.expiresAt > now); const expired = enrollments.filter((e) => e.expiresAt && e.expiresAt <= now); + // Fetch progress for all lessons in active courses + const allLessonIds = active.flatMap((e) => + e.course.modules.flatMap((m) => m.lessons.map((l) => l.id)) + ); + const progressRecords = allLessonIds.length > 0 + ? await prisma.lessonProgress.findMany({ + where: { userId: session.user.id, lessonId: { in: allLessonIds } }, + select: { lessonId: true }, + }) + : []; + const completedSet = new Set(progressRecords.map((p) => p.lessonId)); + return (

Мои курсы

@@ -45,7 +62,14 @@ export default async function StudentDashboard() { ) : (
{active.map(({ course, expiresAt }) => { - const totalLessons = course.modules.reduce((s, m) => s + m._count.lessons, 0); + const totalLessons = course.modules.reduce((s, m) => s + m.lessons.length, 0); + const completedLessons = course.modules + .flatMap((m) => m.lessons) + .filter((l) => completedSet.has(l.id)).length; + const progressPct = totalLessons > 0 + ? Math.round((completedLessons / totalLessons) * 100) + : 0; + return ( )} -
+

{course.title}

{course.description && ( -

+

{course.description}

)} -
- - {course._count.modules} модулей · {totalLessons} уроков - - {expiresAt && ( - - до {new Date(expiresAt).toLocaleDateString("ru-RU")} - - )} -
+ + {/* Progress bar */} + {totalLessons > 0 && ( +
+
+ + {completedLessons} из {totalLessons} уроков + + + {progressPct === 100 ? "✓ Завершён" : `${progressPct}%`} + +
+
+
0 ? "1px solid var(--foreground)" : "none", + }} + /> +
+
+ )} + + {expiresAt && ( +

+ Доступ до {new Date(expiresAt).toLocaleDateString("ru-RU")} +

+ )}
); diff --git a/src/components/student/course-sidebar.tsx b/src/components/student/course-sidebar.tsx index 5fc060f..bdb52dd 100644 --- a/src/components/student/course-sidebar.tsx +++ b/src/components/student/course-sidebar.tsx @@ -21,10 +21,22 @@ interface Course { modules: Module[]; } -export function CourseSidebar({ course }: { course: Course }) { +export function CourseSidebar({ + course, + completedLessonIds = new Set(), +}: { + course: Course; + completedLessonIds?: Set; +}) { const pathname = usePathname(); const [open, setOpen] = useState(true); + const totalLessons = course.modules.reduce((s, m) => s + m.lessons.length, 0); + const completedCount = course.modules + .flatMap((m) => m.lessons) + .filter((l) => completedLessonIds.has(l.id)).length; + const progressPct = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0; + return ( <> {/* Mobile toggle */} @@ -45,54 +57,101 @@ export function CourseSidebar({ course }: { course: Course }) { top: "53px", }} > - {/* Course title */} + {/* Course title + progress */}
{course.title} ← Все курсы + + {totalLessons > 0 && ( +
+
+ + {completedCount} из {totalLessons} уроков + + + {progressPct}% + +
+
+
0 ? "1px solid var(--foreground)" : "none", + }} + /> +
+
+ )}
{/* Modules and lessons */} diff --git a/src/components/student/lesson-complete-button.tsx b/src/components/student/lesson-complete-button.tsx new file mode 100644 index 0000000..8bb48fe --- /dev/null +++ b/src/components/student/lesson-complete-button.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useTransition } from "react"; +import { Check } from "lucide-react"; +import { toggleLessonProgress } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/actions"; + +export function LessonCompleteButton({ + lessonId, + slug, + isCompleted, +}: { + lessonId: string; + slug: string; + isCompleted: boolean; +}) { + const [pending, startTransition] = useTransition(); + + return ( + + ); +}