feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
CI / test (push) Failing after 3m9s
CI / push-image (push) Has been skipped
CI / ci-done (push) Failing after 0s

Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
2026-07-14 17:58:33 +03:00
parent de379e28bf
commit fd2c1f10ad
95 changed files with 12699 additions and 4410 deletions
+157
View File
@@ -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]);
}