mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
feat(frontend): donate link, panel version label, login lang menu
- Sidebar: add heart donate link to https://donate.sanaei.dev and small panel version under 3X-UI brand - Login: swap settings-cog for translation icon, drop title, render languages as a direct list - Vite dev: inject window.X_UI_CUR_VER from config/version so dev mode matches prod - Translations: add menu.donate across all locales
This commit is contained in:
parent
f929ea4b14
commit
dcb837f4e1
18 changed files with 198 additions and 54 deletions
|
|
@ -30,14 +30,68 @@
|
|||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
flex: 1 1 auto;
|
||||
.brand-block {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed .brand-text {
|
||||
.brand-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand-version {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed .brand-block {
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-donate {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-donate:hover,
|
||||
.sidebar-donate:focus-visible {
|
||||
background-color: rgba(236, 72, 153, 0.12);
|
||||
color: #ec4899;
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-donate .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar-theme-cycle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
|
@ -197,6 +251,14 @@ html[data-theme='ultra-dark'] .sidebar-theme-cycle {
|
|||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .sidebar-donate {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .sidebar-donate {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-drawer .ant-drawer-content,
|
||||
body.dark .ant-drawer .ant-drawer-body {
|
||||
background: #252526 !important;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ClusterOutlined,
|
||||
CloseOutlined,
|
||||
DashboardOutlined,
|
||||
HeartOutlined,
|
||||
LogoutOutlined,
|
||||
MenuOutlined,
|
||||
SettingOutlined,
|
||||
|
|
@ -21,6 +22,7 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
|||
import './AppSidebar.css';
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||
|
||||
interface AppSidebarProps {
|
||||
basePath?: string;
|
||||
|
|
@ -48,6 +50,21 @@ function readCollapsed(): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
||||
return (
|
||||
<a
|
||||
href={DONATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sidebar-donate"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
>
|
||||
<HeartOutlined />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
|
||||
id: string;
|
||||
isDark: boolean;
|
||||
|
|
@ -92,6 +109,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
|
|||
|
||||
const prefix = basePath.startsWith('/') ? basePath : `/${basePath || ''}`;
|
||||
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
|
||||
const panelVersion = window.X_UI_CUR_VER || '';
|
||||
|
||||
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
|
||||
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||
|
|
@ -165,15 +183,23 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
|
|||
onCollapse={onSiderCollapse}
|
||||
>
|
||||
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
|
||||
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
|
||||
<div className="brand-block">
|
||||
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
|
||||
{!collapsed && panelVersion && (
|
||||
<span className="brand-version">v{panelVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle"
|
||||
isDark={isDark}
|
||||
isUltra={isUltra}
|
||||
onCycle={() => cycleTheme('theme-cycle')}
|
||||
ariaLabel={t('menu.theme')}
|
||||
/>
|
||||
<div className="brand-actions">
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle"
|
||||
isDark={isDark}
|
||||
isUltra={isUltra}
|
||||
onCycle={() => cycleTheme('theme-cycle')}
|
||||
ariaLabel={t('menu.theme')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
|
|
@ -208,8 +234,12 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
|
|||
onClose={() => setDrawerOpen(false)}
|
||||
>
|
||||
<div className="drawer-header">
|
||||
<span className="drawer-brand">3X-UI</span>
|
||||
<div className="brand-block">
|
||||
<span className="drawer-brand">3X-UI</span>
|
||||
{panelVersion && <span className="brand-version">v{panelVersion}</span>}
|
||||
</div>
|
||||
<div className="drawer-header-actions">
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle-drawer"
|
||||
isDark={isDark}
|
||||
|
|
|
|||
|
|
@ -402,10 +402,44 @@
|
|||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-popover {
|
||||
min-width: 220px;
|
||||
.lang-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.lang-select {
|
||||
.lang-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.lang-item:hover,
|
||||
.lang-item:focus-visible {
|
||||
background-color: rgba(99, 102, 241, 0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.lang-item.is-active {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lang-item-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,13 @@ import {
|
|||
Input,
|
||||
Layout,
|
||||
Popover,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
KeyOutlined,
|
||||
LockOutlined,
|
||||
SettingOutlined,
|
||||
TranslationOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
|
|
@ -107,16 +105,8 @@ export default function LoginPage() {
|
|||
return classes.join(' ');
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
const langOptions = useMemo(
|
||||
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
|
||||
value: l.value,
|
||||
label: (
|
||||
<>
|
||||
<span aria-label={l.name}>{l.icon}</span>
|
||||
<span>{l.name}</span>
|
||||
</>
|
||||
),
|
||||
})),
|
||||
const langList = useMemo(
|
||||
() => LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[],
|
||||
[],
|
||||
);
|
||||
|
||||
|
|
@ -154,26 +144,31 @@ export default function LoginPage() {
|
|||
</button>
|
||||
<Popover
|
||||
rootClassName={isDark ? 'dark' : 'light'}
|
||||
title={t('pages.settings.language')}
|
||||
placement="bottomRight"
|
||||
trigger="click"
|
||||
content={
|
||||
<Space orientation="vertical" size={10} className="settings-popover">
|
||||
<Select
|
||||
className="lang-select"
|
||||
value={lang}
|
||||
onChange={onLangChange}
|
||||
options={langOptions}
|
||||
/>
|
||||
</Space>
|
||||
<ul className="lang-list">
|
||||
{langList.map((l) => (
|
||||
<li key={l.value}>
|
||||
<button
|
||||
type="button"
|
||||
className={`lang-item${lang === l.value ? ' is-active' : ''}`}
|
||||
onClick={() => onLangChange(l.value)}
|
||||
>
|
||||
<span className="lang-item-icon" aria-hidden="true">{l.icon}</span>
|
||||
<span className="lang-item-name">{l.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
className="toolbar-btn"
|
||||
aria-label={t('menu.settings')}
|
||||
icon={<SettingOutlined />}
|
||||
aria-label={t('pages.settings.language')}
|
||||
icon={<TranslationOutlined />}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -67,8 +67,17 @@ function refreshBasePath() {
|
|||
return cachedBasePath;
|
||||
}
|
||||
|
||||
function readPanelVersion() {
|
||||
try {
|
||||
const versionFile = path.resolve(__dirname, '..', 'config', 'version');
|
||||
return fs.readFileSync(versionFile, 'utf8').trim();
|
||||
} catch (_e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// `apply: 'serve'` keeps the injection out of `vite build` — dist.go
|
||||
// already injects webBasePath at runtime in production.
|
||||
// already injects webBasePath and version at runtime in production.
|
||||
function injectBasePathPlugin() {
|
||||
return {
|
||||
name: 'xui-inject-base-path',
|
||||
|
|
@ -76,7 +85,8 @@ function injectBasePathPlugin() {
|
|||
transformIndexHtml(html) {
|
||||
const basePath = refreshBasePath();
|
||||
const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const tag = `<script>window.X_UI_BASE_PATH="${escaped}";</script>`;
|
||||
const version = readPanelVersion().replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const tag = `<script>window.X_UI_BASE_PATH="${escaped}";window.X_UI_CUR_VER="${version}";</script>`;
|
||||
return html.replace('</head>', `${tag}</head>`);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@
|
|||
"xray": "إعدادات Xray",
|
||||
"apiDocs": "توثيق API",
|
||||
"logout": "تسجيل خروج",
|
||||
"link": "إدارة"
|
||||
"link": "إدارة",
|
||||
"donate": "تبرع"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@
|
|||
"xray": "Xray Configs",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Log Out",
|
||||
"link": "Manage"
|
||||
"link": "Manage",
|
||||
"donate": "Donate"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@
|
|||
"xray": "Ajustes Xray",
|
||||
"apiDocs": "Documentación de la API",
|
||||
"logout": "Cerrar Sesión",
|
||||
"link": "Gestionar"
|
||||
"link": "Gestionar",
|
||||
"donate": "Donar"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@
|
|||
"xray": "پیکربندی ایکسری",
|
||||
"apiDocs": "مستندات API",
|
||||
"logout": "خروج",
|
||||
"link": "مدیریت"
|
||||
"link": "مدیریت",
|
||||
"donate": "حمایت مالی"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@
|
|||
"xray": "Konfigurasi Xray",
|
||||
"apiDocs": "Dokumentasi API",
|
||||
"logout": "Keluar",
|
||||
"link": "Kelola"
|
||||
"link": "Kelola",
|
||||
"donate": "Donasi"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@
|
|||
"xray": "Xray設定",
|
||||
"apiDocs": "API ドキュメント",
|
||||
"logout": "ログアウト",
|
||||
"link": "リンク管理"
|
||||
"link": "リンク管理",
|
||||
"donate": "寄付"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@
|
|||
"xray": "Xray Configs",
|
||||
"apiDocs": "Documentação da API",
|
||||
"logout": "Sair",
|
||||
"link": "Gerenciar"
|
||||
"link": "Gerenciar",
|
||||
"donate": "Doar"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@
|
|||
"xray": "Настройки Xray",
|
||||
"apiDocs": "Документация API",
|
||||
"logout": "Выход",
|
||||
"link": "Управление"
|
||||
"link": "Управление",
|
||||
"donate": "Поддержать"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@
|
|||
"xray": "Xray Yapılandırmaları",
|
||||
"apiDocs": "API Belgeleri",
|
||||
"logout": "Çıkış Yap",
|
||||
"link": "Yönet"
|
||||
"link": "Yönet",
|
||||
"donate": "Bağış Yap"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@
|
|||
"xray": "Конфігурації Xray",
|
||||
"apiDocs": "Документація API",
|
||||
"logout": "Вийти",
|
||||
"link": "Керувати"
|
||||
"link": "Керувати",
|
||||
"donate": "Підтримати"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@
|
|||
"xray": "Cài đặt Xray",
|
||||
"apiDocs": "Tài liệu API",
|
||||
"logout": "Đăng xuất",
|
||||
"link": "Quản lý"
|
||||
"link": "Quản lý",
|
||||
"donate": "Quyên góp"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@
|
|||
"xray": "Xray 设置",
|
||||
"apiDocs": "API 文档",
|
||||
"logout": "退出登录",
|
||||
"link": "管理"
|
||||
"link": "管理",
|
||||
"donate": "捐赠"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@
|
|||
"xray": "Xray 設定",
|
||||
"apiDocs": "API 文件",
|
||||
"logout": "退出登入",
|
||||
"link": "管理"
|
||||
"link": "管理",
|
||||
"donate": "捐贈"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue