Files
telegram-shop/src/components/layout/command-palette.tsx
2026-08-05 16:54:25 +00:00

264 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import type { LucideIcon } from "lucide-react";
import {
LayoutDashboard,
FolderTree,
Users,
Wallet,
ShoppingCart,
FileText,
Tag,
MapPin,
Settings,
Languages,
AlertTriangle,
Database,
Trash2,
LogOut,
Loader2,
} from "lucide-react";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { useAuthStore } from "@/stores/auth-store";
const COMMAND_TOGGLE = "command-palette:toggle";
export function openCommandPalette() {
window.dispatchEvent(new CustomEvent(COMMAND_TOGGLE));
}
const navigationItems = [
{ label: "Dashboard", href: "/", Icon: LayoutDashboard },
{ label: "Catalog", href: "/catalog", Icon: FolderTree },
{ label: "Users", href: "/users", Icon: Users },
{ label: "Wallets", href: "/wallets", Icon: Wallet },
{ label: "Purchases", href: "/purchases", Icon: ShoppingCart },
{ label: "Audit Log", href: "/audit", Icon: FileText },
{ label: "Categories", href: "/categories", Icon: Tag },
{ label: "Locations", href: "/locations", Icon: MapPin },
{ label: "Settings", href: "/settings", Icon: Settings },
{ label: "Locales", href: "/locales", Icon: Languages },
{ label: "Danger Zone", href: "/seed", Icon: AlertTriangle },
] as const;
interface GlobalResult {
type: string;
label: string;
href: string;
Icon: LucideIcon;
}
export function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [globalResults, setGlobalResults] = useState<GlobalResult[]>([]);
const [searching, setSearching] = useState(false);
const { logout, role } = useAuthStore();
const isSuperAdmin = role === 'super_admin';
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const toggle = useCallback(() => {
setOpen((prev) => !prev);
}, []);
// Debounced user search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (query.length < 2) {
setGlobalResults([]);
setSearching(false);
return;
}
debounceRef.current = setTimeout(async () => {
setSearching(true);
try {
const res = await fetch(`/api/users/bulk?search=${encodeURIComponent(query)}&limit=5`);
if (!res.ok) {
setGlobalResults([]);
return;
}
const data = await res.json();
const users: Array<{ id: number; username: string | null; telegramId: string }> = data.data || [];
setGlobalResults(
users.map((user) => ({
type: "user",
label: `${user.username || "@" + user.telegramId} (ID: ${user.id})`,
href: `/users/${user.id}`,
Icon: Users,
}))
);
} catch {
setGlobalResults([]);
} finally {
setSearching(false);
}
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query]);
// Reset query when palette closes
useEffect(() => {
if (!open) {
setQuery("");
setGlobalResults([]);
}
}, [open]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
toggle();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [toggle]);
useEffect(() => {
const handleToggle = () => setOpen(true);
window.addEventListener(COMMAND_TOGGLE, handleToggle);
return () => window.removeEventListener(COMMAND_TOGGLE, handleToggle);
}, []);
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
placeholder="Type a command or search..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>
{searching ? (
<span className="flex items-center gap-2 justify-center">
<Loader2 className="size-3.5 animate-spin" />
Searching...
</span>
) : (
"No results found."
)}
</CommandEmpty>
<CommandGroup heading="Navigation">
{navigationItems
.filter((item) => isSuperAdmin || item.href !== "/seed")
.map((item) => (
<CommandItem
key={item.href}
onSelect={() => {
setOpen(false);
window.location.hash = item.href;
}}
>
<item.Icon className="size-4" />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
{globalResults.length > 0 && (
<>
<CommandGroup heading="Users">
{globalResults.map((result) => (
<CommandItem
key={result.href}
onSelect={() => {
setOpen(false);
window.location.hash = result.href;
}}
>
<result.Icon className="size-4" />
<span>{result.label}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
{isSuperAdmin && (
<>
<CommandGroup heading="Management">
<CommandItem
onSelect={() => {
setOpen(false);
window.location.hash = "/seed";
}}
>
<Database className="size-4" />
<span>Seed Demo Data</span>
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false);
window.location.hash = "/seed?action=clear";
}}
>
<Trash2 className="size-4" />
<span>Clear Data</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
</>
)}
<CommandGroup heading="System">
<CommandItem
onSelect={() => {
setOpen(false);
logout();
window.location.hash = "/login";
}}
>
<LogOut className="size-4 text-destructive" />
<span className="text-destructive">Logout</span>
</CommandItem>
</CommandGroup>
</CommandList>
<div className="border-t px-3 py-2">
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span className="flex items-center gap-1.5">
<kbd className="rounded border bg-muted px-1 py-0.5 font-mono text-[10px]">
</kbd>
<kbd className="rounded border bg-muted px-1 py-0.5 font-mono text-[10px]">
</kbd>
<span>navigate</span>
<kbd className="ml-1.5 rounded border bg-muted px-1 py-0.5 font-mono text-[10px]">
</kbd>
<span>select</span>
<kbd className="ml-1.5 rounded border bg-muted px-1 py-0.5 font-mono text-[10px]">
esc
</kbd>
<span>close</span>
</span>
<span className="font-mono">v2.0</span>
</div>
<span className="flex items-center gap-1 mt-0.5">
<kbd className="rounded border bg-muted px-1 py-0.5 font-mono text-[10px]">1</kbd>
<span></span>
<kbd className="rounded border bg-muted px-1 py-0.5 font-mono text-[10px]">9</kbd>
<span className="ml-0.5">nav</span>
</span>
<p className="mt-0.5 text-center text-[10px] text-muted-foreground/60">
TG Shop Admin v2.0
</p>
</div>
</CommandDialog>
);
}