Move account menu to a floating bottom-left card with light/dark/system theme control

This commit is contained in:
Kris Parish
2026-07-03 03:09:06 -05:00
parent 4f122a00df
commit 49bb4cf4f4
4 changed files with 338 additions and 83 deletions
+41 -9
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import Sidebar from './components/Sidebar.vue'; import Sidebar from './components/Sidebar.vue';
import Toolbar from './components/Toolbar.vue'; import Toolbar from './components/Toolbar.vue';
@@ -10,13 +10,32 @@ const props = defineProps({
}, },
}); });
const isDark = ref(props.serverData?.settings?.dark_mode === 'on'); // Three-state theme: 'light' | 'dark' | 'system'. Older stored values were
// just 'on'/'off' — map those forward so existing installs keep working.
const normalizeStoredTheme = (value) => {
if (value === 'on') return 'dark';
if (value === 'off') return 'light';
if (value === 'light' || value === 'dark' || value === 'system') return value;
return 'system';
};
const theme = ref(normalizeStoredTheme(props.serverData?.settings?.dark_mode));
const systemPrefersDark = ref(
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
);
let mediaQuery = null;
const onSystemChange = (e) => (systemPrefersDark.value = e.matches);
const isDark = computed(() =>
theme.value === 'dark' || (theme.value === 'system' && systemPrefersDark.value)
);
const applyTheme = () => { const applyTheme = () => {
document.documentElement.classList.toggle('aurora-dark', isDark.value); document.documentElement.classList.toggle('aurora-dark', isDark.value);
}; };
const persistDark = async () => { const persistTheme = async () => {
try { try {
await fetch(`${props.serverData.restUrl}wp/v2/settings`, { await fetch(`${props.serverData.restUrl}wp/v2/settings`, {
method: 'POST', method: 'POST',
@@ -29,7 +48,7 @@ const persistDark = async () => {
// settings copy this page received. // settings copy this page received.
body: JSON.stringify({ body: JSON.stringify({
aurora_admin_settings: { aurora_admin_settings: {
dark_mode: isDark.value ? 'on' : 'off', dark_mode: theme.value,
}, },
}), }),
}); });
@@ -38,12 +57,14 @@ const persistDark = async () => {
} }
}; };
const toggleDark = () => { const setTheme = (next) => {
isDark.value = !isDark.value; theme.value = next;
applyTheme(); applyTheme();
persistDark(); persistTheme();
}; };
watch(isDark, applyTheme);
// Minified (icon-only) menu — a per-browser preference kept in // Minified (icon-only) menu — a per-browser preference kept in
// localStorage; the html class drives all layout via --aurora-sidebar-w. // localStorage; the html class drives all layout via --aurora-sidebar-w.
const collapsed = ref(localStorage.getItem('aurora-menu-collapsed') === '1'); const collapsed = ref(localStorage.getItem('aurora-menu-collapsed') === '1');
@@ -59,11 +80,21 @@ const toggleMenu = () => {
onMounted(() => { onMounted(() => {
applyTheme(); applyTheme();
applyCollapsed(); applyCollapsed();
if (window.matchMedia) {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', onSystemChange);
}
// Signal the shell is live: reveals the framed content and commits to // Signal the shell is live: reveals the framed content and commits to
// keeping native chrome hidden (the PHP failsafe stops watching once // keeping native chrome hidden (the PHP failsafe stops watching once
// aurora-ready is present). // aurora-ready is present).
document.documentElement.classList.add('aurora-ready'); document.documentElement.classList.add('aurora-ready');
}); });
onUnmounted(() => {
if (mediaQuery) {
mediaQuery.removeEventListener('change', onSystemChange);
}
});
</script> </script>
<template> <template>
@@ -76,11 +107,12 @@ onMounted(() => {
:dark-logo="serverData.settings?.dark_logo || ''" :dark-logo="serverData.settings?.dark_logo || ''"
:is-dark="isDark" :is-dark="isDark"
:collapsed="collapsed" :collapsed="collapsed"
:server-data="serverData"
:theme="theme"
@set-theme="setTheme"
/> />
<Toolbar <Toolbar
:server-data="serverData" :server-data="serverData"
:is-dark="isDark"
@toggle-dark="toggleDark"
@toggle-menu="toggleMenu" @toggle-menu="toggleMenu"
/> />
</div> </div>
+30 -2
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import UserMenu from './UserMenu.vue';
const props = defineProps({ const props = defineProps({
menu: { menu: {
@@ -30,8 +31,18 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
serverData: {
type: Object,
required: true,
},
theme: {
type: String,
default: 'system',
},
}); });
const emit = defineEmits(['set-theme']);
// Custom logo from Settings > General. Dark mode prefers the dark variant // Custom logo from Settings > General. Dark mode prefers the dark variant
// and falls back to the standard logo; no logo set = "Aurora" wordmark. // and falls back to the standard logo; no logo set = "Aurora" wordmark.
const activeLogo = computed(() => const activeLogo = computed(() =>
@@ -93,6 +104,7 @@ const isAuroraActive = (item) => normalize(item.url) === current.value;
<template> <template>
<nav class="sidebar" :class="{ 'sidebar--collapsed': collapsed }"> <nav class="sidebar" :class="{ 'sidebar--collapsed': collapsed }">
<div class="sidebar__scroll">
<div class="sidebar__brand"> <div class="sidebar__brand">
<img v-if="activeLogo" :src="activeLogo" alt="" class="sidebar__brand-img" /> <img v-if="activeLogo" :src="activeLogo" alt="" class="sidebar__brand-img" />
<template v-else>{{ collapsed ? 'A' : 'Aurora' }}</template> <template v-else>{{ collapsed ? 'A' : 'Aurora' }}</template>
@@ -192,6 +204,14 @@ const isAuroraActive = (item) => normalize(item.url) === current.value;
</li> </li>
</ul> </ul>
</div> </div>
</div>
<UserMenu
:server-data="serverData"
:collapsed="collapsed"
:theme="theme"
@set-theme="(t) => emit('set-theme', t)"
/>
</nav> </nav>
</template> </template>
@@ -203,16 +223,24 @@ const isAuroraActive = (item) => normalize(item.url) === current.value;
width: var(--aurora-sidebar-w); width: var(--aurora-sidebar-w);
height: 100vh; height: 100vh;
background: var(--aurora-app-bg); background: var(--aurora-app-bg);
overflow-y: auto;
transition: width 0.2s ease; transition: width 0.2s ease;
z-index: 99999; z-index: 99999;
padding: 12px 0;
box-sizing: border-box; box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* Scrollable nav content — separate from the pinned UserMenu footer below
it, so the account card stays visible regardless of scroll position. */
.sidebar__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 0;
box-sizing: border-box;
}
.sidebar__brand { .sidebar__brand {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
+1 -72
View File
@@ -7,15 +7,10 @@ const props = defineProps({
type: Object, type: Object,
required: true, required: true,
}, },
isDark: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['toggle-dark', 'toggle-menu']); const emit = defineEmits(['toggle-menu']);
const menuOpen = ref(false);
const searchOpen = ref(false); const searchOpen = ref(false);
const sitesOpen = ref(false); const sitesOpen = ref(false);
@@ -120,40 +115,6 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown));
</a> </a>
</div> </div>
</div> </div>
<button
type="button"
class="toolbar__icon-btn toolbar__theme-toggle"
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="emit('toggle-dark')"
>
{{ isDark ? '☀' : '☾' }}
</button>
<div class="toolbar__user">
<button
type="button"
class="toolbar__user-btn"
@click="menuOpen = !menuOpen"
>
<img
v-if="serverData.avatar"
:src="serverData.avatar"
alt=""
class="toolbar__avatar"
/>
<span class="toolbar__user-name">{{ serverData.userName }}</span>
</button>
<div v-if="menuOpen" class="toolbar__dropdown">
<div class="toolbar__dropdown-header">
<div class="toolbar__dropdown-name">{{ serverData.userName }}</div>
<div class="toolbar__dropdown-email">{{ serverData.userEmail }}</div>
</div>
<a :href="serverData.logoutUrl" class="toolbar__dropdown-item">
Log out
</a>
</div>
</div>
</div> </div>
<SearchModal <SearchModal
@@ -256,12 +217,6 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown));
color: var(--aurora-text); color: var(--aurora-text);
} }
.toolbar__theme-toggle {
font-size: 1.05rem;
line-height: 1;
}
.toolbar__user,
.toolbar__sites { .toolbar__sites {
position: relative; position: relative;
} }
@@ -272,32 +227,6 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown));
margin-left: 8px; margin-left: 8px;
} }
.toolbar__user-btn {
display: flex;
align-items: center;
gap: 8px;
border: none;
background: none;
cursor: pointer;
padding: 4px 6px;
border-radius: 8px;
color: var(--aurora-text);
}
.toolbar__user-btn:hover {
background: var(--aurora-bg-subtle);
}
.toolbar__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
}
.toolbar__user-name {
font-size: 0.875rem;
}
.toolbar__dropdown { .toolbar__dropdown {
position: absolute; position: absolute;
top: calc(100% + 6px); top: calc(100% + 6px);
+266
View File
@@ -0,0 +1,266 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
collapsed: { type: Boolean, default: false },
theme: { type: String, default: 'system' }, // 'light' | 'dark' | 'system'
});
const emit = defineEmits(['set-theme']);
const open = ref(false);
const root = ref(null);
const profileUrl = computed(() => `${props.serverData.adminUrl}profile.php`);
const settingsUrl = computed(() => `${props.serverData.adminUrl}options-general.php`);
const onDocClick = (e) => {
if (root.value && !root.value.contains(e.target)) {
open.value = false;
}
};
onMounted(() => document.addEventListener('click', onDocClick));
onUnmounted(() => document.removeEventListener('click', onDocClick));
</script>
<template>
<div ref="root" class="user-menu">
<div v-if="open" class="user-menu__card">
<div class="user-menu__header">
<img v-if="serverData.avatar" :src="serverData.avatar" alt="" class="user-menu__avatar" />
<div class="user-menu__id">
<div class="user-menu__name">{{ serverData.userName }}</div>
<div class="user-menu__email">{{ serverData.userEmail }}</div>
</div>
</div>
<div class="user-menu__items">
<a :href="profileUrl" class="user-menu__item">
<span class="dashicons dashicons-admin-users" />
Edit Profile
</a>
<a :href="serverData.siteUrl" target="_blank" rel="noopener noreferrer" class="user-menu__item">
<span class="dashicons dashicons-admin-home" />
Visit site
</a>
<a :href="settingsUrl" class="user-menu__item">
<span class="dashicons dashicons-admin-generic" />
Site Settings
</a>
<a :href="serverData.logoutUrl" class="user-menu__item user-menu__item--danger">
<span class="dashicons dashicons-migrate" />
Sign Out
</a>
</div>
<div class="user-menu__theme">
<span class="user-menu__theme-label">Theme</span>
<div class="user-menu__segmented">
<button
type="button"
:class="{ 'is-active': theme === 'light' }"
title="Light"
@click="emit('set-theme', 'light')"
>
<span class="dashicons dashicons-sun" />
</button>
<button
type="button"
:class="{ 'is-active': theme === 'dark' }"
title="Dark"
@click="emit('set-theme', 'dark')"
>
<span class="dashicons dashicons-moon" />
</button>
<button
type="button"
:class="{ 'is-active': theme === 'system' }"
title="System"
@click="emit('set-theme', 'system')"
>
<span class="dashicons dashicons-admin-generic" />
</button>
</div>
</div>
</div>
<button
type="button"
class="user-menu__trigger"
:class="{ 'user-menu__trigger--collapsed': collapsed }"
@click="open = !open"
>
<img v-if="serverData.avatar" :src="serverData.avatar" alt="" class="user-menu__trigger-avatar" />
<span v-if="!collapsed" class="user-menu__trigger-name">{{ serverData.userName }}</span>
<span v-if="!collapsed" class="dashicons dashicons-arrow-up-alt2 user-menu__trigger-chevron" />
</button>
</div>
</template>
<style scoped>
.user-menu {
position: relative;
flex-shrink: 0;
padding: 8px;
border-top: 1px solid var(--aurora-border);
}
.user-menu__trigger {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
border: none;
background: none;
cursor: pointer;
padding: 6px 8px;
border-radius: 8px;
color: var(--aurora-text);
}
.user-menu__trigger:hover {
background: var(--aurora-bg-subtle);
}
.user-menu__trigger--collapsed {
justify-content: center;
padding: 6px 0;
}
.user-menu__trigger-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
flex-shrink: 0;
}
.user-menu__trigger-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8125rem;
text-align: left;
}
.user-menu__trigger-chevron {
font-size: 14px;
width: 14px;
height: 14px;
color: var(--aurora-text-muted);
}
.user-menu__card {
position: absolute;
bottom: calc(100% + 8px);
left: 8px;
width: 240px;
background: var(--aurora-bg);
border: 1px solid var(--aurora-border);
border-radius: 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
overflow: hidden;
z-index: 100000;
}
.user-menu__header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px;
border-bottom: 1px solid var(--aurora-border);
}
.user-menu__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
}
.user-menu__id { min-width: 0; }
.user-menu__name {
font-size: 0.875rem;
font-weight: 600;
color: var(--aurora-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-menu__email {
font-size: 0.75rem;
color: var(--aurora-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-menu__items {
padding: 6px;
border-bottom: 1px solid var(--aurora-border);
}
.user-menu__item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
text-decoration: none;
color: var(--aurora-text);
font-size: 0.8125rem;
}
.user-menu__item:hover {
background: var(--aurora-bg-subtle);
}
.user-menu__item .dashicons {
font-size: 16px;
width: 16px;
height: 16px;
color: var(--aurora-text-muted);
}
.user-menu__item--danger {
color: #ef4444;
}
.user-menu__item--danger .dashicons {
color: #ef4444;
}
.user-menu__theme {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
}
.user-menu__theme-label {
font-size: 0.8125rem;
color: var(--aurora-text-muted);
}
.user-menu__segmented {
display: flex;
gap: 2px;
background: var(--aurora-bg-subtle);
border-radius: 8px;
padding: 2px;
}
.user-menu__segmented button {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: none;
border-radius: 6px;
cursor: pointer;
color: var(--aurora-text-muted);
}
.user-menu__segmented button .dashicons {
font-size: 14px;
width: 14px;
height: 14px;
}
.user-menu__segmented button.is-active {
background: var(--aurora-bg);
color: var(--aurora-accent);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
</style>