381 lines
16 KiB
TypeScript
381 lines
16 KiB
TypeScript
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 } from '@/components/CommandPalette';
|
|
import { useCommandPaletteHotkey } from '@/hooks/useCommandPaletteHotkey';
|
|
import { HeaderNodeMetrics } from '@/components/HeaderNodeMetrics';
|
|
import { healthApi } from '@/api/healthApi';
|
|
|
|
function formatBuildLabel(version: string, sha: string): string {
|
|
const shortSha = sha && sha !== 'unknown' && sha !== 'dev' ? sha.slice(0, 12) : sha;
|
|
return `${version} (${shortSha})`;
|
|
}
|
|
|
|
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 [apiVersionLabel, setApiVersionLabel] = useState<string | null>(null);
|
|
|
|
const openPalette = useCallback(() => setPaletteOpen(true), []);
|
|
useCommandPaletteHotkey(openPalette);
|
|
|
|
const adminVersionLabel = formatBuildLabel(
|
|
import.meta.env.VITE_APP_VERSION || '0.0',
|
|
import.meta.env.VITE_GIT_SHA || 'dev'
|
|
);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
healthApi
|
|
.getAdminHealth()
|
|
.then((info) => {
|
|
if (cancelled) return;
|
|
setApiVersionLabel(
|
|
formatBuildLabel(info.version || '0.0', info.git_sha || 'unknown')
|
|
);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setApiVersionLabel(null);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
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 />
|
|
<DropdownMenuLabel className="space-y-0.5 text-xs font-normal text-muted-foreground">
|
|
<div>{t('layout.adminVersion', { version: adminVersionLabel })}</div>
|
|
<div>
|
|
{apiVersionLabel
|
|
? t('layout.apiVersion', { version: apiVersionLabel })
|
|
: t('layout.apiVersionUnavailable')}
|
|
</div>
|
|
</DropdownMenuLabel>
|
|
<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;
|