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
+90 -31
View File
@@ -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<string>;
}) {
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 */}
<div className="px-4 py-4" style={{ borderBottom: "2px solid var(--border)" }}>
<Link
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)" }}
>
{course.title}
</Link>
<Link
href="/dashboard"
className="text-xs mt-1 block underline"
className="text-xs block underline mb-3"
style={{ color: "var(--muted-foreground)" }}
>
Все курсы
</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>
{/* Modules and lessons */}
<nav className="flex-1 py-2">
{course.modules.map((mod) => (
<div key={mod.id}>
<p
className="px-4 py-2 text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
{mod.title}
</p>
{mod.lessons.map((lesson) => {
const active = pathname.includes(lesson.id);
return (
<Link
key={lesson.id}
href={`/courses/${course.slug}/lessons/${lesson.id}`}
className="block px-4 py-2 text-sm leading-snug border-l-2 transition-colors"
style={{
borderLeftColor: active ? "var(--foreground)" : "transparent",
backgroundColor: active ? "var(--color-highlight)" : "transparent",
color: active ? "var(--foreground)" : "var(--foreground)",
fontWeight: active ? 600 : 400,
}}
{course.modules.map((mod) => {
const modCompleted = mod.lessons.filter((l) => completedLessonIds.has(l.id)).length;
return (
<div key={mod.id}>
<div className="flex items-center justify-between px-4 py-2">
<p
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
{lesson.title}
</Link>
);
})}
</div>
))}
{mod.title}
</p>
{mod.lessons.length > 0 && (
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{modCompleted}/{mod.lessons.length}
</span>
)}
</div>
{mod.lessons.map((lesson) => {
const active = pathname.includes(lesson.id);
const done = completedLessonIds.has(lesson.id);
return (
<Link
key={lesson.id}
href={`/courses/${course.slug}/lessons/${lesson.id}`}
className="flex items-center gap-2 px-4 py-2 text-sm leading-snug border-l-2 transition-colors"
style={{
borderLeftColor: active ? "var(--foreground)" : "transparent",
backgroundColor: active ? "var(--color-highlight)" : "transparent",
fontWeight: active ? 600 : 400,
color: done && !active ? "var(--muted-foreground)" : "var(--foreground)",
}}
>
<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>
);
})}
</div>
);
})}
</nav>
</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>
);
}