Files
lms-sb/src/components/student/course-sidebar.tsx
T
adminsandClaude Opus 5.5 09b069cbb5 Add admin command menu, mobile drawer and animated course progress
- cmdk 1.1.1: Cmd/Ctrl+K command menu in the admin panel (students,
  courses, lessons, admin sections) plus a "Search" button in the sidebar.
  New read-only endpoint GET /api/admin/search with the same admin role
  check as /api/admin/export-users; query length 2-100 chars.
- vaul 1.1.2: ResponsiveDialog wrapper (Base UI Dialog on desktop, bottom
  drawer below 768px) styled per DS-2. Used for the storefront checkout on
  the dashboard and for course navigation on phones (tablet and desktop
  unchanged).
- @number-flow/react 0.6.2: animated "N of M lessons" and completion
  percent in course navigation; respects prefers-reduced-motion.

No migrations. Not deployed.

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

227 lines
7.5 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, { NumberFlowGroup } 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,
}: {
course: Course;
completedLessonIds: Set<string>;
pathname: string;
onNavigate: () => void;
}) {
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">
<NumberFlowGroup>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
<NumberFlow value={completedCount} respectMotionPreference /> из {totalLessons} уроков
</span>
<span className="text-xs font-bold" style={{ color: "var(--foreground)" }}>
<NumberFlow value={progressPct} suffix="%" respectMotionPreference />
</span>
</NumberFlowGroup>
</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)}
/>
</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)}
/>
</AubadeDrawer>
)}
</>
);
}