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:
MHSanaei 2026-05-23 11:39:09 +02:00
parent f929ea4b14
commit dcb837f4e1
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
18 changed files with 198 additions and 54 deletions

View file

@ -30,14 +30,68 @@
letter-spacing: 0; letter-spacing: 0;
} }
.brand-text { .brand-block {
flex: 1 1 auto; 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; 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 { .sidebar-theme-cycle {
background: transparent; background: transparent;
border: none; border: none;
@ -197,6 +251,14 @@ html[data-theme='ultra-dark'] .sidebar-theme-cycle {
color: rgba(255, 255, 255, 0.92); 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-content,
body.dark .ant-drawer .ant-drawer-body { body.dark .ant-drawer .ant-drawer-body {
background: #252526 !important; background: #252526 !important;

View file

@ -8,6 +8,7 @@ import {
ClusterOutlined, ClusterOutlined,
CloseOutlined, CloseOutlined,
DashboardOutlined, DashboardOutlined,
HeartOutlined,
LogoutOutlined, LogoutOutlined,
MenuOutlined, MenuOutlined,
SettingOutlined, SettingOutlined,
@ -21,6 +22,7 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import './AppSidebar.css'; import './AppSidebar.css';
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed'; const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const DONATE_URL = 'https://donate.sanaei.dev/';
interface AppSidebarProps { interface AppSidebarProps {
basePath?: string; 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 }: { function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
id: string; id: string;
isDark: boolean; isDark: boolean;
@ -92,6 +109,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
const prefix = basePath.startsWith('/') ? basePath : `/${basePath || ''}`; const prefix = basePath.startsWith('/') ? basePath : `/${basePath || ''}`;
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light'; const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
const panelVersion = window.X_UI_CUR_VER || '';
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [ const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') }, { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
@ -165,8 +183,15 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
onCollapse={onSiderCollapse} onCollapse={onSiderCollapse}
> >
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}> <div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
<div className="brand-block">
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span> <span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
{!collapsed && panelVersion && (
<span className="brand-version">v{panelVersion}</span>
)}
</div>
{!collapsed && ( {!collapsed && (
<div className="brand-actions">
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton <ThemeCycleButton
id="theme-cycle" id="theme-cycle"
isDark={isDark} isDark={isDark}
@ -174,6 +199,7 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
onCycle={() => cycleTheme('theme-cycle')} onCycle={() => cycleTheme('theme-cycle')}
ariaLabel={t('menu.theme')} ariaLabel={t('menu.theme')}
/> />
</div>
)} )}
</div> </div>
<Menu <Menu
@ -208,8 +234,12 @@ export default function AppSidebar({ basePath = '', requestUri = '' }: AppSideba
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
> >
<div className="drawer-header"> <div className="drawer-header">
<div className="brand-block">
<span className="drawer-brand">3X-UI</span> <span className="drawer-brand">3X-UI</span>
{panelVersion && <span className="brand-version">v{panelVersion}</span>}
</div>
<div className="drawer-header-actions"> <div className="drawer-header-actions">
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton <ThemeCycleButton
id="theme-cycle-drawer" id="theme-cycle-drawer"
isDark={isDark} isDark={isDark}

View file

@ -402,10 +402,44 @@
margin-bottom: 0; margin-bottom: 0;
} }
.settings-popover { .lang-list {
min-width: 220px; 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%; 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;
} }

View file

@ -7,15 +7,13 @@ import {
Input, Input,
Layout, Layout,
Popover, Popover,
Select,
Space,
Spin, Spin,
message, message,
} from 'antd'; } from 'antd';
import { import {
KeyOutlined, KeyOutlined,
LockOutlined, LockOutlined,
SettingOutlined, TranslationOutlined,
UserOutlined, UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@ -107,16 +105,8 @@ export default function LoginPage() {
return classes.join(' '); return classes.join(' ');
}, [isDark, isUltra]); }, [isDark, isUltra]);
const langOptions = useMemo( const langList = useMemo(
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({ () => LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[],
value: l.value,
label: (
<>
<span aria-label={l.name}>{l.icon}</span>
&nbsp;&nbsp;<span>{l.name}</span>
</>
),
})),
[], [],
); );
@ -154,26 +144,31 @@ export default function LoginPage() {
</button> </button>
<Popover <Popover
rootClassName={isDark ? 'dark' : 'light'} rootClassName={isDark ? 'dark' : 'light'}
title={t('pages.settings.language')}
placement="bottomRight" placement="bottomRight"
trigger="click" trigger="click"
content={ content={
<Space orientation="vertical" size={10} className="settings-popover"> <ul className="lang-list">
<Select {langList.map((l) => (
className="lang-select" <li key={l.value}>
value={lang} <button
onChange={onLangChange} type="button"
options={langOptions} className={`lang-item${lang === l.value ? ' is-active' : ''}`}
/> onClick={() => onLangChange(l.value)}
</Space> >
<span className="lang-item-icon" aria-hidden="true">{l.icon}</span>
<span className="lang-item-name">{l.name}</span>
</button>
</li>
))}
</ul>
} }
> >
<Button <Button
shape="circle" shape="circle"
size="large" size="large"
className="toolbar-btn" className="toolbar-btn"
aria-label={t('menu.settings')} aria-label={t('pages.settings.language')}
icon={<SettingOutlined />} icon={<TranslationOutlined />}
/> />
</Popover> </Popover>
</div> </div>

View file

@ -67,8 +67,17 @@ function refreshBasePath() {
return cachedBasePath; 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 // `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() { function injectBasePathPlugin() {
return { return {
name: 'xui-inject-base-path', name: 'xui-inject-base-path',
@ -76,7 +85,8 @@ function injectBasePathPlugin() {
transformIndexHtml(html) { transformIndexHtml(html) {
const basePath = refreshBasePath(); const basePath = refreshBasePath();
const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); 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>`); return html.replace('</head>', `${tag}</head>`);
}, },
}; };

View file

@ -102,7 +102,8 @@
"xray": "إعدادات Xray", "xray": "إعدادات Xray",
"apiDocs": "توثيق API", "apiDocs": "توثيق API",
"logout": "تسجيل خروج", "logout": "تسجيل خروج",
"link": "إدارة" "link": "إدارة",
"donate": "تبرع"
}, },
"pages": { "pages": {
"login": { "login": {

View file

@ -102,7 +102,8 @@
"xray": "Xray Configs", "xray": "Xray Configs",
"apiDocs": "API Docs", "apiDocs": "API Docs",
"logout": "Log Out", "logout": "Log Out",
"link": "Manage" "link": "Manage",
"donate": "Donate"
}, },
"pages": { "pages": {
"login": { "login": {

View file

@ -102,7 +102,8 @@
"xray": "Ajustes Xray", "xray": "Ajustes Xray",
"apiDocs": "Documentación de la API", "apiDocs": "Documentación de la API",
"logout": "Cerrar Sesión", "logout": "Cerrar Sesión",
"link": "Gestionar" "link": "Gestionar",
"donate": "Donar"
}, },
"pages": { "pages": {
"login": { "login": {

View file

@ -102,7 +102,8 @@
"xray": "پیکربندی ایکس‌ری", "xray": "پیکربندی ایکس‌ری",
"apiDocs": "مستندات API", "apiDocs": "مستندات API",
"logout": "خروج", "logout": "خروج",
"link": "مدیریت" "link": "مدیریت",
"donate": "حمایت مالی"
}, },
"pages": { "pages": {
"login": { "login": {

View file

@ -102,7 +102,8 @@
"xray": "Konfigurasi Xray", "xray": "Konfigurasi Xray",
"apiDocs": "Dokumentasi API", "apiDocs": "Dokumentasi API",
"logout": "Keluar", "logout": "Keluar",
"link": "Kelola" "link": "Kelola",
"donate": "Donasi"
}, },
"pages": { "pages": {
"login": { "login": {

View file

@ -102,7 +102,8 @@
"xray": "Xray設定", "xray": "Xray設定",
"apiDocs": "API ドキュメント", "apiDocs": "API ドキュメント",
"logout": "ログアウト", "logout": "ログアウト",
"link": "リンク管理" "link": "リンク管理",
"donate": "寄付"
}, },
"pages": { "pages": {
"login": { "login": {

View file

@ -102,7 +102,8 @@
"xray": "Xray Configs", "xray": "Xray Configs",
"apiDocs": "Documentação da API", "apiDocs": "Documentação da API",
"logout": "Sair", "logout": "Sair",
"link": "Gerenciar" "link": "Gerenciar",
"donate": "Doar"
}, },
"pages": { "pages": {
"login": { "login": {

View file

@ -102,7 +102,8 @@
"xray": "Настройки Xray", "xray": "Настройки Xray",
"apiDocs": "Документация API", "apiDocs": "Документация API",
"logout": "Выход", "logout": "Выход",
"link": "Управление" "link": "Управление",
"donate": "Поддержать"
}, },
"pages": { "pages": {
"login": { "login": {

View file

@ -102,7 +102,8 @@
"xray": "Xray Yapılandırmaları", "xray": "Xray Yapılandırmaları",
"apiDocs": "API Belgeleri", "apiDocs": "API Belgeleri",
"logout": ıkış Yap", "logout": ıkış Yap",
"link": "Yönet" "link": "Yönet",
"donate": "Bağış Yap"
}, },
"pages": { "pages": {
"login": { "login": {

View file

@ -102,7 +102,8 @@
"xray": "Конфігурації Xray", "xray": "Конфігурації Xray",
"apiDocs": "Документація API", "apiDocs": "Документація API",
"logout": "Вийти", "logout": "Вийти",
"link": "Керувати" "link": "Керувати",
"donate": "Підтримати"
}, },
"pages": { "pages": {
"login": { "login": {

View file

@ -102,7 +102,8 @@
"xray": "Cài đặt Xray", "xray": "Cài đặt Xray",
"apiDocs": "Tài liệu API", "apiDocs": "Tài liệu API",
"logout": "Đăng xuất", "logout": "Đăng xuất",
"link": "Quản lý" "link": "Quản lý",
"donate": "Quyên góp"
}, },
"pages": { "pages": {
"login": { "login": {

View file

@ -102,7 +102,8 @@
"xray": "Xray 设置", "xray": "Xray 设置",
"apiDocs": "API 文档", "apiDocs": "API 文档",
"logout": "退出登录", "logout": "退出登录",
"link": "管理" "link": "管理",
"donate": "捐赠"
}, },
"pages": { "pages": {
"login": { "login": {

View file

@ -102,7 +102,8 @@
"xray": "Xray 設定", "xray": "Xray 設定",
"apiDocs": "API 文件", "apiDocs": "API 文件",
"logout": "退出登入", "logout": "退出登入",
"link": "管理" "link": "管理",
"donate": "捐贈"
}, },
"pages": { "pages": {
"login": { "login": {