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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user