Jelajahi Sumber

feat(ui): redesign admin shell with grouped dark sidebar

AI-Co-Authored-By: Grok
chendeben 1 bulan lalu
induk
melakukan
5c6851c399
2 mengubah file dengan 186 tambahan dan 33 penghapusan
  1. 114 8
      src/frontend/styles.css
  2. 72 25
      src/layouts/AdminLayout.tsx

+ 114 - 8
src/frontend/styles.css

@@ -42,15 +42,96 @@ body {
   border-right: 1px solid rgba(255, 255, 255, 0.06);
   height: 100vh;
   max-height: 100vh;
-  overflow-y: auto;
+  overflow: hidden;
   position: sticky !important;
   top: 0;
   z-index: 20;
 }
 
+.admin-sider-inner {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 100%;
+}
+
+.admin-sider .admin-menu {
+  background: transparent;
+  border-inline-end: none !important;
+  flex: 1;
+  overflow-y: auto;
+  padding: 4px 12px 16px;
+}
+
+.admin-sider .ant-menu-dark.ant-menu-inline .ant-menu-item {
+  border-radius: 10px;
+  height: 40px;
+  line-height: 40px;
+  margin-block: 2px;
+  width: 100%;
+}
+
+.admin-sider .ant-menu-dark .ant-menu-item-selected {
+  background: rgba(79, 70, 229, 0.22) !important;
+  color: #e0e7ff !important;
+}
+
+.admin-sider .ant-menu-dark .ant-menu-item-selected .anticon {
+  color: #c7d2fe;
+}
+
+.admin-sider .ant-menu-dark .ant-menu-item:not(.ant-menu-item-selected):hover {
+  background: rgba(255, 255, 255, 0.06) !important;
+  color: #f8fafc !important;
+}
+
+.admin-sider .ant-menu-dark .ant-menu-item-group-title {
+  color: rgba(255, 255, 255, 0.38);
+  font-size: 11px;
+  font-weight: 600;
+  letter-spacing: 0.06em;
+  padding: 16px 12px 8px;
+  text-transform: uppercase;
+}
+
+.admin-sider-footer {
+  align-items: center;
+  border-top: 1px solid rgba(255, 255, 255, 0.06);
+  display: flex;
+  gap: 10px;
+  margin-top: auto;
+  padding: 14px 16px 16px;
+}
+
+.sider-user-avatar {
+  background: rgba(99, 102, 241, 0.35) !important;
+  color: #e0e7ff !important;
+  flex-shrink: 0;
+}
+
+.sider-user-meta {
+  min-width: 0;
+}
+
+.sider-user-name {
+  color: #f8fafc;
+  font-size: 13px;
+  font-weight: 600;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.sider-user-role {
+  color: rgba(255, 255, 255, 0.45);
+  font-size: 11px;
+  text-transform: capitalize;
+}
+
 .brand {
   align-items: center;
   display: flex;
+  flex-shrink: 0;
   gap: 12px;
   min-height: 72px;
   padding: 18px 20px;
@@ -58,13 +139,16 @@ body {
 
 .brand-logo {
   align-items: center;
-  background: var(--mh-surface);
-  border-radius: var(--mh-radius-control);
-  color: var(--mh-primary);
+  background: linear-gradient(135deg, #6366f1, #8b5cf6);
+  border-radius: 10px;
+  color: #fff;
   display: inline-flex;
+  flex-shrink: 0;
+  font-size: 13px;
   font-weight: 800;
   height: 36px;
   justify-content: center;
+  letter-spacing: 0.02em;
   width: 36px;
 }
 
@@ -72,11 +156,14 @@ body {
   color: #ffffff;
   font-size: 16px;
   font-weight: 700;
+  line-height: 1.2;
 }
 
 .brand-subtitle {
   color: rgba(255, 255, 255, 0.56);
   font-size: 12px;
+  line-height: 1.3;
+  margin-top: 2px;
 }
 
 .admin-header {
@@ -86,8 +173,8 @@ body {
   display: flex;
   height: auto;
   justify-content: space-between;
-  min-height: 72px;
-  padding: 14px 24px;
+  min-height: 64px;
+  padding: 12px 24px;
   position: sticky;
   top: 0;
   z-index: 10;
@@ -96,6 +183,21 @@ body {
 .header-title {
   display: grid;
   gap: 4px;
+  min-width: 0;
+}
+
+.header-breadcrumb.ant-breadcrumb {
+  font-size: 15px;
+  font-weight: 600;
+  line-height: 1.35;
+}
+
+.header-breadcrumb .ant-breadcrumb-link {
+  color: var(--mh-ink);
+}
+
+.header-actions {
+  flex-shrink: 0;
 }
 
 .runtime-line {
@@ -108,7 +210,7 @@ body {
 
 .admin-content {
   background: var(--mh-canvas);
-  padding: 24px;
+  padding: 28px 28px 40px;
 }
 
 .ant-card {
@@ -479,7 +581,11 @@ body {
   }
 
   .admin-content {
-    padding: 16px;
+    padding: 16px 16px 28px;
+  }
+
+  .admin-sider .admin-menu {
+    overflow-y: visible;
   }
 
   .toolbar-search,

+ 72 - 25
src/layouts/AdminLayout.tsx

@@ -20,16 +20,38 @@ import type { User, ViewKey } from '../frontend/types';
 
 const { Header, Sider, Content } = Layout;
 
-const navItems: Array<{ key: ViewKey; labelKey: string; icon: ReactNode }> = [
-  { key: 'dashboard', labelKey: 'nav.dashboard', icon: <DashboardOutlined /> },
-  { key: 'domains', labelKey: 'nav.domains', icon: <GlobalOutlined /> },
-  { key: 'dns-api', labelKey: 'nav.dnsApi', icon: <CloudServerOutlined /> },
-  { key: 'smtp', labelKey: 'nav.smtp', icon: <MailOutlined /> },
-  { key: 'tokens', labelKey: 'nav.tokens', icon: <KeyOutlined /> },
-  { key: 'logs', labelKey: 'nav.logs', icon: <SendOutlined /> },
-  { key: 'webhooks', labelKey: 'nav.webhooks', icon: <ApiOutlined /> },
-  { key: 'admin', labelKey: 'nav.admin', icon: <SafetyCertificateOutlined /> },
-  { key: 'settings', labelKey: 'nav.settings', icon: <SettingOutlined /> }
+const navGroups: Array<{
+  key: string;
+  labelKey: string;
+  items: Array<{ key: ViewKey; labelKey: string; icon: ReactNode; adminOnly?: boolean }>;
+}> = [
+  {
+    key: 'overview',
+    labelKey: 'nav.group.overview',
+    items: [
+      { key: 'dashboard', labelKey: 'nav.dashboard', icon: <DashboardOutlined /> },
+      { key: 'domains', labelKey: 'nav.domains', icon: <GlobalOutlined /> },
+      { key: 'dns-api', labelKey: 'nav.dnsApi', icon: <CloudServerOutlined /> }
+    ]
+  },
+  {
+    key: 'delivery',
+    labelKey: 'nav.group.delivery',
+    items: [
+      { key: 'smtp', labelKey: 'nav.smtp', icon: <MailOutlined /> },
+      { key: 'tokens', labelKey: 'nav.tokens', icon: <KeyOutlined /> },
+      { key: 'logs', labelKey: 'nav.logs', icon: <SendOutlined /> },
+      { key: 'webhooks', labelKey: 'nav.webhooks', icon: <ApiOutlined /> }
+    ]
+  },
+  {
+    key: 'system',
+    labelKey: 'nav.group.system',
+    items: [
+      { key: 'admin', labelKey: 'nav.admin', icon: <SafetyCertificateOutlined />, adminOnly: true },
+      { key: 'settings', labelKey: 'nav.settings', icon: <SettingOutlined /> }
+    ]
+  }
 ];
 
 interface AdminLayoutProps {
@@ -58,35 +80,60 @@ export function AdminLayout({
   onLogout
 }: AdminLayoutProps) {
   const { locale, locales, setLocale, t } = useI18n();
-  const visibleNavItems = navItems.filter((item) => item.key !== 'admin' || user?.role === 'admin');
+  const isAdmin = user?.role === 'admin';
+
+  const menuItems = navGroups.map((group) => ({
+    type: 'group' as const,
+    key: group.key,
+    label: t(group.labelKey),
+    children: group.items
+      .filter((item) => !item.adminOnly || isAdmin)
+      .map((item) => ({
+        key: item.key,
+        icon: item.icon,
+        label: t(item.labelKey)
+      }))
+  }));
 
   return (
     <Layout className="admin-layout">
       <Sider breakpoint="lg" collapsedWidth={0} width={248} className="admin-sider">
-        <div className="brand">
-          <div className="brand-logo">MH</div>
-          <div>
-            <div className="brand-title">MailHub</div>
-            <div className="brand-subtitle">Email Delivery</div>
+        <div className="admin-sider-inner">
+          <div className="brand">
+            <div className="brand-logo">MH</div>
+            <div>
+              <div className="brand-title">MailHub</div>
+              <div className="brand-subtitle">Email Delivery</div>
+            </div>
           </div>
+          <Menu
+            theme="dark"
+            mode="inline"
+            selectedKeys={[activeView]}
+            items={menuItems}
+            onClick={({ key }) => onViewChange(key as ViewKey)}
+            className="admin-menu"
+          />
+          {user ? (
+            <div className="admin-sider-footer">
+              <Avatar size={28} icon={<UserOutlined />} className="sider-user-avatar" />
+              <div className="sider-user-meta">
+                <div className="sider-user-name">{user.username || t('common.user')}</div>
+                <div className="sider-user-role">{user.role || '—'}</div>
+              </div>
+            </div>
+          ) : null}
         </div>
-        <Menu
-          theme="dark"
-          mode="inline"
-          selectedKeys={[activeView]}
-          items={visibleNavItems.map((item) => ({ key: item.key, icon: item.icon, label: t(item.labelKey) }))}
-          onClick={({ key }) => onViewChange(key as ViewKey)}
-        />
       </Sider>
       <Layout>
         <Header className="admin-header">
           <div className="header-title">
-            <Breadcrumb items={breadcrumb.map((title) => ({ title }))} />
+            <Breadcrumb items={breadcrumb.map((title) => ({ title }))} className="header-breadcrumb" />
             <Typography.Text type="secondary" className="runtime-line">
               {runtimeLine}
             </Typography.Text>
           </div>
-          <Space wrap>
+          <Space wrap className="header-actions">
             <Select
               aria-label="Language"
               value={locale}