Files
lms-sb/src/components/student/course-sidebar.tsx
T
adminsandClaude Opus 5.5 197bbb80ec Limit motion to one animated element per page (DS-2)
- Course navigation: only the completion percent animates; the
  "N of M lessons" counter is static. NumberFlow is enabled only in the
  visible copy of the navigation (drawer on phones, sidebar otherwise).
- globals.css: disable Vaul drawer and overlay animation under
  prefers-reduced-motion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:26:44 +05:00

236 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { Menu, X } from "lucide-react";
import NumberFlow from "@number-flow/react";
import { AubadeDrawer, ResponsiveDialogTitle } from "@/components/shared/responsive-dialog";
import { useIsMobile } from "@/hooks/use-media-query";
interface Lesson {
id: string;
title: string;
}
interface Module {
id: string;
title: string;
lessons: Lesson[];
}
interface Course {
slug: string;
title: string;
modules: Module[];
}
function CourseNavBody({
course,
completedLessonIds,
pathname,
onNavigate,
animateProgress,
}: {
course: Course;
completedLessonIds: Set<string>;
pathname: string;
onNavigate: () => void;
/** DS-2: one animated moment per page — only the visible copy animates. */
animateProgress: boolean;
}) {
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 (
<>
{/* Course title + progress */}
<div className="px-4 py-4" style={{ borderBottom: "2px solid var(--border)" }}>
<Link
href={`/courses/${course.slug}`}
onClick={onNavigate}
className="font-bold text-sm leading-snug block mb-1"
style={{ color: "var(--foreground)" }}
>
{course.title}
</Link>
<Link
href="/dashboard"
onClick={onNavigate}
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)" }}>
{/* The page's single animated moment (DS-2 §8); static under reduced motion. */}
<NumberFlow
value={progressPct}
suffix="%"
animated={animateProgress}
respectMotionPreference
/>
</span>
</div>
<div
className="h-1.5 w-full"
style={{ background: "var(--border)" }}
>
<div
className="h-full transition-all duration-300 motion-reduce:transition-none"
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) => {
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)" }}
>
{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}`}
onClick={() => onNavigate()}
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>
</>
);
}
export function CourseSidebar({
course,
completedLessonIds = new Set(),
}: {
course: Course;
completedLessonIds?: Set<string>;
}) {
const pathname = usePathname();
// Tablet (768–1023px): the old off-canvas panel. Phone (<768px): bottom sheet.
const [open, setOpen] = useState(false);
const [sheetOpen, setSheetOpen] = useState(false);
const isMobile = useIsMobile();
const menuOpen = isMobile ? sheetOpen : open;
return (
<>
{/* Mobile hamburger */}
<button
className="lg:hidden fixed top-3 left-3 z-50 p-2 rounded"
style={{
backgroundColor: "var(--background)",
border: "1.5px solid var(--border)",
color: "var(--foreground)",
}}
onClick={() => (isMobile ? setSheetOpen((v) => !v) : setOpen((v) => !v))}
aria-label="Навигация по курсу"
aria-expanded={menuOpen}
>
{menuOpen ? <X size={20} /> : <Menu size={20} />}
</button>
{/* Overlay */}
{open && !isMobile && (
<div
className="lg:hidden fixed inset-0 bg-black/50 z-30"
onClick={() => setOpen(false)}
/>
)}
<aside
className={[
"w-64 flex flex-col overflow-y-auto",
"fixed top-[53px] left-0 bottom-0 z-40 transition-transform duration-200",
"lg:sticky lg:shrink-0 lg:translate-x-0",
open && !isMobile ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
].join(" ")}
style={{
borderRight: "2px solid var(--border)",
backgroundColor: "var(--background)",
maxHeight: "calc(100vh - 53px)",
top: "53px",
}}
>
<CourseNavBody
course={course}
completedLessonIds={completedLessonIds}
pathname={pathname}
onNavigate={() => setOpen(false)}
animateProgress={!isMobile}
/>
</aside>
{isMobile && (
<AubadeDrawer open={sheetOpen} onOpenChange={setSheetOpen} className="px-0 pt-0">
<ResponsiveDialogTitle className="sr-only">Навигация по курсу</ResponsiveDialogTitle>
<CourseNavBody
course={course}
completedLessonIds={completedLessonIds}
pathname={pathname}
onNavigate={() => setSheetOpen(false)}
animateProgress
/>
</AubadeDrawer>
)}
</>
);
}