1
This commit is contained in:
parent
02e795fead
commit
72de966d0c
107
frontend/package-lock.json
generated
107
frontend/package-lock.json
generated
@ -27,7 +27,9 @@
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
@ -2137,6 +2139,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.4.tgz",
|
||||
"integrity": "sha512-iXU1Ab5ecM+yEepGAWK8ZhMyKX4ubFdCNtol4sT9D0OVErG9PNElfx3TQhjw7n7BC5nFVz68/5//clWy+8TXzA==",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.0",
|
||||
"@radix-ui/react-menu": "2.1.4",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz",
|
||||
@ -2218,6 +2248,80 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-menu/-/react-menu-2.1.4.tgz",
|
||||
"integrity": "sha512-BnOgVoL6YYdHAG6DtXONaR29Eq4nvbi8rutrV/xlr3RQCMMb3yqP85Qiw/3NReozrSW+4dfLkK+rc1hb4wPU/A==",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-collection": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-direction": "1.1.0",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.3",
|
||||
"@radix-ui/react-focus-guards": "1.1.1",
|
||||
"@radix-ui/react-focus-scope": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.0",
|
||||
"@radix-ui/react-popper": "1.2.1",
|
||||
"@radix-ui/react-portal": "1.1.3",
|
||||
"@radix-ui/react-presence": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-roving-focus": "1.1.1",
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"aria-hidden": "^1.1.1",
|
||||
"react-remove-scroll": "^2.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-navigation-menu": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.3.tgz",
|
||||
"integrity": "sha512-IQWAsQ7dsLIYDrn0WqPU+cdM7MONTv9nqrLVYoie3BPiabSfUVDe6Fr+oEt0Cofsr9ONDcDe9xhmJbL1Uq1yKg==",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-collection": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-direction": "1.1.0",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.3",
|
||||
"@radix-ui/react-id": "1.1.0",
|
||||
"@radix-ui/react-presence": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0",
|
||||
"@radix-ui/react-use-previous": "1.1.0",
|
||||
"@radix-ui/react-visually-hidden": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
|
||||
@ -4173,7 +4277,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
@ -4196,7 +4299,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@ -8392,7 +8494,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
|
||||
"integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
|
||||
@ -29,7 +29,9 @@
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
|
||||
66
frontend/src/components/AppMenu.tsx
Normal file
66
frontend/src/components/AppMenu.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Sidebar, SidebarHeader, SidebarContent } from './ui/sidebar';
|
||||
import { MenuItem, MenuGroup } from './ui/sidebar-menu';
|
||||
import type { RootState } from '@/store';
|
||||
import { MenuResponse, MenuTypeEnum } from '@/pages/System/Menu/types';
|
||||
import { getIconComponent } from '@/config/icons';
|
||||
|
||||
interface AppMenuProps {
|
||||
openKeys: string[];
|
||||
onOpenChange: (keys: string[]) => void;
|
||||
}
|
||||
|
||||
export function AppMenu({ openKeys, onOpenChange }: AppMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const menus = useSelector((state: RootState) => state.user.menus);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<div className="text-lg font-semibold">Deploy Ease</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<MenuGroup>
|
||||
{menus?.filter(menu => menu.type !== MenuTypeEnum.BUTTON && !menu.hidden)
|
||||
.map(menu => (
|
||||
<MenuItem
|
||||
key={menu.path || String(menu.id)}
|
||||
icon={getIconComponent(menu.icon)}
|
||||
title={menu.name}
|
||||
active={location.pathname === menu.path}
|
||||
expanded={openKeys.includes(menu.path || String(menu.id))}
|
||||
onClick={() => {
|
||||
if (menu.children?.length) {
|
||||
onOpenChange(
|
||||
openKeys.includes(menu.path || String(menu.id))
|
||||
? openKeys.filter(key => key !== (menu.path || String(menu.id)))
|
||||
: [...openKeys, menu.path || String(menu.id)]
|
||||
);
|
||||
} else if (menu.path) {
|
||||
navigate(menu.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{menu.children?.map(child => (
|
||||
<MenuItem
|
||||
key={child.path || String(child.id)}
|
||||
icon={getIconComponent(child.icon)}
|
||||
title={child.name}
|
||||
active={location.pathname === child.path}
|
||||
onClick={() => {
|
||||
if (child.path) {
|
||||
navigate(child.path);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/ui/sidebar-menu.tsx
Normal file
81
frontend/src/components/ui/sidebar-menu.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import * as React from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface MenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
active?: boolean;
|
||||
expanded?: boolean;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
|
||||
({ className, icon, title, active, expanded, disabled, children, onClick, ...props }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={cn("relative", className)} {...props}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm font-medium",
|
||||
"transition-all duration-200 ease-in-out",
|
||||
"hover:bg-accent/50 hover:text-accent-foreground",
|
||||
"dark:hover:bg-slate-800 dark:hover:text-slate-100",
|
||||
active && "bg-accent text-accent-foreground dark:bg-slate-800 dark:text-slate-100",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon && (
|
||||
<span className={cn(
|
||||
"mr-2 transition-colors",
|
||||
"text-muted-foreground group-hover:text-current",
|
||||
active && "text-current"
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1">{title}</span>
|
||||
{children && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"ml-1 h-4 w-4 transition-transform duration-200",
|
||||
"text-muted-foreground group-hover:text-current",
|
||||
active && "text-current",
|
||||
expanded && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{expanded && children && (
|
||||
<div className={cn(
|
||||
"mt-1 space-y-1 pl-4",
|
||||
"animate-in slide-in-from-left-5 duration-200"
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
MenuItem.displayName = "MenuItem";
|
||||
|
||||
const MenuGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"space-y-1.5 px-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenuGroup.displayName = "MenuGroup";
|
||||
|
||||
export { MenuItem, MenuGroup };
|
||||
67
frontend/src/components/ui/sidebar.tsx
Normal file
67
frontend/src/components/ui/sidebar.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Sidebar = React.forwardRef<HTMLDivElement, SidebarProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full flex-col border-r bg-background transition-all duration-300",
|
||||
"min-w-[240px] w-60",
|
||||
"dark:border-slate-700 dark:bg-slate-900",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
const SidebarHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-14 items-center border-b px-4",
|
||||
"dark:border-slate-700 dark:bg-slate-900/50",
|
||||
"bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarHeader.displayName = "SidebarHeader";
|
||||
|
||||
const SidebarContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex-1 overflow-auto py-2",
|
||||
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-200",
|
||||
"dark:scrollbar-thumb-slate-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarContent.displayName = "SidebarContent";
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarHeader,
|
||||
SidebarContent,
|
||||
};
|
||||
@ -1,11 +1,10 @@
|
||||
import React, {useEffect, useState, useCallback} from 'react';
|
||||
import {Layout, Menu, Dropdown, Modal, message, Spin, Space, Tooltip} from 'antd';
|
||||
import {Layout, Dropdown, Modal, message, Spin, Space, Tooltip} from 'antd';
|
||||
import {useNavigate, useLocation, Outlet} from 'react-router-dom';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
import {
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
DownOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloudOutlined
|
||||
@ -17,10 +16,9 @@ import {getWeather} from '../services/weather';
|
||||
import type {RootState} from '../store';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import {MenuResponse, MenuTypeEnum} from "@/pages/System/Menu/types";
|
||||
import { getIconComponent } from '@/config/icons.tsx';
|
||||
import { AppMenu } from '@/components/AppMenu';
|
||||
|
||||
const {Header, Content, Sider} = Layout;
|
||||
const {Header, Content} = Layout;
|
||||
const {confirm} = Modal;
|
||||
|
||||
// 设置中文语言
|
||||
@ -31,7 +29,6 @@ const BasicLayout: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const userInfo = useSelector((state: RootState) => state.user.userInfo);
|
||||
const menus = useSelector((state: RootState) => state.user.menus);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(dayjs());
|
||||
const [weather, setWeather] = useState({temp: '--', weather: '未知', city: '未知'});
|
||||
@ -149,26 +146,8 @@ const BasicLayout: React.FC = () => {
|
||||
}
|
||||
];
|
||||
|
||||
// 获取图标组件
|
||||
const getIcon = getIconComponent;
|
||||
|
||||
// 将菜单数据转换为antd Menu需要的格式
|
||||
const getMenuItems = (menuList: MenuResponse[]): MenuProps['items'] => {
|
||||
return menuList
|
||||
?.filter(menu => menu.type !== MenuTypeEnum.BUTTON && !menu.hidden)
|
||||
.map(menu => {
|
||||
return {
|
||||
key: menu.path || String(menu.id),
|
||||
icon: getIconComponent(menu.icon),
|
||||
label: menu.name,
|
||||
children: menu.children ? getMenuItems(menu.children) : undefined
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 处理菜单展开/收起
|
||||
const handleOpenChange = (keys: string[]) => {
|
||||
console.log('Menu open change:', keys);
|
||||
setOpenKeys(keys);
|
||||
};
|
||||
|
||||
@ -197,25 +176,10 @@ const BasicLayout: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Layout style={{minHeight: '100vh'}}>
|
||||
<Sider>
|
||||
<div style={{height: 32, margin: 16, background: 'rgba(255, 255, 255, 0.2)'}}/>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
openKeys={openKeys}
|
||||
onOpenChange={handleOpenChange}
|
||||
items={getMenuItems(menus)}
|
||||
onClick={({key}) => {
|
||||
console.log('Menu click:', key);
|
||||
navigate(key);
|
||||
}}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Layout style={{minHeight: '100vh', display: 'flex', flexDirection: 'row'}}>
|
||||
<AppMenu openKeys={openKeys} onOpenChange={handleOpenChange} />
|
||||
<Layout style={{flex: 1, overflow: 'hidden'}}>
|
||||
<Header style={{
|
||||
padding: '0 24px',
|
||||
background: '#fff',
|
||||
@ -248,35 +212,25 @@ const BasicLayout: React.FC = () => {
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<Dropdown menu={{items: userMenuItems}} trigger={['hover']}>
|
||||
<span style={{
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
transition: 'all 0.3s',
|
||||
padding: '0 4px',
|
||||
borderRadius: 4
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.025)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<UserOutlined style={{fontSize: 16, marginRight: 8}}/>
|
||||
<Space style={{cursor: 'pointer'}}>
|
||||
<UserOutlined/>
|
||||
<span>{userInfo?.nickname || userInfo?.username}</span>
|
||||
<DownOutlined style={{fontSize: 12, marginLeft: 6}}/>
|
||||
</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
<Content style={{ margin: '24px 16px', padding: 24, background: '#fff', minHeight: 280 }}>
|
||||
<Content style={{
|
||||
margin: '24px 16px',
|
||||
padding: 24,
|
||||
background: '#fff',
|
||||
minHeight: 280,
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<Outlet/>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default BasicLayout;
|
||||
@ -1,4 +1,4 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user