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:
2026-11-11 11:25:52 +05:00
co-authored by Claude Opus 5.5
parent 74d51f72bf
commit 09b069cbb5
10 changed files with 1469 additions and 186 deletions
+266
View File
@@ -0,0 +1,266 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
import { Command } from "cmdk";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { Search } from "lucide-react";
import { adminLinks } from "@/components/admin/admin-nav";
interface SearchResults {
users: { id: string; name: string; email: string; role: string }[];
courses: { id: string; title: string; slug: string; published: boolean }[];
lessons: {
id: string;
title: string;
published: boolean;
moduleId: string;
courseId: string;
courseTitle: string;
}[];
}
const EMPTY: SearchResults = { users: [], courses: [], lessons: [] };
const MIN_QUERY = 2;
const DEBOUNCE_MS = 200;
const ROLE_LABEL: Record<string, string> = {
student: "студент",
curator: "куратор",
admin: "админ",
};
const noopSubscribe = () => () => {};
function useShortcutLabel() {
return useSyncExternalStore(
noopSubscribe,
() => (/Mac|iPhone|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl K"),
() => "Ctrl K",
);
}
// DS-2: hard-edged group headings, accent highlight for the active row.
const groupClass =
"mb-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pt-3 [&_[cmdk-group-heading]]:pb-1 " +
"[&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-bold [&_[cmdk-group-heading]]:uppercase " +
"[&_[cmdk-group-heading]]:tracking-widest [&_[cmdk-group-heading]]:text-[var(--muted-foreground)]";
const itemClass =
"flex min-h-[44px] cursor-pointer items-center gap-3 border-2 border-transparent px-2 py-1.5 text-sm " +
"data-[selected=true]:border-[var(--foreground)] data-[selected=true]:bg-[var(--accent)]";
export function AdminCommandMenu({ onOpen }: { onOpen?: () => void }) {
const router = useRouter();
const shortcut = useShortcutLabel();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResults>(EMPTY);
const [loading, setLoading] = useState(false);
// Global hotkey: Ctrl+K / ⌘K toggles the menu from anywhere in the admin.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((v) => !v);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// Debounced server lookup; stale responses are aborted.
const trimmed = query.trim();
useEffect(() => {
if (trimmed.length < MIN_QUERY) return;
const controller = new AbortController();
const timer = setTimeout(() => {
setLoading(true);
fetch(`/api/admin/search?q=${encodeURIComponent(trimmed)}`, { signal: controller.signal })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
.then((data: SearchResults) => setResults(data))
.catch((e: unknown) => {
if ((e as Error).name !== "AbortError") {
console.error("admin search failed", e);
setResults(EMPTY);
}
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
}, DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [trimmed]);
const active = trimmed.length >= MIN_QUERY;
const shown = active ? results : EMPTY;
const showLoading = active && loading;
const needle = trimmed.toLowerCase();
const sections = needle
? adminLinks.filter((l) => l.label.toLowerCase().includes(needle))
: adminLinks;
function go(href: string) {
setOpen(false);
setQuery("");
setResults(EMPTY);
router.push(href);
}
return (
<>
<button
type="button"
onClick={() => {
onOpen?.();
setOpen(true);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors hover:bg-[var(--sidebar-surface)] hover:text-[#F5F5F0]"
style={{ border: "2px solid var(--sidebar-border)", color: "var(--sidebar-text)" }}
aria-label="Поиск по админке"
>
<Search size={14} aria-hidden />
<span className="flex-1 text-left">Поиск</span>
<kbd className="text-[0.65rem] tracking-widest" style={{ fontFamily: "inherit" }}>
{shortcut}
</kbd>
</button>
<DialogPrimitive.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setQuery("");
}}
>
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop
className="fixed inset-0 z-50"
style={{ background: "rgba(50,50,50,0.45)" }}
/>
<DialogPrimitive.Popup
className="fixed top-[12vh] left-1/2 z-50 w-[calc(100%-2rem)] max-w-xl -translate-x-1/2 outline-none"
style={{
background: "var(--background)",
color: "var(--foreground)",
border: "2px solid var(--foreground)",
boxShadow: "6px 6px 0 0 var(--foreground)",
}}
>
<DialogPrimitive.Title className="sr-only">Поиск по админке</DialogPrimitive.Title>
<Command shouldFilter={false} label="Поиск по админке" loop>
<div
className="flex items-center gap-2 px-3"
style={{ borderBottom: "2px solid var(--border)" }}
>
<Search size={16} aria-hidden style={{ color: "var(--muted-foreground)" }} />
<Command.Input
value={query}
onValueChange={setQuery}
placeholder="Студент, курс, урок или раздел…"
className="h-12 flex-1 bg-transparent text-sm outline-none placeholder:text-[var(--muted-foreground)]"
/>
{showLoading && (
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
ищем…
</span>
)}
</div>
<Command.List className="max-h-[60vh] overflow-y-auto p-2">
<Command.Empty className="px-2 py-6 text-center text-sm" style={{ color: "var(--muted-foreground)" }}>
{!active ? "Введите хотя бы два символа" : showLoading ? "Ищем…" : "Ничего не нашлось"}
</Command.Empty>
{shown.users.length > 0 && (
<Command.Group heading="Студенты и сотрудники" className={groupClass}>
{shown.users.map((u) => (
<Command.Item
key={u.id}
value={`user:${u.id}`}
onSelect={() => go(`/admin/users/${u.id}`)}
className={itemClass}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{u.name}</span>
<span className="block truncate text-xs" style={{ color: "var(--muted-foreground)" }}>
{u.email}
</span>
</span>
{u.role !== "student" && (
<span className="tag-aubade">{ROLE_LABEL[u.role] ?? u.role}</span>
)}
</Command.Item>
))}
</Command.Group>
)}
{shown.courses.length > 0 && (
<Command.Group heading="Курсы" className={groupClass}>
{shown.courses.map((c) => (
<Command.Item
key={c.id}
value={`course:${c.id}`}
onSelect={() => go(`/admin/courses/${c.id}`)}
className={itemClass}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{c.title}</span>
<span className="block truncate text-xs" style={{ color: "var(--muted-foreground)" }}>
{c.slug}
</span>
</span>
{!c.published && <span className="tag-aubade">черновик</span>}
</Command.Item>
))}
</Command.Group>
)}
{shown.lessons.length > 0 && (
<Command.Group heading="Уроки" className={groupClass}>
{shown.lessons.map((l) => (
<Command.Item
key={l.id}
value={`lesson:${l.id}`}
onSelect={() =>
go(`/admin/courses/${l.courseId}/modules/${l.moduleId}/lessons/${l.id}`)
}
className={itemClass}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{l.title}</span>
<span className="block truncate text-xs" style={{ color: "var(--muted-foreground)" }}>
{l.courseTitle}
</span>
</span>
{!l.published && <span className="tag-aubade">черновик</span>}
</Command.Item>
))}
</Command.Group>
)}
{sections.length > 0 && (
<Command.Group heading="Разделы" className={groupClass}>
{sections.map((l) => (
<Command.Item
key={l.href}
value={`section:${l.href}`}
onSelect={() => go(l.href)}
className={itemClass}
>
{l.label}
</Command.Item>
))}
</Command.Group>
)}
</Command.List>
</Command>
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</>
);
}