feat(sidebar): pin Logout above trigger, inline 3-state theme cycle

The desktop sider stretched to match the page height, so below lg
(992px) where dashboard cards stack into one column the collapse
trigger plus Logout slid off-screen. Pin the sider with
`position: sticky; height: 100vh; align-self: flex-start` so the chrome
stays viewport-tall. Split the menu into `.sider-nav` (flex: 1,
scrollable) and `.sider-utility` so Logout sits directly above the
48px trigger reserved by padding-bottom.

Replace the `<ThemeSwitch>` a-sub-menu with a single inline icon
button next to the '3X-UI' brand (sun / moon / moon+star SVG). One
click cycles Light -> Dark -> Ultra Dark -> Light. ThemeSwitch.vue
removed since it is now inlined.

Override AD-Vue dark Menu selected + hover/active state on the
sider-nav, sider-utility, and drawer menus to use the same light-blue
tint AD-Vue's light theme uses (rgba(64,150,255,0.2) / #4096ff). The
default dark variant was too subtle against #252526, so the current
page and Logout-on-hover barely distinguished themselves.
This commit is contained in:
MHSanaei
2026-05-11 11:50:40 +02:00
parent d8aedcdde4
commit b5479f3f30
7 changed files with 432 additions and 192 deletions
+70 -6
View File
@@ -8,8 +8,10 @@ import {
antdThemeConfig,
currentTheme,
theme as themeState,
toggleTheme,
toggleUltra,
pauseAnimationsUntilLeave,
} from '@/composables/useTheme.js';
import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
const { t } = useI18n();
@@ -61,21 +63,53 @@ const lang = ref(LanguageManager.getLanguage());
function onLangChange(next) {
LanguageManager.setLanguage(next);
}
/* Same Light -> Dark -> Ultra Dark -> Light cycle the sidebar's brand
* button uses, so the login chrome offers a one-click theme toggle
* without the popover ceremony. */
function cycleTheme() {
pauseAnimationsUntilLeave('login-theme-cycle');
if (!themeState.isDark) {
toggleTheme();
if (themeState.isUltra) toggleUltra();
} else if (!themeState.isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
}
</script>
<template>
<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">
<!-- Floating settings (theme switcher + language picker) sits in
the viewport's top-right corner so the card stays uncluttered. -->
<!-- Floating chrome at top-right: theme cycle (Light/Dark/Ultra)
plus a language picker hidden behind the gear popover. -->
<div class="login-toolbar">
<a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight"
<button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
@click="cycleTheme">
<svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
<svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
</svg>
</button>
<a-popover :overlay-class-name="currentTheme" :title="t('pages.settings.language')" 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>
@@ -286,6 +320,9 @@ function onLangChange(next) {
top: 16px;
right: 16px;
z-index: 10;
display: inline-flex;
align-items: center;
gap: 8px;
}
.toolbar-btn {
@@ -293,6 +330,33 @@ function onLangChange(next) {
height: 40px;
}
.theme-cycle {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--bg-card);
color: var(--color-text);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: background-color 0.2s, transform 0.15s, color 0.2s;
}
.theme-cycle:hover,
.theme-cycle:focus-visible {
color: var(--color-accent);
transform: scale(1.05);
outline: none;
}
.theme-cycle svg {
width: 18px;
height: 18px;
}
.login-wrapper {
min-height: 100vh;
display: flex;
+93 -20
View File
@@ -14,8 +14,10 @@ import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
import {
theme as themeState,
antdThemeConfig,
toggleTheme,
toggleUltra,
pauseAnimationsUntilLeave,
} from '@/composables/useTheme.js';
import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
const { t } = useI18n();
@@ -72,6 +74,22 @@ function onLangChange(next) {
LanguageManager.setLanguage(next);
}
/* Same Light -> Dark -> Ultra Dark -> Light cycle the panel sidebar
* uses, so the standalone subscription page offers a one-click theme
* toggle without the popover ceremony. */
function cycleTheme() {
pauseAnimationsUntilLeave('sub-theme-cycle');
if (!themeState.isDark) {
toggleTheme();
if (themeState.isUltra) toggleUltra();
} else if (!themeState.isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
}
const QR_SIZE = 240;
// Actions =====================================================
@@ -140,26 +158,44 @@ const themeClass = computed(() => ({
</a-space>
</template>
<template #extra>
<a-popover :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">
<template #icon>
<SettingOutlined />
<a-space :size="8" align="center">
<button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
@click="cycleTheme">
<svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
<svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
</svg>
</button>
<a-popover :title="t('pages.settings.language')" placement="bottomRight" trigger="click">
<template #content>
<a-space direction="vertical" :size="10" class="settings-popover">
<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>
</a-popover>
<a-button shape="circle">
<template #icon>
<SettingOutlined />
</template>
</a-button>
</a-popover>
</a-space>
</template>
<!-- ============== QR codes ============== -->
@@ -450,6 +486,43 @@ const themeClass = computed(() => ({
min-width: 220px;
}
.theme-cycle {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.08);
background: var(--bg-card);
color: rgba(0, 0, 0, 0.65);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: background-color 0.2s, transform 0.15s, color 0.2s;
}
.theme-cycle:hover,
.theme-cycle:focus-visible {
color: #1677ff;
transform: scale(1.05);
outline: none;
}
.theme-cycle svg {
width: 16px;
height: 16px;
}
.is-dark .theme-cycle {
border-color: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
}
.is-dark .theme-cycle:hover,
.is-dark .theme-cycle:focus-visible {
color: #4096ff;
}
.lang-select {
width: 100%;
}