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 ( return (
<div className="flex flex-1"> <div className="flex flex-1">
<CourseSidebar course={course} /> <CourseSidebar course={course} completedLessonIds={completedLessonIds} />
<main className="flex-1 min-w-0 overflow-y-auto"> <main className="flex-1 min-w-0 overflow-y-auto">
{children} {children}
</main> </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 { auth } from "@/lib/auth";
import { KinescopePlayer } from "@/components/player/kinescope-player"; import { KinescopePlayer } from "@/components/player/kinescope-player";
import { LessonContent } from "@/components/student/lesson-content"; import { LessonContent } from "@/components/student/lesson-content";
import { LessonCompleteButton } from "@/components/student/lesson-complete-button";
interface Props { interface Props {
params: Promise<{ slug: string; lessonId: string }>; params: Promise<{ slug: string; lessonId: string }>;
@@ -16,7 +17,8 @@ export default async function LessonPage({ params }: Props) {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
const isAdmin = session?.user.role === "admin"; const isAdmin = session?.user.role === "admin";
const lesson = await prisma.lesson.findUnique({ const [lesson, progress] = await Promise.all([
prisma.lesson.findUnique({
where: { id: lessonId, ...(isAdmin ? {} : { published: true }) }, where: { id: lessonId, ...(isAdmin ? {} : { published: true }) },
include: { include: {
files: { orderBy: { createdAt: "asc" } }, files: { orderBy: { createdAt: "asc" } },
@@ -39,10 +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(); if (!lesson || lesson.module.course.slug !== slug) notFound();
const isCompleted = !!progress;
// Build ordered flat list of all lessons for prev/next // Build ordered flat list of all lessons for prev/next
const allLessons = lesson.module.course.modules.flatMap((m) => m.lessons); const allLessons = lesson.module.course.modules.flatMap((m) => m.lessons);
const idx = allLessons.findIndex((l) => l.id === lessonId); const idx = allLessons.findIndex((l) => l.id === lessonId);
@@ -108,7 +118,7 @@ export default async function LessonPage({ params }: Props) {
</div> </div>
)} )}
{/* Prev / Next navigation */} {/* Complete button + Prev/Next navigation */}
<div <div
className="flex items-center justify-between pt-6 mt-6" className="flex items-center justify-between pt-6 mt-6"
style={{ borderTop: "2px solid var(--border)" }} style={{ borderTop: "2px solid var(--border)" }}
@@ -116,23 +126,28 @@ export default async function LessonPage({ params }: Props) {
{prevLesson ? ( {prevLesson ? (
<Link <Link
href={`/courses/${slug}/lessons/${prevLesson.id}`} href={`/courses/${slug}/lessons/${prevLesson.id}`}
className="btn-aubade text-sm max-w-[45%]" className="btn-aubade text-sm max-w-[40%]"
> >
{prevLesson.title} {prevLesson.title}
</Link> </Link>
) : ( ) : (
<div /> <div />
)} )}
{!isAdmin && (
<LessonCompleteButton lessonId={lessonId} slug={slug} isCompleted={isCompleted} />
)}
{nextLesson ? ( {nextLesson ? (
<Link <Link
href={`/courses/${slug}/lessons/${nextLesson.id}`} 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} {nextLesson.title}
</Link> </Link>
) : ( ) : (
<div className="text-sm" style={{ color: "var(--muted-foreground)" }}> <div className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Последний урок курса {isAdmin ? "Последний урок курса" : ""}
</div> </div>
)} )}
</div> </div>
+57 -10
View File
@@ -14,7 +14,12 @@ export default async function StudentDashboard() {
course: { course: {
include: { include: {
modules: { modules: {
include: { _count: { select: { lessons: true } } }, include: {
lessons: {
where: { published: true },
select: { id: true },
},
},
}, },
_count: { select: { modules: 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 active = enrollments.filter((e) => !e.expiresAt || e.expiresAt > now);
const expired = 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 ( return (
<main className="max-w-4xl mx-auto px-6 py-10 w-full"> <main className="max-w-4xl mx-auto px-6 py-10 w-full">
<h1 className="text-2xl font-bold mb-1">Мои курсы</h1> <h1 className="text-2xl font-bold mb-1">Мои курсы</h1>
@@ -45,7 +62,14 @@ export default async function StudentDashboard() {
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{active.map(({ course, expiresAt }) => { {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 ( return (
<Link <Link
key={course.id} key={course.id}
@@ -60,23 +84,46 @@ export default async function StudentDashboard() {
📚 📚
</div> </div>
)} )}
<div className="p-4 flex-1"> <div className="p-4 flex-1 flex flex-col gap-2">
<h2 className="font-bold text-base leading-tight">{course.title}</h2> <h2 className="font-bold text-base leading-tight">{course.title}</h2>
{course.description && ( {course.description && (
<p className="text-xs mt-1 line-clamp-2" style={{ color: "var(--muted-foreground)" }}> <p className="text-xs line-clamp-2" style={{ color: "var(--muted-foreground)" }}>
{course.description} {course.description}
</p> </p>
)} )}
<div className="flex items-center justify-between mt-3">
{/* Progress bar */}
{totalLessons > 0 && (
<div className="mt-auto">
<div className="flex items-center justify-between mb-1">
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}> <span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{course._count.modules} модулей · {totalLessons} уроков {completedLessons} из {totalLessons} уроков
</span> </span>
{expiresAt && ( <span
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}> className="text-xs font-bold"
до {new Date(expiresAt).toLocaleDateString("ru-RU")} style={{ color: progressPct === 100 ? "var(--foreground)" : "var(--muted-foreground)" }}
>
{progressPct === 100 ? "✓ Завершён" : `${progressPct}%`}
</span> </span>
)}
</div> </div>
<div className="h-1.5 w-full" style={{ background: "var(--border)" }}>
<div
className="h-full transition-all"
style={{
width: `${progressPct}%`,
background: progressPct === 100 ? "var(--foreground)" : "var(--accent)",
border: progressPct > 0 ? "1px solid var(--foreground)" : "none",
}}
/>
</div>
</div>
)}
{expiresAt && (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Доступ до {new Date(expiresAt).toLocaleDateString("ru-RU")}
</p>
)}
</div> </div>
</Link> </Link>
); );
+69 -10
View File
@@ -21,10 +21,22 @@ interface Course {
modules: Module[]; modules: Module[];
} }
export function CourseSidebar({ course }: { course: Course }) { export function CourseSidebar({
course,
completedLessonIds = new Set(),
}: {
course: Course;
completedLessonIds?: Set<string>;
}) {
const pathname = usePathname(); const pathname = usePathname();
const [open, setOpen] = useState(true); 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 ( return (
<> <>
{/* Mobile toggle */} {/* Mobile toggle */}
@@ -45,54 +57,101 @@ export function CourseSidebar({ course }: { course: Course }) {
top: "53px", top: "53px",
}} }}
> >
{/* Course title */} {/* Course title + progress */}
<div className="px-4 py-4" style={{ borderBottom: "2px solid var(--border)" }}> <div className="px-4 py-4" style={{ borderBottom: "2px solid var(--border)" }}>
<Link <Link
href={`/courses/${course.slug}`} href={`/courses/${course.slug}`}
className="font-bold text-sm leading-snug block" className="font-bold text-sm leading-snug block mb-1"
style={{ color: "var(--foreground)" }} style={{ color: "var(--foreground)" }}
> >
{course.title} {course.title}
</Link> </Link>
<Link <Link
href="/dashboard" href="/dashboard"
className="text-xs mt-1 block underline" className="text-xs block underline mb-3"
style={{ color: "var(--muted-foreground)" }} style={{ color: "var(--muted-foreground)" }}
> >
Все курсы Все курсы
</Link> </Link>
{totalLessons > 0 && (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{completedCount} из {totalLessons} уроков
</span>
<span className="text-xs font-bold" style={{ color: "var(--foreground)" }}>
{progressPct}%
</span>
</div>
<div
className="h-1.5 w-full"
style={{ background: "var(--border)" }}
>
<div
className="h-full transition-all duration-300"
style={{
width: `${progressPct}%`,
background: progressPct === 100 ? "var(--foreground)" : "var(--accent)",
border: progressPct > 0 ? "1px solid var(--foreground)" : "none",
}}
/>
</div>
</div>
)}
</div> </div>
{/* Modules and lessons */} {/* Modules and lessons */}
<nav className="flex-1 py-2"> <nav className="flex-1 py-2">
{course.modules.map((mod) => ( {course.modules.map((mod) => {
const modCompleted = mod.lessons.filter((l) => completedLessonIds.has(l.id)).length;
return (
<div key={mod.id}> <div key={mod.id}>
<div className="flex items-center justify-between px-4 py-2">
<p <p
className="px-4 py-2 text-xs font-bold uppercase tracking-widest" className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }} style={{ color: "var(--muted-foreground)" }}
> >
{mod.title} {mod.title}
</p> </p>
{mod.lessons.length > 0 && (
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{modCompleted}/{mod.lessons.length}
</span>
)}
</div>
{mod.lessons.map((lesson) => { {mod.lessons.map((lesson) => {
const active = pathname.includes(lesson.id); const active = pathname.includes(lesson.id);
const done = completedLessonIds.has(lesson.id);
return ( return (
<Link <Link
key={lesson.id} key={lesson.id}
href={`/courses/${course.slug}/lessons/${lesson.id}`} href={`/courses/${course.slug}/lessons/${lesson.id}`}
className="block px-4 py-2 text-sm leading-snug border-l-2 transition-colors" className="flex items-center gap-2 px-4 py-2 text-sm leading-snug border-l-2 transition-colors"
style={{ style={{
borderLeftColor: active ? "var(--foreground)" : "transparent", borderLeftColor: active ? "var(--foreground)" : "transparent",
backgroundColor: active ? "var(--color-highlight)" : "transparent", backgroundColor: active ? "var(--color-highlight)" : "transparent",
color: active ? "var(--foreground)" : "var(--foreground)",
fontWeight: active ? 600 : 400, fontWeight: active ? 600 : 400,
color: done && !active ? "var(--muted-foreground)" : "var(--foreground)",
}} }}
> >
{lesson.title} <span
className="shrink-0 w-4 h-4 flex items-center justify-center text-xs"
style={{
border: `1.5px solid ${done ? "var(--foreground)" : "var(--border)"}`,
background: done ? "var(--foreground)" : "transparent",
color: "var(--background)",
}}
>
{done && "✓"}
</span>
<span className="flex-1 leading-snug">{lesson.title}</span>
</Link> </Link>
); );
})} })}
</div> </div>
))} );
})}
</nav> </nav>
</aside> </aside>
</> </>
@@ -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 (
<button
onClick={() => startTransition(() => toggleLessonProgress(lessonId, slug))}
disabled={pending}
className={`btn-aubade ${isCompleted ? "btn-aubade-accent" : ""} flex items-center gap-2 px-5 py-2.5 text-sm`}
style={{ opacity: pending ? 0.6 : 1 }}
>
<Check size={15} strokeWidth={isCompleted ? 3 : 2} />
{pending ? "..." : isCompleted ? "Пройдено" : "Отметить как пройденный"}
</button>
);
}