feat(frontend): refresh dark theme + redesign login page

- Swap navy dark palette for VS Code Dark+ neutrals (#1e1e1e/#252526/
  #2d2d30) across theme tokens, page backgrounds and DateTimePicker
- Add brand header to the mobile drawer and desktop sider, and recolor
  the drawer body so it reads as one panel with the menu
- Redesign login page with a centered card, cycling Hello/Welcome
  headline and per-theme animated gradient-blob backgrounds
This commit is contained in:
MHSanaei 2026-05-11 01:10:05 +02:00
parent f1760b0a28
commit c1efc48694
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
10 changed files with 490 additions and 290 deletions

View file

@ -9,7 +9,7 @@ import {
ClusterOutlined,
LogoutOutlined,
CloseOutlined,
MenuFoldOutlined,
MenuOutlined,
} from '@ant-design/icons-vue';
import { currentTheme } from '@/composables/useTheme.js';
@ -58,11 +58,23 @@ const tabs = computed(() => [
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
]);
// Logout sits in its own pinned-to-bottom block on the drawer; the
// remaining items are the navigation proper. The full-height sider on
// desktop still uses `tabs` as-is so the desktop look is unchanged.
const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
const utilTabs = computed(() => tabs.value.filter((tab) => tab.icon === 'logout'));
const activeTab = ref([props.requestUri]);
const drawerOpen = ref(false);
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
// Drawer width is capped against the viewport AD-Vue's default 378px
// overflows on narrow phones (e.g. 360px portrait), leaving the page
// hidden behind the mask. `min()` keeps it sane on both phones and
// tablets while never exceeding 320px on larger displays.
const drawerWidth = 'min(82vw, 320px)';
function openLink(key) {
if (key.startsWith('http')) {
window.open(key);
@ -91,6 +103,9 @@ function closeDrawer() {
<template>
<div class="ant-sidebar">
<a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
<div class="sider-brand" :class="{ 'sider-brand-collapsed': collapsed }">
{{ collapsed ? '3X' : '3X-UI' }}
</div>
<ThemeSwitch />
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
<a-menu-item v-for="tab in tabs" :key="tab.key">
@ -101,19 +116,35 @@ function closeDrawer() {
</a-layout-sider>
<a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
:wrap-style="{ padding: 0 }" :style="{ height: '100%' }" @close="closeDrawer">
:wrap-style="{ padding: 0 }" :width="drawerWidth"
:body-style="{ padding: 0, display: 'flex', flexDirection: 'column', height: '100%' }"
:header-style="{ display: 'none' }" @close="closeDrawer">
<div class="drawer-header">
<span class="drawer-brand">3X-UI</span>
<button class="drawer-close" type="button" :aria-label="t('close')" @click="closeDrawer">
<CloseOutlined />
</button>
</div>
<ThemeSwitch />
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
<a-menu-item v-for="tab in tabs" :key="tab.key">
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-nav"
@click="({ key }) => openLink(key)">
<a-menu-item v-for="tab in navTabs" :key="tab.key">
<component :is="iconByName[tab.icon]" />
<span>{{ tab.title }}</span>
</a-menu-item>
</a-menu>
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-utility"
@click="({ key }) => openLink(key)">
<a-menu-item v-for="tab in utilTabs" :key="tab.key">
<component :is="iconByName[tab.icon]" />
<span>{{ tab.title }}</span>
</a-menu-item>
</a-menu>
</a-drawer>
<button class="drawer-handle" type="button" @click="toggleDrawer">
<CloseOutlined v-if="drawerOpen" />
<MenuFoldOutlined v-else />
<button v-show="!drawerOpen" class="drawer-handle" type="button" :aria-label="t('menu.dashboard')"
@click="toggleDrawer">
<MenuOutlined />
</button>
</div>
</template>
@ -123,21 +154,96 @@ function closeDrawer() {
height: 100%;
}
/* `.sider-brand` and `.drawer-brand` share the same light-theme colour
* but differ in layout the sider one is centered with its own
* top-of-sidebar padding + border, the drawer one sits inside a flex
* header next to the close button. Dark/ultra colour overrides live
* in the non-scoped block at the bottom (theme classes attach to
* body / html). */
.sider-brand,
.drawer-brand {
font-weight: 600;
font-size: 18px;
letter-spacing: 0.5px;
color: rgba(0, 0, 0, 0.88);
}
.sider-brand {
text-align: center;
padding: 16px 12px;
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
user-select: none;
}
.sider-brand-collapsed {
font-size: 16px;
padding: 16px 4px;
letter-spacing: 0;
}
.drawer-handle {
position: fixed;
top: 16px;
left: 16px;
top: 12px;
left: 12px;
z-index: 1100;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: none;
width: 36px;
height: 36px;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
}
.drawer-close {
background: transparent;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
color: rgba(0, 0, 0, 0.65);
}
.drawer-close:hover,
.drawer-close:focus-visible {
background: rgba(128, 128, 128, 0.18);
}
.drawer-menu :deep(.ant-menu-item) {
height: 48px;
line-height: 48px;
margin: 0;
border-radius: 0;
}
.drawer-menu :deep(.ant-menu-item .anticon) {
font-size: 16px;
}
/* Push the utility (Logout) block to the bottom of the flex-column
* drawer body and separate it from the nav block with a hairline. The
* border colour is theme-neutral so it reads on both light and dark. */
.drawer-utility {
margin-top: auto;
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
@media (max-width: 768px) {
@ -161,3 +267,48 @@ function closeDrawer() {
}
}
</style>
<style>
/* Non-scoped so the rules survive AD-Vue teleporting the drawer body
* outside the AppSidebar element's scope id. Without this the Vue
* `:global(body.dark) .drawer-brand` form did not produce the expected
* `body.dark .drawer-brand[data-v-xxx]` selector reliably, and the
* drawer brand stayed at the light-theme dark colour on the navy
* drawer surface. Class names are specific enough that no collision is
* expected; AppSidebar owns the only drawer in the app. */
body.dark .drawer-brand,
body.dark .sider-brand {
color: rgba(255, 255, 255, 0.92);
}
html[data-theme='ultra-dark'] .drawer-brand,
html[data-theme='ultra-dark'] .sider-brand {
color: #ffffff;
}
body.dark .drawer-close {
color: rgba(255, 255, 255, 0.75);
}
html[data-theme='ultra-dark'] .drawer-close {
color: rgba(255, 255, 255, 0.85);
}
/* Pin the drawer surface to the same colour the desktop sider uses
* (Layout.colorBgHeader / Menu.colorItemBg from useTheme.js) so the
* header, empty body region, and menu items read as one continuous
* panel. AD-Vue's CSS-in-JS tokens otherwise leave the drawer at
* colorBgElevated (#2d2d30 in dark) which clashes with the #252526
* menu rows. `!important` is required to beat the CSS-in-JS rule
* specificity; AppSidebar owns the only drawer in the app so this
* doesn't collide with anything else. */
body.dark .ant-drawer .ant-drawer-content,
body.dark .ant-drawer .ant-drawer-body {
background: #252526 !important;
}
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
background: #0a0a0a !important;
}
</style>

View file

@ -235,8 +235,8 @@ function onAntChange(next) {
/* ===== Dark (navy) ======================================================= */
body.dark .persian-datepicker-input {
background: #142340;
border-color: #1f3358;
background: #252526;
border-color: #3c3c3c;
color: rgba(255, 255, 255, 0.88);
}
@ -251,14 +251,14 @@ body.dark .persian-datepicker-input:focus {
body.dark .vpd-main .vpd-icon-btn {
background: rgba(255, 255, 255, 0.04) !important;
border: 1px solid #1f3358 !important;
border: 1px solid #3c3c3c !important;
border-right: none !important;
border-radius: 6px 0 0 6px !important;
color: rgba(255, 255, 255, 0.75) !important;
}
body.dark .vpd-wrapper .vpd-content {
background: #1a2c4d;
background: #2d2d30;
color: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
0 3px 6px -4px rgba(0, 0, 0, 0.48),
@ -266,7 +266,7 @@ body.dark .vpd-wrapper .vpd-content {
}
body.dark .vpd-wrapper .vpd-body {
background: #1a2c4d;
background: #2d2d30;
color: rgba(255, 255, 255, 0.88);
}
@ -315,7 +315,7 @@ body.dark .vpd-wrapper .vpd-actions button:hover {
body.dark .vpd-wrapper .vpd-addon-list,
body.dark .vpd-wrapper .vpd-addon-list-content {
background: #1a2c4d;
background: #2d2d30;
color: rgba(255, 255, 255, 0.88);
}

View file

@ -27,14 +27,15 @@ export const currentTheme = computed(() => (theme.isDark ? 'dark' : 'light'));
// AD-Vue 4 theme config consumed by every page's <a-config-provider>.
// Three modes — light / dark / ultra-dark — all share AD-Vue's vanilla
// blue primary. Dark uses a navy palette across page/cards/modals so
// the sidebar blends with the rest of the surface; ultra-dark stays
// neutral black on top of darkAlgorithm.
// blue primary. Dark uses a neutral grey palette modelled on VS Code's
// Dark+ chrome (`#1e1e1e` editor, `#252526` sidebar, `#2d2d30` panel),
// so the panel reads as a familiar modern IDE rather than the older
// navy shade. Ultra-dark stays pure-black on darkAlgorithm.
const DARK_TOKENS = {
colorBgBase: '#0a1426',
colorBgLayout: '#0a1426',
colorBgContainer: '#142340',
colorBgElevated: '#1a2c4d',
colorBgBase: '#1e1e1e',
colorBgLayout: '#1e1e1e',
colorBgContainer: '#252526',
colorBgElevated: '#2d2d30',
};
const ULTRA_DARK_TOKENS = {
colorBgBase: '#000',
@ -47,13 +48,12 @@ const ULTRA_DARK_TOKENS = {
// + trigger backgrounds and `#001529` / `#000c17` as the dark Menu item
// backgrounds (see node_modules/ant-design-vue/es/{layout,menu}/style/
// index.js). Override at the component-token level so the sider blends
// with darkAlgorithm's neutral surfaces.
// Dark theme uses a refined navy for the sidebar — distinct from the
// neutral ultra-dark and warmer than AD-Vue's stock #001529.
// with darkAlgorithm's neutral surfaces. Sider/trigger use the same
// `#252526` / `#333333` tones VS Code does for its activity bar.
const DARK_LAYOUT_TOKENS = {
colorBgHeader: '#0d1d33',
colorBgTrigger: '#15294a',
colorBgBody: '#000',
colorBgHeader: '#252526',
colorBgTrigger: '#333333',
colorBgBody: '#1e1e1e',
};
const ULTRA_DARK_LAYOUT_TOKENS = {
colorBgHeader: '#0a0a0a',
@ -61,9 +61,9 @@ const ULTRA_DARK_LAYOUT_TOKENS = {
colorBgBody: '#000',
};
const DARK_MENU_TOKENS = {
colorItemBg: '#0d1d33',
colorSubItemBg: '#08142a',
menuSubMenuBg: '#0d1d33',
colorItemBg: '#252526',
colorSubItemBg: '#1e1e1e',
menuSubMenuBg: '#252526',
};
const ULTRA_DARK_MENU_TOKENS = {
colorItemBg: '#0a0a0a',

View file

@ -650,8 +650,8 @@ function onRowAction({ key, dbInbound }) {
}
.inbounds-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.inbounds-page.is-dark.is-ultra {

View file

@ -336,8 +336,8 @@ async function openConfig() {
}
.index-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.index-page.is-dark.is-ultra {

View file

@ -13,6 +13,19 @@ import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
const { t } = useI18n();
const fetched = ref(false);
const submitting = ref(false);
const twoFactorEnable = ref(false);
const version = computed(() => window.X_UI_CUR_VER || '');
const user = reactive({
username: '',
password: '',
twoFactorCode: '',
});
const basePath = window.X_UI_BASE_PATH || '';
const headlineWords = computed(() => [t('pages.login.hello'), t('pages.login.title')]);
const HEADLINE_INTERVAL_MS = 2000;
const headlineIndex = ref(0);
@ -28,23 +41,9 @@ onBeforeUnmount(() => {
if (headlineTimer != null) window.clearInterval(headlineTimer);
});
const fetched = ref(false);
const submitting = ref(false);
const twoFactorEnable = ref(false);
const user = reactive({
username: '',
password: '',
twoFactorCode: '',
});
const basePath = window.X_UI_BASE_PATH || '';
onMounted(async () => {
const msg = await HttpUtil.post('/getTwoFactorEnable');
if (msg.success) {
twoFactorEnable.value = !!msg.obj;
}
if (msg.success) twoFactorEnable.value = !!msg.obj;
fetched.value = true;
});
@ -52,9 +51,7 @@ async function login() {
submitting.value = true;
try {
const msg = await HttpUtil.post('/login', user);
if (msg.success) {
window.location.href = basePath + 'panel/';
}
if (msg.success) window.location.href = basePath + 'panel/';
} finally {
submitting.value = false;
}
@ -70,102 +67,85 @@ function onLangChange(next) {
<a-config-provider :theme="antdThemeConfig">
<a-layout class="login-app" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
<a-layout-content class="login-content">
<div class="waves-header">
<div class="waves-inner-header"></div>
<svg class="waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 24 150 28" preserveAspectRatio="none" shape-rendering="auto">
<defs>
<path id="gentle-wave" d="M-160 44c30 0 58-18 88-18s 58 18 88 18 58-18 88-18 58 18 88 18 v44h-352z" />
</defs>
<g class="parallax">
<use xlink:href="#gentle-wave" x="48" y="0" />
<use xlink:href="#gentle-wave" x="48" y="3" />
<use xlink:href="#gentle-wave" x="48" y="5" />
<use xlink:href="#gentle-wave" x="48" y="7" />
</g>
</svg>
<!-- Floating settings (theme switcher + language picker) sits in
the viewport's top-right corner so the card stays uncluttered. -->
<div class="login-toolbar">
<a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight"
trigger="click">
<template #content>
<a-space direction="vertical" :size="10" class="settings-popover">
<ThemeSwitchLogin />
<span>{{ t('pages.settings.language') }}</span>
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value">
<span :aria-label="l.name">{{ l.icon }}</span>
&nbsp;&nbsp;<span>{{ l.name }}</span>
</a-select-option>
</a-select>
</a-space>
</template>
<a-button shape="circle" class="toolbar-btn" :aria-label="t('menu.settings')">
<template #icon>
<SettingOutlined />
</template>
</a-button>
</a-popover>
</div>
<a-row type="flex" justify="center" align="middle" class="login-row">
<a-col class="login-card">
<div v-if="!fetched" class="login-loading">
<a-spin size="large" />
</div>
<div class="login-wrapper">
<div v-if="!fetched" class="login-loading">
<a-spin size="large" />
</div>
<div v-else>
<div class="login-settings">
<a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight"
trigger="click">
<template #content>
<a-space direction="vertical" :size="10" class="settings-popover">
<ThemeSwitchLogin />
<span>{{ t('pages.settings.language') }}</span>
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
:value="l.value">
<span :aria-label="l.name">{{ l.icon }}</span>
&nbsp;&nbsp;<span>{{ l.name }}</span>
</a-select-option>
</a-select>
</a-space>
<div v-else class="login-card">
<div class="brand">
<span class="brand-name">3X-UI</span>
<span class="brand-accent" aria-hidden="true"></span>
</div>
<h2 class="welcome">
<Transition name="headline" mode="out-in">
<b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
</Transition>
</h2>
<a-form layout="vertical" class="login-form" @submit.prevent="login">
<a-form-item :label="t('username')">
<a-input v-model:value="user.username" autocomplete="username" name="username" size="large"
:placeholder="t('username')" autofocus required>
<template #prefix>
<UserOutlined />
</template>
<a-button shape="circle">
<template #icon>
<SettingOutlined />
</template>
</a-button>
</a-popover>
</div>
</a-input>
</a-form-item>
<a-row justify="center">
<a-col :span="24">
<h2 class="login-title">
<Transition name="headline" mode="out-in">
<b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
</Transition>
</h2>
</a-col>
</a-row>
<a-form-item :label="t('password')">
<a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
size="large" :placeholder="t('password')" required>
<template #prefix>
<LockOutlined />
</template>
</a-input-password>
</a-form-item>
<a-form layout="vertical" @submit.prevent="login">
<a-form-item>
<a-input v-model:value="user.username" autocomplete="username" name="username"
:placeholder="t('username')" autofocus required>
<template #prefix>
<UserOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item v-if="twoFactorEnable" :label="t('twoFactorCode')">
<a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
size="large" :placeholder="t('twoFactorCode')" required>
<template #prefix>
<KeyOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item>
<a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
:placeholder="t('password')" required>
<template #prefix>
<LockOutlined />
</template>
</a-input-password>
</a-form-item>
<a-form-item class="submit-row">
<a-button type="primary" html-type="submit" :loading="submitting" size="large" block>
{{ submitting ? '' : t('login') }}
</a-button>
</a-form-item>
</a-form>
<a-form-item v-if="twoFactorEnable">
<a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
:placeholder="t('twoFactorCode')" required>
<template #prefix>
<KeyOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item>
<a-row justify="center">
<a-button type="primary" html-type="submit" :loading="submitting" block>
{{ submitting ? '' : t('login') }}
</a-button>
</a-row>
</a-form-item>
</a-form>
</div>
</a-col>
</a-row>
<div v-if="version" class="version">v{{ version }}</div>
</div>
</div>
</a-layout-content>
</a-layout>
</a-config-provider>
@ -173,59 +153,242 @@ function onLangChange(next) {
<style scoped>
.login-app {
--bg-page: #c7ebe2;
--bg-wave-header: #dbf5ed;
--bg-page: #f5f7fa;
--bg-card: #ffffff;
--color-title: #008771;
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.09);
--wave-fill: rgba(0, 135, 113, 0.12);
--wave-fill-bottom: #c7ebe2;
--color-text: rgba(0, 0, 0, 0.88);
--color-text-subtle: rgba(0, 0, 0, 0.55);
--color-accent: #1677ff;
--color-border: rgba(0, 0, 0, 0.08);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
--blob-1: rgba(99, 102, 241, 0.45);
--blob-2: rgba(236, 72, 153, 0.38);
--blob-3: rgba(20, 184, 166, 0.32);
position: relative;
min-height: 100vh;
overflow: hidden;
background: var(--bg-page);
}
.login-app.is-dark {
--bg-page: #222d42;
--bg-wave-header: #0a1222;
--bg-card: #151f31;
--color-title: rgba(255, 255, 255, 0.92);
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.45);
--wave-fill: #222d42;
--wave-fill-bottom: #222d42;
--bg-page: #1e1e1e;
--bg-card: #252526;
--color-text: rgba(255, 255, 255, 0.92);
--color-text-subtle: rgba(255, 255, 255, 0.55);
--color-accent: #4096ff;
--color-border: rgba(255, 255, 255, 0.08);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 8px 32px rgba(0, 0, 0, 0.4);
--blob-1: rgba(64, 150, 255, 0.40);
--blob-2: rgba(168, 85, 247, 0.34);
--blob-3: rgba(34, 211, 238, 0.22);
}
.login-app.is-dark.is-ultra {
--bg-page: #0f2d32;
--bg-wave-header: #0a2227;
--bg-card: #0c0e12;
--wave-fill: #1f4d52;
--wave-fill-bottom: #0f2d32;
--bg-page: #000;
--bg-card: #141414;
--color-border: rgba(255, 255, 255, 0.06);
--blob-1: rgba(64, 150, 255, 0.22);
--blob-2: rgba(168, 85, 247, 0.18);
--blob-3: rgba(34, 211, 238, 0.12);
}
/* Three blurred blobs slowly drift across the page; ::before and
* ::after carry two of them, the third lives on .login-content::before
* so we can animate it independently. */
.login-app::before,
.login-app::after {
content: '';
position: absolute;
width: 70vw;
height: 70vw;
max-width: 900px;
max-height: 900px;
border-radius: 50%;
filter: blur(90px);
pointer-events: none;
z-index: 0;
will-change: transform;
}
.login-app::before {
top: -25vw;
left: -20vw;
background: radial-gradient(circle, var(--blob-1) 0%, transparent 65%);
animation: blob-drift-a 24s ease-in-out infinite alternate;
}
.login-app::after {
bottom: -25vw;
right: -20vw;
background: radial-gradient(circle, var(--blob-2) 0%, transparent 65%);
animation: blob-drift-b 30s ease-in-out infinite alternate;
}
.login-content::before {
content: '';
position: absolute;
top: 30%;
left: 50%;
width: 50vw;
height: 50vw;
max-width: 700px;
max-height: 700px;
border-radius: 50%;
background: radial-gradient(circle, var(--blob-3) 0%, transparent 65%);
filter: blur(90px);
pointer-events: none;
z-index: 0;
will-change: transform;
animation: blob-drift-c 36s ease-in-out infinite alternate;
}
@keyframes blob-drift-a {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(18vw, 10vh) scale(1.15); }
100% { transform: translate(34vw, 22vh) scale(1.25); }
}
@keyframes blob-drift-b {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-16vw, -10vh) scale(1.12); }
100% { transform: translate(-30vw, -22vh) scale(1.2); }
}
@keyframes blob-drift-c {
0% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-20%, -20%) scale(1.1); }
100% { transform: translate(-80%, -10%) scale(1.05); }
}
@media (prefers-reduced-motion: reduce) {
.login-app::before,
.login-app::after,
.login-content::before {
animation: none;
}
}
.login-app,
.login-app :deep(.ant-layout-content) {
background: transparent;
}
.login-app {
background: var(--bg-page);
.login-content {
position: relative;
}
.login-content > * {
position: relative;
z-index: 1;
}
.login-toolbar {
position: fixed;
top: 16px;
right: 16px;
z-index: 10;
}
.toolbar-btn {
width: 40px;
height: 40px;
}
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.login-loading {
text-align: center;
}
.login-card {
width: 100%;
max-width: 400px;
background: var(--bg-card);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 40px 32px 28px;
box-shadow: var(--shadow-card);
}
.login-title {
color: var(--color-title);
@media (max-width: 480px) {
.login-card {
padding: 32px 20px 24px;
}
}
.login-settings {
.brand {
display: flex;
justify-content: flex-end;
flex-direction: column;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.brand-name {
font-size: 28px;
font-weight: 700;
letter-spacing: 1.5px;
color: var(--color-text);
}
.brand-accent {
display: block;
width: 40px;
height: 3px;
border-radius: 2px;
background: var(--color-accent);
}
.welcome {
text-align: center;
color: var(--color-text);
font-size: 32px;
font-weight: 700;
line-height: 1.2;
min-height: 42px;
margin: 12px 0 28px;
letter-spacing: 0.3px;
}
.welcome b {
display: inline-block;
font-weight: inherit;
}
.headline-enter-active,
.headline-leave-active {
transition: opacity 280ms ease, transform 280ms ease;
}
.headline-enter-from {
opacity: 0;
transform: translateY(6px);
}
.headline-leave-to {
opacity: 0;
transform: translateY(-6px);
}
.login-form :deep(.ant-form-item-label > label) {
color: var(--color-text);
font-weight: 500;
}
.submit-row {
margin-bottom: 0;
}
.version {
text-align: center;
font-size: 12px;
color: var(--color-text-subtle);
margin-top: 16px;
}
.settings-popover {
min-width: 220px;
}
@ -233,118 +396,4 @@ function onLangChange(next) {
.lang-select {
width: 100%;
}
.login-content {
position: relative;
}
.login-row {
position: relative;
z-index: 1;
min-height: 100vh;
padding: 24px 0;
}
.login-card {
width: clamp(280px, 90vw, 300px);
border-radius: 2rem;
padding: clamp(2rem, 5vw, 4rem) 1.5rem;
transition: background 0.3s, box-shadow 0.3s;
}
.login-loading {
text-align: center;
padding: 40px 0;
}
.login-title {
text-align: center;
margin-bottom: 32px;
font-size: 2rem;
font-weight: 500;
min-height: 2.5rem;
}
.login-title b {
display: inline-block;
}
.headline-enter-active,
.headline-leave-active {
transition: opacity 0.4s ease, transform 0.4s ease;
}
.headline-enter-from {
opacity: 0;
transform: translateY(-12px);
}
.headline-leave-to {
opacity: 0;
transform: translateY(12px);
}
.waves-header {
position: fixed;
inset: 0 0 auto 0;
width: 100%;
z-index: 0;
pointer-events: none;
background: var(--bg-wave-header);
}
.waves-inner-header {
height: 50vh;
width: 100%;
}
.waves {
position: relative;
display: block;
width: 100%;
height: 15vh;
min-height: 100px;
max-height: 150px;
margin-bottom: -8px;
}
.parallax>use {
fill: var(--wave-fill);
animation: move-forever 25s cubic-bezier(0.55, 0.5, 0.45, 0.5) infinite;
}
.parallax>use:nth-child(1) {
animation-delay: -2s;
animation-duration: 4s;
opacity: 0.2;
}
.parallax>use:nth-child(2) {
animation-delay: -3s;
animation-duration: 7s;
opacity: 0.4;
}
.parallax>use:nth-child(3) {
animation-delay: -4s;
animation-duration: 10s;
opacity: 0.6;
}
.parallax>use:nth-child(4) {
animation-delay: -5s;
animation-duration: 13s;
fill: var(--wave-fill-bottom);
opacity: 1;
}
@keyframes move-forever {
0% {
transform: translate3d(-90px, 0, 0);
}
100% {
transform: translate3d(85px, 0, 0);
}
}
</style>

View file

@ -172,8 +172,8 @@ async function onToggleEnable(node, next) {
}
.nodes-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.nodes-page.is-dark.is-ultra {

View file

@ -256,8 +256,8 @@ const alertVisible = ref(true);
}
.settings-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.settings-page.is-dark.is-ultra {

View file

@ -299,8 +299,8 @@ const themeClass = computed(() => ({
}
.subscription-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.subscription-page.is-dark.is-ultra {

View file

@ -339,8 +339,8 @@ function confirmRestart() {
}
.xray-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.xray-page.is-dark.is-ultra {