feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Inbox,
|
||||
Users,
|
||||
Calendar,
|
||||
CalendarDays,
|
||||
AlertTriangle,
|
||||
Star,
|
||||
Ban,
|
||||
Ticket,
|
||||
CreditCard,
|
||||
Shield,
|
||||
FileSearch,
|
||||
Activity,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
User,
|
||||
Search,
|
||||
Rows3,
|
||||
Check,
|
||||
Languages,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useAdminWebSocket } from '@/hooks/useAdminWebSocket';
|
||||
import { useUpdateProfile } from '@/hooks/useProfile';
|
||||
import {
|
||||
filterNavGroupsByRole,
|
||||
getMenuSelectedKey,
|
||||
canReceiveWsNotification,
|
||||
NAV_GROUPS,
|
||||
type NavItem,
|
||||
} from '@/config/adminAccess';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getTableDensity, toggleTableDensity, type TableDensity } from '@/lib/density';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { CommandPalette, useCommandPaletteHotkey } from '@/components/CommandPalette';
|
||||
import { HeaderNodeMetrics } from '@/components/HeaderNodeMetrics';
|
||||
|
||||
const navIconByKey: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
'/dashboard': LayoutDashboard,
|
||||
'/inbox/reports': Inbox,
|
||||
'/inbox/tickets': Ticket,
|
||||
'/users': Users,
|
||||
'/calendars': Calendar,
|
||||
'/events': CalendarDays,
|
||||
'/reports': AlertTriangle,
|
||||
'/reviews': Star,
|
||||
'/banned-words': Ban,
|
||||
'/tickets': Ticket,
|
||||
'/subscriptions': CreditCard,
|
||||
'/admins': Shield,
|
||||
'/audit': FileSearch,
|
||||
'/monitoring': Activity,
|
||||
};
|
||||
|
||||
function NavLinkItem({ item, active, collapsed }: { item: NavItem; active: boolean; collapsed: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = navIconByKey[item.key] ?? LayoutDashboard;
|
||||
const label = t(item.label);
|
||||
return (
|
||||
<Link
|
||||
to={item.key}
|
||||
title={collapsed ? label : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors',
|
||||
collapsed && 'justify-center px-2',
|
||||
active
|
||||
? 'bg-sidebar-accent text-white font-medium'
|
||||
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span className="truncate">{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const ControlCenterLayout: React.FC = () => {
|
||||
useAdminWebSocket();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuthStore();
|
||||
const updateProfile = useUpdateProfile();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [density, setDensity] = useState<TableDensity>(() => getTableDensity());
|
||||
|
||||
const openPalette = useCallback(() => setPaletteOpen(true), []);
|
||||
useCommandPaletteHotkey(openPalette);
|
||||
|
||||
const selectedKey = getMenuSelectedKey(location.pathname);
|
||||
const groups = useMemo(() => filterNavGroupsByRole(NAV_GROUPS, user?.role), [user?.role]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: CustomEvent) => {
|
||||
const msg = event.detail;
|
||||
if (msg.type === 'report_created') {
|
||||
if (!canReceiveWsNotification('report_created', user?.role)) return;
|
||||
const { report_id, target_type, reason } = msg.data || {};
|
||||
const inboxPath = report_id
|
||||
? `/inbox/reports?selected=${report_id}`
|
||||
: '/inbox/reports';
|
||||
notify.info(
|
||||
[
|
||||
report_id ? t('ws.reportId', { id: report_id }) : t('ws.newReport'),
|
||||
target_type ? t('ws.onTarget', { type: target_type }) : '',
|
||||
reason ? `(${reason})` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
{
|
||||
action: {
|
||||
label: t('ws.open'),
|
||||
onClick: () => navigate(inboxPath),
|
||||
},
|
||||
}
|
||||
);
|
||||
} else if (msg.type === 'ticket_created') {
|
||||
if (!canReceiveWsNotification('ticket_created', user?.role)) return;
|
||||
const { ticket_id } = msg.data || {};
|
||||
const inboxPath = ticket_id
|
||||
? `/inbox/tickets?selected=${ticket_id}`
|
||||
: '/inbox/tickets';
|
||||
notify.info(
|
||||
ticket_id ? t('ws.ticketCreated', { id: ticket_id }) : t('ws.newTicket'),
|
||||
{
|
||||
action: {
|
||||
label: t('ws.open'),
|
||||
onClick: () => navigate(inboxPath),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
window.addEventListener('admin-ws-message', handler as EventListener);
|
||||
return () => window.removeEventListener('admin-ws-message', handler as EventListener);
|
||||
}, [navigate, user?.role, t]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleDensityToggle = () => {
|
||||
const next = toggleTableDensity();
|
||||
setDensity(next);
|
||||
notify.info(next === 'compact' ? t('layout.densityCompact') : t('layout.densityNormal'));
|
||||
};
|
||||
|
||||
const displayName = (() => {
|
||||
const nick = user?.nickname;
|
||||
const email = user?.email;
|
||||
if (nick && nick !== '-' && nick !== 'undefined') return nick;
|
||||
if (email && email !== '-' && email !== 'undefined') return email;
|
||||
return user?.id ?? t('common.emDash');
|
||||
})();
|
||||
|
||||
const initials = displayName.slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<aside
|
||||
className={cn(
|
||||
'flex flex-col border-r bg-[hsl(var(--sidebar))] text-[hsl(var(--sidebar-foreground))] transition-[width]',
|
||||
collapsed ? 'w-16' : 'w-60'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-14 items-center justify-between px-3 border-b border-white/10">
|
||||
{!collapsed && (
|
||||
<span className="font-semibold text-sm truncate">
|
||||
{import.meta.env.VITE_APP_TITLE || t('common.appTitle')}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-sidebar-foreground hover:bg-sidebar-accent shrink-0"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 px-2 py-3">
|
||||
<nav className="space-y-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.id}>
|
||||
{!collapsed && (
|
||||
<p className="mb-1 px-3 text-[11px] font-semibold uppercase tracking-wider text-sidebar-foreground/50">
|
||||
{t(group.label)}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => (
|
||||
<NavLinkItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
active={selectedKey === item.key}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-14 items-center gap-3 border-b bg-card px-4">
|
||||
<HeaderNodeMetrics className="min-w-0 flex-1" />
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1 sm:gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hidden sm:inline-flex gap-2 text-muted-foreground"
|
||||
onClick={openPalette}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
{t('layout.search')}
|
||||
<kbd className="pointer-events-none rounded border bg-muted px-1.5 py-0.5 font-mono text-[10px]">
|
||||
⌘K
|
||||
</kbd>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="sm:hidden"
|
||||
title={t('layout.searchHotkey')}
|
||||
onClick={openPalette}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={density === 'compact' ? t('layout.densityToNormal') : t('layout.densityToCompact')}
|
||||
onClick={handleDensityToggle}
|
||||
>
|
||||
<Rows3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="gap-2 px-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user?.avatar_url ?? undefined} alt={displayName} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden sm:inline max-w-[120px] truncate text-sm font-medium">
|
||||
{displayName}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col">
|
||||
<span>{displayName}</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">{user?.role}</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate('/profile')}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
{t('layout.myProfile')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
|
||||
<Languages className="h-3.5 w-3.5" />
|
||||
{t('layout.uiLanguage')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
disabled={updateProfile.isPending}
|
||||
onClick={() => {
|
||||
if ((user?.language || 'ru') === 'ru') return;
|
||||
updateProfile.mutate({ language: 'ru' });
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
(user?.language || 'ru') === 'ru' ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{t('common.langRu')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={updateProfile.isPending}
|
||||
onClick={() => {
|
||||
if (user?.language === 'en') return;
|
||||
updateProfile.mutate({ language: 'en' });
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
user?.language === 'en' ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{t('common.langEn')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t('layout.logout')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="cc-main-surface flex-1 overflow-auto p-4 md:p-6">
|
||||
<ErrorBoundary>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlCenterLayout;
|
||||
Reference in New Issue
Block a user