feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { NAV_GROUPS, filterNavGroupsByRole } from '@/config/adminAccess';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type PaletteItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
group: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const items = useMemo<PaletteItem[]>(() => {
|
||||
const groups = filterNavGroupsByRole(NAV_GROUPS, role);
|
||||
const routes = groups.flatMap((g) =>
|
||||
g.items.map((item) => ({
|
||||
id: item.key,
|
||||
label: t(item.label),
|
||||
group: t(g.label),
|
||||
path: item.key,
|
||||
}))
|
||||
);
|
||||
routes.push(
|
||||
{ id: '/profile', label: t('nav.profile'), group: t('navGroup.account'), path: '/profile' },
|
||||
{ id: '/dashboard', label: t('nav.dashboard'), group: t('navGroup.overview'), path: '/dashboard' }
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
return routes.filter((r) => {
|
||||
if (seen.has(r.path)) return false;
|
||||
seen.add(r.path);
|
||||
return true;
|
||||
});
|
||||
}, [role, t]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
item.group.toLowerCase().includes(q) ||
|
||||
item.path.toLowerCase().includes(q)
|
||||
);
|
||||
}, [items, query]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [query, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setQuery('');
|
||||
}, [open]);
|
||||
|
||||
const go = useCallback(
|
||||
(path: string) => {
|
||||
onOpenChange(false);
|
||||
navigate(path);
|
||||
},
|
||||
[navigate, onOpenChange]
|
||||
);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, Math.max(filtered.length - 1, 0)));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter' && filtered[activeIndex]) {
|
||||
e.preventDefault();
|
||||
go(filtered[activeIndex].path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-lg" aria-describedby={undefined}>
|
||||
<DialogTitle className="sr-only">{t('layout.paletteTitle')}</DialogTitle>
|
||||
<div className="flex items-center border-b px-3">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={t('layout.palettePlaceholder')}
|
||||
className="h-12 border-0 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<ul className="max-h-72 overflow-auto py-2" role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
{t('common.nothingFound')}
|
||||
</li>
|
||||
) : (
|
||||
filtered.map((item, index) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between px-4 py-2 text-left text-sm',
|
||||
index === activeIndex ? 'bg-accent' : 'hover:bg-muted/60'
|
||||
)}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => go(item.path)}
|
||||
>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.group}</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
<div className="border-t px-3 py-2 text-[11px] text-muted-foreground">
|
||||
{t('layout.paletteHints')}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Глобальный хоткей Ctrl/⌘+K */
|
||||
export function useCommandPaletteHotkey(onOpen: () => void) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onOpen]);
|
||||
}
|
||||
Reference in New Issue
Block a user