Add lesson progress tracking

- Toggle lesson completion via server action (LessonProgress table)
- "Отметить как пройденный" button on lesson page, turns accent when done
- Course sidebar: progress bar, checkmarks on completed lessons, X/Y counter per module
- Dashboard: progress bar on each course card with completion percentage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 13:16:28 +05:00
parent c88b5d2004
commit d0c8c6dd53
6 changed files with 257 additions and 67 deletions
+12 -1
View File
@@ -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 (
<div className="flex flex-1">
<CourseSidebar course={course} />
<CourseSidebar course={course} completedLessonIds={completedLessonIds} />
<main className="flex-1 min-w-0 overflow-y-auto">
{children}
</main>
@@ -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");
}
@@ -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) {
</div>
)}
{/* Prev / Next navigation */}
{/* Complete button + Prev/Next navigation */}
<div
className="flex items-center justify-between pt-6 mt-6"
style={{ borderTop: "2px solid var(--border)" }}
@@ -116,23 +126,28 @@ export default async function LessonPage({ params }: Props) {
{prevLesson ? (
<Link
href={`/courses/${slug}/lessons/${prevLesson.id}`}
className="btn-aubade text-sm max-w-[45%]"
className="btn-aubade text-sm max-w-[40%]"
>
{prevLesson.title}
</Link>
) : (
<div />
)}
{!isAdmin && (
<LessonCompleteButton lessonId={lessonId} slug={slug} isCompleted={isCompleted} />
)}
{nextLesson ? (
<Link
href={`/courses/${slug}/lessons/${nextLesson.id}`}
className="btn-aubade btn-aubade-accent text-sm max-w-[45%] text-right"
className="btn-aubade btn-aubade-accent text-sm max-w-[40%] text-right"
>
{nextLesson.title}
</Link>
) : (
<div className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Последний урок курса
{isAdmin ? "Последний урок курса" : ""}
</div>
)}
</div>