Initial commit
Этот коммит содержится в:
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('[ErrorBoundary] Caught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="alert" className="flex flex-col items-center justify-center gap-4 p-8">
|
||||
<div className="text-destructive text-lg font-semibold">
|
||||
{i18n.t('errorBoundary.title', 'Something went wrong')}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm max-w-md text-center">
|
||||
{this.state.error?.message ?? i18n.t('errors.unexpectedError', 'An unexpected error occurred.')}
|
||||
</p>
|
||||
<Button variant="outline" onClick={this.handleReset}>
|
||||
{i18n.t('errorBoundary.tryAgain', 'Try Again')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { useCacheStore } from '@/stores/cacheStore';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { resolveObject } from '@/lib/schemaResolver';
|
||||
import { DynamicList } from '@/components/lists/DynamicList';
|
||||
import { DynamicForm } from '@/components/forms/DynamicForm';
|
||||
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
|
||||
import { DashboardView } from '@/features/dashboard/components/DashboardView';
|
||||
import { DeliveryTracePage } from '@/features/troubleshoot/DeliveryTracePage';
|
||||
import { LiveTracingPage } from '@/features/tracing/components/LiveTracingPage';
|
||||
import { TraceDetailView } from '@/features/tracing/components/TraceDetailView';
|
||||
import { ActionPage } from '@/features/actions/ActionPage';
|
||||
|
||||
interface MainContentProps {
|
||||
viewName?: string;
|
||||
id?: string;
|
||||
section?: string;
|
||||
}
|
||||
|
||||
export function MainContent({ viewName, id, section }: MainContentProps) {
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const invalidateAllObjectLists = useCacheStore((s) => s.invalidateAllObjectLists);
|
||||
|
||||
useEffect(() => {
|
||||
invalidateAllObjectLists();
|
||||
}, [viewName, invalidateAllObjectLists]);
|
||||
|
||||
if (!viewName) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground">Select a view from the sidebar.</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewName.startsWith('Dashboard/')) {
|
||||
const dashboardId = viewName.slice('Dashboard/'.length);
|
||||
return <DashboardView dashboardId={dashboardId} section={section ?? ''} />;
|
||||
}
|
||||
|
||||
if (viewName.startsWith('CustomComponent/')) {
|
||||
const componentName = viewName.slice('CustomComponent/'.length);
|
||||
if (componentName === 'Dashboard') {
|
||||
const firstId = schema?.dashboards?.[0]?.id ?? 'overview';
|
||||
return <DashboardView dashboardId={firstId} section={section ?? ''} />;
|
||||
}
|
||||
if (componentName === 'LiveDelivery') {
|
||||
return <DeliveryTracePage />;
|
||||
}
|
||||
if (componentName === 'LiveTracing') {
|
||||
return <LiveTracingPage />;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
Unknown component: {componentName}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!schema) {
|
||||
return <div className="flex items-center justify-center p-8 text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
const resolved = resolveObject(schema, viewName);
|
||||
if (!resolved) {
|
||||
return <div className="flex items-center justify-center p-8 text-destructive">Unknown view: {viewName}</div>;
|
||||
}
|
||||
|
||||
if (resolved.objectName === 'x:Action') {
|
||||
return <ActionPage viewName={viewName} />;
|
||||
}
|
||||
|
||||
if (resolved.objectType.type === 'singleton') {
|
||||
return <DynamicForm viewName={viewName} objectId="singleton" />;
|
||||
}
|
||||
|
||||
if (id === 'new') {
|
||||
return <DynamicForm viewName={viewName} objectId={null} />;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
if (resolved.objectName === 'x:Trace' && id !== 'new') {
|
||||
return <TraceDetailView viewName={viewName} objectId={id} />;
|
||||
}
|
||||
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
|
||||
if (!canUpdate) {
|
||||
return <DynamicViewPage viewName={viewName} objectId={id} />;
|
||||
}
|
||||
return <DynamicForm viewName={viewName} objectId={id} />;
|
||||
}
|
||||
|
||||
return <DynamicList viewName={viewName} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
||||
const location = useLocation();
|
||||
if (!accessToken && !bypassToken) {
|
||||
const originalPath = location.pathname + location.search;
|
||||
return <Navigate to="/login" replace state={{ from: originalPath }} />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
const { ChevronDown, Lock } = LucideIcons;
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import {
|
||||
visibleLayouts,
|
||||
findFirstVisibleLinkInLayout,
|
||||
findFirstAccessibleLinkInLayout,
|
||||
isLinkEnterprise,
|
||||
isLinkVisible,
|
||||
} from '@/lib/layout';
|
||||
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
|
||||
|
||||
function LucideIcon({ name, className }: { name: string; className?: string }) {
|
||||
const formatted = name
|
||||
.split('-')
|
||||
.map((s) => s[0].toUpperCase() + s.slice(1))
|
||||
.join('');
|
||||
const IconComp = (LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon | undefined;
|
||||
if (!IconComp) return <LucideIcons.Circle className={className} />;
|
||||
return <IconComp className={className} />;
|
||||
}
|
||||
|
||||
function resolveViewPath(sectionName: string, viewName: string): string {
|
||||
return `/${sectionName}/${viewName}`;
|
||||
}
|
||||
|
||||
function pathMatchesView(currentPath: string, sectionName: string, viewName: string): boolean {
|
||||
const base = `/${sectionName}/${viewName}`;
|
||||
if (currentPath === base || currentPath.startsWith(`${base}/`)) return true;
|
||||
if (viewName === 'CustomComponent/Dashboard') {
|
||||
const dashBase = `/${sectionName}/Dashboard/`;
|
||||
return currentPath.startsWith(dashBase);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function subtreeContainsActive(items: LayoutSubItem[], currentPath: string, sectionName: string): boolean {
|
||||
for (const item of items) {
|
||||
if (item.type === 'link') {
|
||||
if (pathMatchesView(currentPath, sectionName, item.viewName)) return true;
|
||||
} else if (item.type === 'container') {
|
||||
if (subtreeContainsActive(item.items, currentPath, sectionName)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean {
|
||||
for (const item of items) {
|
||||
if (item.type === 'link') {
|
||||
if (!checkLinkVisible(item.viewName)) continue;
|
||||
const enterprise = checkIsEnterprise(item.viewName);
|
||||
if (enterprise && edition === 'oss') continue;
|
||||
return true;
|
||||
} else if (item.type === 'container') {
|
||||
if (subtreeHasVisibleLink(item.items, edition)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkLinkVisible(viewName: string): boolean {
|
||||
const schema = useSchemaStore.getState().schema;
|
||||
if (!schema) return true;
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
return isLinkVisible(
|
||||
schema,
|
||||
viewName,
|
||||
accountStore.edition,
|
||||
(prefix: string) => accountStore.hasObjectPermission(prefix, 'Get'),
|
||||
(perm: string) => accountStore.hasPermission(perm),
|
||||
);
|
||||
}
|
||||
|
||||
function checkIsEnterprise(viewName: string): boolean {
|
||||
const schema = useSchemaStore.getState().schema;
|
||||
if (!schema) return false;
|
||||
const edition = useAccountStore.getState().edition;
|
||||
return isLinkEnterprise(schema, viewName, edition);
|
||||
}
|
||||
|
||||
interface SidebarSubItemProps {
|
||||
item: LayoutSubItem;
|
||||
depth: number;
|
||||
sectionName: string;
|
||||
currentPath: string;
|
||||
navigate: ReturnType<typeof useNavigate>;
|
||||
edition: string;
|
||||
onUpsell: () => void;
|
||||
}
|
||||
|
||||
function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) {
|
||||
if (item.type === 'link') {
|
||||
if (!checkLinkVisible(item.viewName)) return null;
|
||||
|
||||
const path = resolveViewPath(sectionName, item.viewName);
|
||||
const isActive = pathMatchesView(currentPath, sectionName, item.viewName);
|
||||
const enterprise = checkIsEnterprise(item.viewName);
|
||||
const isLocked = enterprise && edition === 'community';
|
||||
const isHidden = enterprise && edition === 'oss';
|
||||
|
||||
if (isHidden) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start gap-2 font-normal',
|
||||
isActive && 'bg-accent text-accent-foreground',
|
||||
depth > 0 && 'text-sm',
|
||||
)}
|
||||
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
||||
onClick={() => {
|
||||
if (isLocked) {
|
||||
onUpsell();
|
||||
} else {
|
||||
navigate(path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{item.name || 'Overview'}</span>
|
||||
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'container') {
|
||||
if (!subtreeHasVisibleLink(item.items, edition)) return null;
|
||||
|
||||
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
|
||||
return (
|
||||
<Collapsible defaultOpen={containsActive}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 font-normal text-sm"
|
||||
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
||||
<span className="truncate">{item.name}</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{item.items.map((sub) => (
|
||||
<SidebarSubItem
|
||||
key={sub.type === 'link' ? sub.viewName : sub.name}
|
||||
item={sub}
|
||||
depth={depth + 1}
|
||||
sectionName={sectionName}
|
||||
currentPath={currentPath}
|
||||
navigate={navigate}
|
||||
edition={edition}
|
||||
onUpsell={onUpsell}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface SidebarTopItemProps {
|
||||
item: LayoutItem;
|
||||
sectionName: string;
|
||||
currentPath: string;
|
||||
navigate: ReturnType<typeof useNavigate>;
|
||||
edition: string;
|
||||
onUpsell: () => void;
|
||||
}
|
||||
|
||||
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) {
|
||||
if ('link' in item) {
|
||||
const { name, icon, viewName } = item.link;
|
||||
|
||||
if (!checkLinkVisible(viewName)) return null;
|
||||
|
||||
const path = resolveViewPath(sectionName, viewName);
|
||||
const isActive = pathMatchesView(currentPath, sectionName, viewName);
|
||||
const enterprise = checkIsEnterprise(viewName);
|
||||
const isLocked = enterprise && edition === 'community';
|
||||
const isHidden = enterprise && edition === 'oss';
|
||||
|
||||
if (isHidden) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
|
||||
onClick={() => {
|
||||
if (isLocked) {
|
||||
onUpsell();
|
||||
} else {
|
||||
navigate(path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{name}</span>
|
||||
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if ('container' in item) {
|
||||
const { name, icon, items } = item.container;
|
||||
if (!subtreeHasVisibleLink(items, edition)) return null;
|
||||
|
||||
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
||||
|
||||
return (
|
||||
<Collapsible defaultOpen={containsActive}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
|
||||
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{name}</span>
|
||||
<ChevronDown className="ml-auto h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{items.map((sub) => (
|
||||
<SidebarSubItem
|
||||
key={sub.type === 'link' ? sub.viewName : sub.name}
|
||||
item={sub}
|
||||
depth={1}
|
||||
sectionName={sectionName}
|
||||
currentPath={currentPath}
|
||||
navigate={navigate}
|
||||
edition={edition}
|
||||
onUpsell={onUpsell}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const activeSection = useUIStore((s) => s.activeSection);
|
||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const edition = useAccountStore((s) => s.edition);
|
||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||
|
||||
const layouts = useMemo(
|
||||
() =>
|
||||
schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) : [],
|
||||
[schema, edition, hasObjectPermission, hasPermission],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!schema) return;
|
||||
if (layouts.length === 0) return;
|
||||
if (!layouts.find((l) => l.name === activeSection)) {
|
||||
setActiveSection(layouts[0].name);
|
||||
}
|
||||
}, [schema, layouts, activeSection, setActiveSection]);
|
||||
|
||||
if (!sidebarOpen || !schema) return null;
|
||||
|
||||
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
|
||||
if (!layout) return null;
|
||||
|
||||
const handleSectionClick = (target: Layout) => {
|
||||
setActiveSection(target.name);
|
||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
||||
const first =
|
||||
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
|
||||
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
|
||||
if (first) navigate(`/${target.name}/${first}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="fixed top-14 left-0 bottom-0 z-30 hidden w-64 flex-col border-r bg-background md:flex">
|
||||
<ScrollArea className="flex-1 py-2">
|
||||
<nav className="flex flex-col gap-0.5 px-2">
|
||||
{layout.items.map((item) => (
|
||||
<SidebarTopItem
|
||||
key={'link' in item ? item.link.viewName : item.container.name}
|
||||
item={item}
|
||||
sectionName={layout.name}
|
||||
currentPath={location.pathname}
|
||||
navigate={navigate}
|
||||
edition={edition}
|
||||
onUpsell={() => setUpsellOpen(true)}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
|
||||
{layouts.length > 1 && (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-around border-t bg-background px-2 py-2">
|
||||
{layouts.map((target) => {
|
||||
const Icon = (LucideIcons as Record<string, unknown>)[
|
||||
target.icon
|
||||
.split('-')
|
||||
.map((s) => s[0].toUpperCase() + s.slice(1))
|
||||
.join('')
|
||||
] as LucideIcons.LucideIcon | undefined;
|
||||
const isActive = target.name === activeSection;
|
||||
return (
|
||||
<Tooltip key={target.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={target.name}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => handleSectionClick(target)}
|
||||
className={cn('h-9 w-9', isActive && 'bg-accent text-accent-foreground')}
|
||||
>
|
||||
{Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{target.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles } = LucideIcons;
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GlobalSearch } from '@/components/common/GlobalSearch';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import Logo from '@/components/common/Logo';
|
||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { useState } from 'react';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
|
||||
function getIcon(name: string): LucideIcons.LucideIcon {
|
||||
const formatted = name
|
||||
.split('-')
|
||||
.map((s) => s[0].toUpperCase() + s.slice(1))
|
||||
.join('');
|
||||
return ((LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon) || LucideIcons.Circle;
|
||||
}
|
||||
|
||||
export function TopBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useUIStore((s) => s.theme);
|
||||
const toggleTheme = useUIStore((s) => s.toggleTheme);
|
||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||
const accounts = useAuthStore((s) => s.accounts);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const edition = useAccountStore((s) => s.edition);
|
||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||
|
||||
const navigableLayouts = schema
|
||||
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 flex h-14 items-center gap-4 border-b bg-background px-4">
|
||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="md:hidden">
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Link to="/" className="flex shrink-0 items-center">
|
||||
<Logo />
|
||||
</Link>
|
||||
|
||||
<GlobalSearch />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{edition !== 'enterprise' && <EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />}
|
||||
|
||||
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
|
||||
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label={t('userMenu', 'User menu')}>
|
||||
<User className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{schema && navigableLayouts.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel>{t('sections', 'Sections')}</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{navigableLayouts.map((layout) => {
|
||||
const Icon = getIcon(layout.icon);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={layout.name}
|
||||
onClick={() => {
|
||||
setActiveSection(layout.name);
|
||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
||||
const firstLink =
|
||||
findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPermission) ??
|
||||
findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPermission);
|
||||
if (firstLink) {
|
||||
navigate(`/${layout.name}/${firstLink}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{layout.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{Object.keys(accounts).length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel>{t('accounts', 'Accounts')}</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{Object.entries(accounts).map(([id, info]) => (
|
||||
<DropdownMenuItem key={id} onClick={() => switchAccount(id)}>
|
||||
{id === activeAccountId && <Check className="mr-2 h-4 w-4" />}
|
||||
<span className={id !== activeAccountId ? 'ml-6' : ''}>{info.name || id}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{edition !== 'enterprise' && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => setUpsellOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{t('tryEnterprise', 'Try Enterprise')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
}}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t('logout', 'Logout')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Ссылка в новой задаче
Block a user