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;
}
.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;

View file

@ -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}

View file

@ -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;
}

View file

@ -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>
&nbsp;&nbsp;<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>

View file

@ -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>`);
},
};

View file

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

View file

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

View file

@ -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": {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": {

View file

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

View file

@ -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": {

View file

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

View file

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