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:
@@ -12,8 +12,7 @@ import {
|
||||
MenuOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { currentTheme } from '@/composables/useTheme.js';
|
||||
import ThemeSwitch from '@/components/ThemeSwitch.vue';
|
||||
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -98,17 +97,60 @@ function toggleDrawer() {
|
||||
function closeDrawer() {
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
|
||||
/* 3-state theme cycle driven by the brand-row icon button.
|
||||
* Light → Dark (turn dark on, ensure ultra off)
|
||||
* Dark → Ultra (turn ultra on)
|
||||
* Ultra → Light (turn ultra off, turn dark off)
|
||||
* Using a single button keeps the sider header clean — the old
|
||||
* ThemeSwitch a-sub-menu plus its expandable items lived here. */
|
||||
function cycleTheme() {
|
||||
pauseAnimationsUntilLeave('theme-cycle');
|
||||
if (!theme.isDark) {
|
||||
toggleTheme();
|
||||
if (theme.isUltra) toggleUltra();
|
||||
} else if (!theme.isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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' }}
|
||||
<span class="brand-text">{{ collapsed ? '3X' : '3X-UI' }}</span>
|
||||
<button v-if="!collapsed" id="theme-cycle" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
|
||||
:title="t('menu.theme')" @click="cycleTheme">
|
||||
<svg v-if="!theme.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="!theme.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>
|
||||
</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="sider-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="sider-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>
|
||||
@@ -121,11 +163,29 @@ function closeDrawer() {
|
||||
: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 class="drawer-header-actions">
|
||||
<button id="theme-cycle-drawer" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
|
||||
:title="t('menu.theme')" @click="cycleTheme">
|
||||
<svg v-if="!theme.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="!theme.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>
|
||||
<button class="drawer-close" type="button" :aria-label="t('close')" @click="closeDrawer">
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeSwitch />
|
||||
<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">
|
||||
@@ -150,8 +210,18 @@ function closeDrawer() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Pin the desktop sider to the viewport. Without this, AD-Vue's
|
||||
* `<a-layout-sider>` stretches to match the flex row's height — which
|
||||
* equals the page height on tall dashboards (cards stack into one
|
||||
* column below `lg` = 992px), so the bottom-anchored
|
||||
* `.ant-layout-sider-trigger` (and Logout right above it) slide off
|
||||
* the screen. Sticky + 100vh keeps the sider exactly viewport-tall;
|
||||
* `align-self: flex-start` stops the flex row from re-stretching it. */
|
||||
.ant-sidebar>.ant-layout-sider {
|
||||
height: 100%;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* `.sider-brand` and `.drawer-brand` share the same light-theme colour
|
||||
@@ -169,18 +239,65 @@ function closeDrawer() {
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
text-align: center;
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Collapsed sider only has room for the '3X' brand — center it and
|
||||
* hide the theme cycle button (which is `v-if`-ed out in template). */
|
||||
.sider-brand-collapsed {
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
padding: 16px 4px;
|
||||
padding: 14px 4px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed .brand-text {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.theme-cycle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.theme-cycle:hover,
|
||||
.theme-cycle:focus-visible {
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.theme-cycle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.drawer-header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-handle {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
@@ -246,6 +363,41 @@ function closeDrawer() {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
/* Pin Logout exactly above AD-Vue's `.ant-layout-sider-trigger` (the
|
||||
* collapse bar at the bottom, position: absolute; height: 48px). The
|
||||
* old `margin-top: auto` approach only pushed the utility down when the
|
||||
* content was shorter than the container — on short viewports the
|
||||
* Logout got hidden behind the trigger. Switching to a flex layout
|
||||
* where `.sider-nav` consumes all spare space (flex: 1) and
|
||||
* `.sider-utility` stays at content height pins it consistently. The
|
||||
* padding-bottom: 48px on the parent reserves the trigger's strip so
|
||||
* Logout sits directly above it.
|
||||
*
|
||||
* The mobile @media rule below still hides the whole sider on phones;
|
||||
* this block only kicks in once that override no longer matches. */
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sider-nav {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sider-utility {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.drawer-handle {
|
||||
display: inline-flex;
|
||||
@@ -311,4 +463,29 @@ html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
/* Force the same light-blue tint on selected + hover/active across
|
||||
* all three themes. AD-Vue's defaults read too subtle on the dark
|
||||
* sider, and the light-theme variant looked inconsistent vs. dark —
|
||||
* applying the same RGBA tint over all backgrounds gives the active
|
||||
* page the same visual weight everywhere. `!important` is required to
|
||||
* beat AD-Vue's CSS-in-JS specificity; scoped to .sider-nav /
|
||||
* .sider-utility / .drawer-menu so only the navigation menus pick up
|
||||
* the override (other a-menu instances keep AD-Vue defaults). */
|
||||
.sider-nav .ant-menu-item-selected,
|
||||
.sider-utility .ant-menu-item-selected,
|
||||
.drawer-menu .ant-menu-item-selected {
|
||||
background-color: rgba(64, 150, 255, 0.2) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
|
||||
background-color: rgba(64, 150, 255, 0.1) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { BulbFilled, BulbOutlined } from '@ant-design/icons-vue';
|
||||
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const BulbIcon = computed(() => (theme.isDark ? BulbFilled : BulbOutlined));
|
||||
|
||||
function onDarkChange() {
|
||||
pauseAnimationsUntilLeave('change-theme');
|
||||
toggleTheme();
|
||||
}
|
||||
|
||||
function onUltraClick() {
|
||||
pauseAnimationsUntilLeave('change-theme-ultra');
|
||||
toggleUltra();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="[]">
|
||||
<a-sub-menu>
|
||||
<template #title>
|
||||
<span>
|
||||
<component :is="BulbIcon" />
|
||||
<span class="theme-label">{{ t('menu.theme') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<a-menu-item id="change-theme" class="ant-menu-theme-switch">
|
||||
<span>{{ t('menu.dark') }}</span>
|
||||
<a-switch :style="{ marginLeft: '2px' }" size="small" :checked="theme.isDark" @change="onDarkChange" />
|
||||
</a-menu-item>
|
||||
|
||||
<a-menu-item v-if="theme.isDark" id="change-theme-ultra" class="ant-menu-theme-switch">
|
||||
<span>{{ t('menu.ultraDark') }}</span>
|
||||
<a-checkbox :style="{ marginLeft: '2px' }" :checked="theme.isUltra" @click="onUltraClick" />
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</a-menu>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-label {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup>
|
||||
import { theme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||
|
||||
function onDarkChange() {
|
||||
pauseAnimationsUntilLeave('change-theme');
|
||||
toggleTheme();
|
||||
}
|
||||
|
||||
function onUltraClick() {
|
||||
toggleUltra();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-space id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }">
|
||||
<a-space direction="horizontal" size="small">
|
||||
<a-switch size="small" :checked="theme.isDark" @change="onDarkChange" />
|
||||
<span>Dark</span>
|
||||
</a-space>
|
||||
<a-space v-if="theme.isDark" direction="horizontal" size="small">
|
||||
<a-checkbox :checked="theme.isUltra" @click="onUltraClick" />
|
||||
<span>Ultra dark</span>
|
||||
</a-space>
|
||||
</a-space>
|
||||
</template>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user