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 (
+
+ );
+}