"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 = { 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(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 ( <> { setOpen(next); if (!next) setQuery(""); }} > Поиск по админке { if (e.key === "Tab") e.preventDefault(); }} >
{showLoading && ( ищем… )}
{!active ? "Введите хотя бы два символа" : showLoading ? "Ищем…" : "Ничего не нашлось"} {shown.users.length > 0 && ( {shown.users.map((u) => ( go(`/admin/users/${u.id}`)} className={itemClass} > {u.name} {u.email} {u.role !== "student" && ( {ROLE_LABEL[u.role] ?? u.role} )} ))} )} {shown.courses.length > 0 && ( {shown.courses.map((c) => ( go(`/admin/courses/${c.id}`)} className={itemClass} > {c.title} {c.slug} {!c.published && черновик} ))} )} {shown.lessons.length > 0 && ( {shown.lessons.map((l) => ( go(`/admin/courses/${l.courseId}/modules/${l.moduleId}/lessons/${l.id}`) } className={itemClass} > {l.title} {l.courseTitle} {!l.published && черновик} ))} )} {sections.length > 0 && ( {sections.map((l) => ( go(l.href)} className={itemClass} > {l.label} ))} )}
); }