chore(ui): polish empty states + sidebar icon + i18n page titles

- AppSidebar: switch the inbounds icon from UserOutlined (a single
  person — wrong semantic) to ImportOutlined, matching the empty-state
  icon and reflecting the actual concept of an incoming entry point.
- usePageTitle: stop hardcoding English titles; resolve them through
  i18n (menu.* keys are already translated), so the browser tab now
  follows the active language.
- InboundList / NodeList: replace the bare "—" empty cell with a
  centered icon + t('noData') message (ImportOutlined for inbounds,
  ClusterOutlined for nodes), and swap opacity:0.4 for
  var(--ant-color-text-secondary) so the text stays readable on the
  light theme's tinted card background.
This commit is contained in:
MHSanaei 2026-05-27 15:06:57 +02:00
parent 2bba1d21d2
commit 6286bb8676
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
6 changed files with 54 additions and 20 deletions

View file

@ -10,6 +10,7 @@ import {
CloseOutlined,
DashboardOutlined,
HeartOutlined,
ImportOutlined,
LogoutOutlined,
MenuOutlined,
MoonFilled,
@ -18,7 +19,6 @@ import {
SunOutlined,
TeamOutlined,
ToolOutlined,
UserOutlined,
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
@ -29,11 +29,11 @@ const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const DONATE_URL = 'https://donate.sanaei.dev/';
const LOGOUT_KEY = '__logout__';
type IconName = 'dashboard' | 'user' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
type IconName = 'dashboard' | 'inbound' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
const iconByName: Record<IconName, ComponentType> = {
dashboard: DashboardOutlined,
user: UserOutlined,
inbound: ImportOutlined,
team: TeamOutlined,
setting: SettingOutlined,
tool: ToolOutlined,
@ -101,7 +101,7 @@ export default function AppSidebar() {
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
{ key: '/inbounds', icon: 'user', title: t('menu.inbounds') },
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
{ key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') },

View file

@ -1,22 +1,25 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const TITLES: Record<string, string> = {
'/': 'Overview',
'/inbounds': 'Inbounds',
'/clients': 'Clients',
'/nodes': 'Nodes',
'/settings': 'Settings',
'/xray': 'Xray Config',
'/api-docs': 'API Docs',
const TITLE_KEYS: Record<string, string> = {
'/': 'menu.dashboard',
'/inbounds': 'menu.inbounds',
'/clients': 'menu.clients',
'/nodes': 'menu.nodes',
'/settings': 'menu.settings',
'/xray': 'menu.xray',
'/api-docs': 'menu.apiDocs',
};
export function usePageTitle() {
const { pathname } = useLocation();
const { t } = useTranslation();
useEffect(() => {
const title = TITLES[pathname] || '3X-UI';
const key = TITLE_KEYS[pathname];
const title = key ? t(key) : '3X-UI';
const host = window.location.hostname;
document.title = host ? `${host} - ${title}` : title;
}, [pathname]);
}, [pathname, t]);
}

View file

@ -132,8 +132,12 @@
.card-empty {
text-align: center;
opacity: 0.4;
padding: 20px 0;
color: var(--ant-color-text-secondary);
padding: 24px 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
@media (max-width: 768px) {

View file

@ -600,7 +600,10 @@ export default function InboundList({
{isMobile ? (
<div className="inbound-cards">
{sortedInbounds.length === 0 ? (
<div className="card-empty"></div>
<div className="card-empty">
<ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
<div>{t('noData')}</div>
</div>
) : (
sortedInbounds.map((record) => (
<div key={record.id} className="inbound-card">
@ -641,6 +644,14 @@ export default function InboundList({
scroll={{ x: 1000 }}
style={{ marginTop: 10 }}
size="small"
locale={{
emptyText: (
<div className="card-empty">
<ImportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
onChange={(_p, _f, sorter) => {
const single = Array.isArray(sorter) ? sorter[0] : sorter;
const colKey = (single?.columnKey || single?.field) as SortKey | undefined;

View file

@ -135,6 +135,10 @@
.card-empty {
text-align: center;
opacity: 0.4;
padding: 20px 0;
color: var(--ant-color-text-secondary);
padding: 24px 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}

View file

@ -15,6 +15,7 @@ import {
import type { BadgeProps } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ClusterOutlined,
DeleteOutlined,
EditOutlined,
ExclamationCircleOutlined,
@ -279,7 +280,10 @@ export default function NodeList({
<>
<div className="node-cards">
{dataSource.length === 0 ? (
<div className="card-empty"></div>
<div className="card-empty">
<ClusterOutlined style={{ fontSize: 28, opacity: 0.5 }} />
<div>{t('noData')}</div>
</div>
) : (
dataSource.map((record) => (
<div key={record.id} className="node-card">
@ -435,6 +439,14 @@ export default function NodeList({
scroll={{ x: 'max-content' }}
size="middle"
rowKey="id"
locale={{
emptyText: (
<div className="card-empty">
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
expandable={{
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
}}