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>
This commit is contained in:
@@ -4,6 +4,9 @@ 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;
|
||||
@@ -22,6 +25,130 @@ interface Course {
|
||||
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(),
|
||||
@@ -30,13 +157,11 @@ export function CourseSidebar({
|
||||
completedLessonIds?: Set<string>;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
// Tablet (768–1023px): the old off-canvas panel. Phone (<768px): bottom sheet.
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
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;
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const menuOpen = isMobile ? sheetOpen : open;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -48,14 +173,15 @@ export function CourseSidebar({
|
||||
border: "1.5px solid var(--border)",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={() => (isMobile ? setSheetOpen((v) => !v) : setOpen((v) => !v))}
|
||||
aria-label="Навигация по курсу"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
{open ? <X size={20} /> : <Menu size={20} />}
|
||||
{menuOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Overlay */}
|
||||
{open && (
|
||||
{open && !isMobile && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black/50 z-30"
|
||||
onClick={() => setOpen(false)}
|
||||
@@ -67,7 +193,7 @@ export function CourseSidebar({
|
||||
"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 ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
|
||||
open && !isMobile ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
|
||||
].join(" ")}
|
||||
style={{
|
||||
borderRight: "2px solid var(--border)",
|
||||
@@ -76,104 +202,25 @@ export function CourseSidebar({
|
||||
top: "53px",
|
||||
}}
|
||||
>
|
||||
{/* 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 mb-1"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
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) => {
|
||||
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={() => setOpen(false)}
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user