264 lines
7.9 KiB
TypeScript
264 lines
7.9 KiB
TypeScript
"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>
|
||
);
|
||
}
|