The search input is the only tabbable element in the menu, and Base UI's focus guards let Tab leak out of a single-element trap: focus walked to the sidebar while the menu stayed open. Arrow keys drive the selection in a command menu, so Tab now stays on the input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
"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
|
|
// The input is the only tabbable element here, and Base UI's focus
|
|
// guards let Tab leak out of a single-element trap (seen live:
|
|
// focus walked to the sidebar while the menu stayed open). In a
|
|
// command menu arrows move the selection, so Tab simply stays put.
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Tab") e.preventDefault();
|
|
}}
|
|
>
|
|
<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>
|
|
</>
|
|
);
|
|
}
|