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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user