feat: 全站 PageShell 设计系统与专业 UI 统一
- 新增 PageShell/PageTabs/ContentPanel/PageLoading/PageEmpty 页面骨架 - index.css 设计令牌文档化,布局令牌与纯色主按钮(去除渐变粉彩) - 全站路由页接入 PageShell,云账单三 Tab 为标杆 - 主题体系:跟随系统 + 主流/开发者分组,新增 Dracula/Tokyo Night,One Dark 暖灰差异化 - 登录页/侧栏/顶栏去渐变光球,企业克制风 - 修复 AIAlertAnalyzer JSX 与构建依赖导入问题
This commit is contained in:
1 parent
1acb0c49af
commit
e363ded445
213 files changed
+7148
-6818
No files matched your search
@@ -0,0 +1,30 @@
|
||||
// 列表/表格内容面板 — 统一卡片容器 + 可选工具栏槽 // Logan.Li
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ContentPanelProps {
|
||||
toolbar?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
noPadding?: boolean;
|
||||
}
|
||||
|
||||
export function ContentPanel({
|
||||
toolbar,
|
||||
children,
|
||||
className,
|
||||
bodyClassName,
|
||||
noPadding,
|
||||
}: ContentPanelProps) {
|
||||
return (
|
||||
<div className={cn('dg-content-panel', className)}>
|
||||
{toolbar && (
|
||||
<div className="dg-content-panel-toolbar">{toolbar}</div>
|
||||
)}
|
||||
<div className={cn(!noPadding && 'dg-content-panel-body', bodyClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
<div className="dg-card max-w-lg w-full p-8 text-center space-y-6">
|
||||
{/* Error Icon */}
|
||||
<div className="mx-auto w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center">
|
||||
<AlertTriangle className="h-8 w-8 text-red-400" />
|
||||
<AlertTriangle className="h-8 w-8 dg-text-red" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
@@ -87,7 +87,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
查看错误详情
|
||||
</summary>
|
||||
<div className="mt-2 p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] overflow-auto max-h-48">
|
||||
<code className="text-xs text-red-400 font-mono block whitespace-pre-wrap">
|
||||
<code className="text-xs dg-text-red font-mono block whitespace-pre-wrap">
|
||||
{this.state.error.name}: {this.state.error.message}
|
||||
</code>
|
||||
{this.state.errorInfo && (
|
||||
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
Languages,
|
||||
} from 'lucide-react';
|
||||
import { formatDistanceToNow } from '@/lib/utils';
|
||||
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { ThemePicker } from '@/components/layout/ThemePicker';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '@/i18n';
|
||||
|
||||
// Keyboard shortcut handler — Cmd/Ctrl+B 切换侧边栏
|
||||
@@ -65,22 +66,23 @@ export function Header() {
|
||||
const getNotificationIcon = useCallback((type: string) => {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return <AlertTriangle className="h-4 w-4 text-red-500" />;
|
||||
return <AlertTriangle className="h-4 w-4 dg-text-red" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="h-4 w-4 text-amber-500" />;
|
||||
return <AlertTriangle className="h-4 w-4 dg-text-yellow" />;
|
||||
case 'success':
|
||||
return <CheckCircle className="h-4 w-4 text-emerald-500" />;
|
||||
return <CheckCircle className="h-4 w-4 dg-text-green" />;
|
||||
default:
|
||||
return <Info className="h-4 w-4 text-blue-500" />;
|
||||
return <Info className="h-4 w-4 dg-text-blue" />;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'fixed top-0 right-0 z-30 h-[var(--header-height)] bg-[var(--dg-bg-secondary)]/95 backdrop-blur-md',
|
||||
'border-b border-[var(--dg-border)] transition-[margin] duration-300 ease-in-out',
|
||||
'supports-[backdrop-filter]:bg-[var(--dg-bg-secondary)]/75',
|
||||
'fixed top-0 right-0 z-30 h-[var(--layout-header-height)]',
|
||||
'bg-[var(--surface-raised)]/95 backdrop-blur-md',
|
||||
'border-b border-[var(--border-default)] transition-[margin] duration-300 ease-in-out',
|
||||
'supports-[backdrop-filter]:bg-[var(--surface-raised)]/75',
|
||||
)}
|
||||
style={{ marginLeft: 'var(--sidebar-width)', left: 0 }}
|
||||
>
|
||||
@@ -118,38 +120,8 @@ export function Header() {
|
||||
className="w-60 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"
|
||||
style={{ boxShadow: 'var(--shadow-dropdown)' }}
|
||||
>
|
||||
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">{t('header.themeSwitch')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||||
{THEME_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 cursor-pointer transition-colors',
|
||||
'focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)]',
|
||||
theme === option.id
|
||||
? 'text-[var(--dg-text-primary)]'
|
||||
: 'text-[var(--dg-text-secondary)]',
|
||||
)}
|
||||
onClick={() => setTheme(option.id)}
|
||||
>
|
||||
<span
|
||||
className="w-3.5 h-3.5 rounded-full shrink-0 ring-2"
|
||||
style={{
|
||||
backgroundColor: option.primaryColor,
|
||||
boxShadow: '0 0 0 2px var(--dg-bg-primary)',
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{option.name}</span>
|
||||
<span className="block text-xs text-[var(--dg-text-muted)] truncate">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
{theme === option.id && (
|
||||
<Check className="h-4 w-4 text-[var(--dg-blue)] shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuLabel className="text-[var(--text-secondary)]">{t('header.themeSwitch')}</DropdownMenuLabel>
|
||||
<ThemePicker value={theme} onChange={setTheme} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -304,7 +276,7 @@ export function Header() {
|
||||
variant="ghost"
|
||||
className="h-8 gap-2 px-2 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-[var(--dg-blue)] to-[var(--dg-purple)] flex items-center justify-center text-white text-xs font-bold">
|
||||
<div className="w-7 h-7 rounded-full bg-[var(--brand)] flex items-center justify-center text-white text-xs font-bold">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="hidden md:inline text-sm max-w-[100px] truncate">
|
||||
@@ -344,7 +316,7 @@ export function Header() {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||||
<DropdownMenuItem
|
||||
className="text-red-400 focus:bg-red-500/10 focus:text-red-400 cursor-pointer"
|
||||
className="dg-text-red focus:bg-red-500/10 focus:dg-text-red cursor-pointer"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2.5" />
|
||||
|
||||
@@ -9,15 +9,8 @@ import { authApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
/**
|
||||
* MainLayout - 优化后的主布局组件
|
||||
*
|
||||
* 优化点:
|
||||
* 1. 使用 CSS 变量 + Tailwind 类替代硬编码的 style 属性
|
||||
* 2. sidebar 宽度通过 CSS 变量管理,统一协调
|
||||
* 3. 添加 Breadcrumb 面包屑导航
|
||||
* 4. 布局过渡动画平滑
|
||||
* 5. 支持响应式布局
|
||||
* 6. F3: 页面刷新后自动拉取当前用户权限(RBAC 按钮级控制)
|
||||
* MainLayout — 统一应用布局壳
|
||||
* 侧栏 + 顶栏 + 面包屑 + 内容区,间距由 --layout-* 令牌驱动 // Logan.Li
|
||||
*/
|
||||
export function MainLayout() {
|
||||
const { sidebarCollapsed } = useThemeStore();
|
||||
@@ -25,8 +18,6 @@ export function MainLayout() {
|
||||
const permissions = useUserStore((s) => s.permissions);
|
||||
const setPermissions = useUserStore((s) => s.setPermissions);
|
||||
|
||||
// 页面刷新后 zustand persist 会恢复 token/user,但 permissions 可能丢失或过期
|
||||
// 在 MainLayout 挂载时如果 permissions 为 null 则拉取一次
|
||||
useEffect(() => {
|
||||
if (!token || permissions !== null) return;
|
||||
let cancelled = false;
|
||||
@@ -37,7 +28,6 @@ export function MainLayout() {
|
||||
setPermissions(Array.isArray(perms) ? perms : []);
|
||||
})
|
||||
.catch(() => {
|
||||
// 拉取失败不阻塞页面,权限保持 null(usePermission 默认放行)
|
||||
if (!cancelled) setPermissions([]);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
@@ -46,33 +36,29 @@ export function MainLayout() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-[var(--dg-bg-primary)] transition-colors duration-300"
|
||||
className="dg-app-shell"
|
||||
style={{
|
||||
'--sidebar-width': sidebarCollapsed ? '4rem' : '16rem',
|
||||
'--header-height': '3.5rem',
|
||||
'--sidebar-width': sidebarCollapsed
|
||||
? 'var(--layout-sidebar-collapsed)'
|
||||
: 'var(--layout-sidebar-expanded)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Sidebar />
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col min-h-screen transition-[margin] duration-300 ease-in-out',
|
||||
)}
|
||||
className={cn('flex flex-col min-h-screen transition-[margin] duration-300 ease-in-out')}
|
||||
style={{ marginLeft: 'var(--sidebar-width)' }}
|
||||
>
|
||||
<Header />
|
||||
<main className="flex-1 pt-[var(--header-height)]">
|
||||
{/* Breadcrumb Navigation */}
|
||||
<div className="px-6 pt-4 pb-0">
|
||||
<main className="dg-app-main">
|
||||
<div className="dg-app-breadcrumb">
|
||||
<Breadcrumb />
|
||||
</div>
|
||||
{/* Page Content */}
|
||||
<div className="p-6">
|
||||
<div className="dg-app-content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
{/* Footer */}
|
||||
<footer className="px-6 py-4 border-t border-[var(--dg-border)] mt-auto">
|
||||
<div className="flex items-center justify-between text-xs text-[var(--dg-text-muted)]">
|
||||
<footer className="dg-app-footer">
|
||||
<div className="flex items-center justify-between text-xs dg-text-muted">
|
||||
<span>AIOps 智能运维平台</span>
|
||||
<span>v1.0.0</span>
|
||||
</div>
|
||||
@@ -80,4 +66,4 @@ export function MainLayout() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// 页面级空状态 // Logan.Li
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PageEmptyProps {
|
||||
icon?: ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageEmpty({
|
||||
icon,
|
||||
title = '暂无数据',
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: PageEmptyProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-16 text-center', className)}>
|
||||
{icon && <div className="mb-3 opacity-40 text-[var(--text-tertiary)]">{icon}</div>}
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{title}</p>
|
||||
{description && <p className="text-sm dg-text-muted mt-1 max-w-sm">{description}</p>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 页面级加载态 // Logan.Li
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PageLoadingProps {
|
||||
label?: string;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function PageLoading({ label = '加载中...', className, compact }: PageLoadingProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center dg-text-muted',
|
||||
compact ? 'py-12' : 'py-20',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-6 w-6 animate-spin mb-2" />
|
||||
<span className="text-sm">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// 统一页面骨架 — 标题区 / 指标条 / 工具栏 / Tab / 内容区 // Logan.Li
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PageTabs, type PageTabItem } from './PageTabs';
|
||||
import { PageLoading } from './PageLoading';
|
||||
|
||||
export type PageShellVariant = 'default' | 'list' | 'config' | 'workspace';
|
||||
|
||||
const maxWidthClass: Record<string, string> = {
|
||||
full: 'w-full',
|
||||
lg: 'max-w-6xl mx-auto w-full',
|
||||
xl: 'max-w-7xl mx-auto w-full',
|
||||
'3xl': 'max-w-3xl mx-auto w-full',
|
||||
};
|
||||
|
||||
export interface PageShellProps {
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
/** 标题左侧图标(建议单色 lucide,勿用渐变底) */
|
||||
icon?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
/** 标题下方指标条(StatStrip 等) */
|
||||
metrics?: ReactNode;
|
||||
/** 筛选 / 搜索工具栏,与标题操作区分开 */
|
||||
toolbar?: ReactNode;
|
||||
tabs?: PageTabItem[];
|
||||
activeTab?: string;
|
||||
onTabChange?: (id: string) => void;
|
||||
variant?: PageShellVariant;
|
||||
maxWidth?: keyof typeof maxWidthClass;
|
||||
loading?: boolean;
|
||||
loadingLabel?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
export function PageShell({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
actions,
|
||||
metrics,
|
||||
toolbar,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
variant = 'default',
|
||||
maxWidth = 'full',
|
||||
loading = false,
|
||||
loadingLabel,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: PageShellProps) {
|
||||
const width = maxWidthClass[maxWidth] ?? maxWidthClass.full;
|
||||
const isConfig = variant === 'config' || maxWidth === '3xl';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'dg-page',
|
||||
width,
|
||||
variant === 'workspace' && 'flex flex-col min-h-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<header className="dg-page-header">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
{icon && (
|
||||
<div className="dg-icon-box">{icon}</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="dg-page-title truncate">{title}</h1>
|
||||
{subtitle != null && subtitle !== '' && (
|
||||
<p className="dg-page-subtitle">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="dg-page-actions shrink-0">{actions}</div>}
|
||||
</header>
|
||||
|
||||
{metrics && <section className="animate-fade-in-delay-1">{metrics}</section>}
|
||||
|
||||
{tabs && tabs.length > 0 && activeTab && onTabChange && (
|
||||
<PageTabs items={tabs} value={activeTab} onChange={onTabChange} />
|
||||
)}
|
||||
|
||||
{toolbar && (
|
||||
<div className="dg-page-toolbar animate-fade-in-delay-1">{toolbar}</div>
|
||||
)}
|
||||
|
||||
<section
|
||||
className={cn(
|
||||
'animate-fade-in-delay-2',
|
||||
variant === 'workspace' && 'flex-1 min-h-0 flex flex-col',
|
||||
isConfig && 'space-y-4',
|
||||
variant === 'list' && 'space-y-0',
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{loading ? <PageLoading label={loadingLabel} /> : children}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// 页面级 Tab 导航 — 与内容区筛选分离 // Logan.Li
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface PageTabItem {
|
||||
id: string;
|
||||
label: string;
|
||||
badge?: number | string;
|
||||
}
|
||||
|
||||
interface PageTabsProps {
|
||||
items: PageTabItem[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageTabs({ items, value, onChange, className }: PageTabsProps) {
|
||||
return (
|
||||
<nav
|
||||
className={cn('dg-page-tabs flex gap-0.5 border-b border-[var(--border-default)]', className)}
|
||||
role="tablist"
|
||||
>
|
||||
{items.map((tab) => {
|
||||
const active = value === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={cn(
|
||||
'dg-page-tab px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px',
|
||||
active
|
||||
? 'text-[var(--brand)] border-[var(--brand)]'
|
||||
: 'dg-text-secondary border-transparent hover:text-[var(--text-primary)]',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge != null && (
|
||||
<span className="ml-1.5 text-xs dg-text-muted">({tab.badge})</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -380,8 +380,8 @@ export function Sidebar() {
|
||||
{/* Logo Area */}
|
||||
<div className="flex items-center h-14 px-3 border-b border-[var(--dg-border)] flex-shrink-0">
|
||||
<div className={cn('flex items-center gap-2.5 flex-1 min-w-0', sidebarCollapsed && 'justify-center')}>
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[var(--dg-blue)] to-[var(--dg-cyan)] flex items-center justify-center flex-shrink-0 shadow-lg shadow-[var(--dg-blue)]/20">
|
||||
<Cloud className="h-5 w-5 text-white" />
|
||||
<div className="dg-icon-box w-8 h-8 rounded-lg flex-shrink-0">
|
||||
<Cloud className="h-5 w-5" />
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="font-bold text-[var(--dg-text-primary)] text-sm tracking-tight truncate">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// 全局主题选择器 — Header / Login 共用,分组展示主流与开发者主题 // Logan.Li
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { THEME_GROUPS, type ThemePreference } from '@/utils/theme';
|
||||
|
||||
interface ThemePickerProps {
|
||||
value: ThemePreference;
|
||||
onChange: (theme: ThemePreference) => void;
|
||||
}
|
||||
|
||||
export function ThemePicker({ value, onChange }: ThemePickerProps) {
|
||||
return (
|
||||
<>
|
||||
{THEME_GROUPS.map((group, gi) => (
|
||||
<div key={group.label}>
|
||||
{gi > 0 && <DropdownMenuSeparator className="bg-[var(--border-default)]" />}
|
||||
<DropdownMenuLabel className="text-xs dg-text-muted font-normal py-1">
|
||||
{group.label}
|
||||
</DropdownMenuLabel>
|
||||
{group.options.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 cursor-pointer transition-colors',
|
||||
'focus:bg-[var(--surface-hover)] focus:text-[var(--text-primary)]',
|
||||
value === option.id
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'dg-text-secondary',
|
||||
)}
|
||||
onClick={() => onChange(option.id)}
|
||||
>
|
||||
<span
|
||||
className="w-3.5 h-3.5 rounded-full shrink-0"
|
||||
style={{
|
||||
background: option.primaryColor.startsWith('linear')
|
||||
? option.primaryColor
|
||||
: option.primaryColor,
|
||||
boxShadow: '0 0 0 2px var(--surface-canvas)',
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{option.name}</span>
|
||||
<span className="block text-xs dg-text-muted truncate">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
{value === option.id && (
|
||||
<Check className="h-4 w-4 text-[var(--brand)] shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user