Initial commit: Aurora Admin plugin
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const input = ref(null);
|
||||
const query = ref('');
|
||||
const results = ref([]);
|
||||
const loading = ref(false);
|
||||
const highlighted = ref(0);
|
||||
|
||||
let debounceTimer;
|
||||
let requestSeq = 0;
|
||||
|
||||
const search = async () => {
|
||||
const q = query.value.trim();
|
||||
if (q.length < 2) {
|
||||
results.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = ++requestSeq;
|
||||
loading.value = true;
|
||||
try {
|
||||
const url = new URL(`${props.serverData.restUrl}wp/v2/search`);
|
||||
url.searchParams.set('search', q);
|
||||
url.searchParams.set('per_page', '10');
|
||||
// Settings > General > "Search post types" limits which post types the
|
||||
// global search covers; empty means all searchable types.
|
||||
const types = props.serverData.settings?.search_post_types;
|
||||
if (Array.isArray(types) && types.length) {
|
||||
url.searchParams.set('subtype', types.join(','));
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
headers: { 'X-WP-Nonce': props.serverData.restNonce },
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const items = await res.json();
|
||||
if (seq === requestSeq) {
|
||||
results.value = items;
|
||||
highlighted.value = 0;
|
||||
}
|
||||
} catch {
|
||||
if (seq === requestSeq) results.value = [];
|
||||
} finally {
|
||||
if (seq === requestSeq) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(query, () => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(search, 250);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
query.value = '';
|
||||
results.value = [];
|
||||
nextTick(() => input.value?.focus());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const openResult = (item) => {
|
||||
if (!item) return;
|
||||
window.location.href = `${props.serverData.adminUrl}post.php?post=${item.id}&action=edit`;
|
||||
};
|
||||
|
||||
const onKeydown = (e) => {
|
||||
if (!props.open) return;
|
||||
if (e.key === 'Escape') {
|
||||
emit('close');
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
highlighted.value = Math.min(highlighted.value + 1, results.value.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
highlighted.value = Math.max(highlighted.value - 1, 0);
|
||||
} else if (e.key === 'Enter') {
|
||||
openResult(results.value[highlighted.value]);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeydown));
|
||||
onUnmounted(() => document.removeEventListener('keydown', onKeydown));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="smodal__overlay" @click.self="emit('close')">
|
||||
<div class="smodal">
|
||||
<div class="smodal__inputwrap">
|
||||
<span class="dashicons dashicons-search smodal__icon" />
|
||||
<input
|
||||
ref="input"
|
||||
v-model="query"
|
||||
type="text"
|
||||
class="smodal__input"
|
||||
placeholder="Search posts, pages…"
|
||||
/>
|
||||
<span class="smodal__esc">Esc</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="smodal__state">Searching…</div>
|
||||
<div v-else-if="query.trim().length >= 2 && !results.length" class="smodal__state">
|
||||
No results for “{{ query.trim() }}”
|
||||
</div>
|
||||
|
||||
<ul v-if="results.length" class="smodal__results">
|
||||
<li v-for="(item, i) in results" :key="item.id">
|
||||
<button
|
||||
type="button"
|
||||
class="smodal__result"
|
||||
:class="{ 'smodal__result--active': i === highlighted }"
|
||||
@mouseenter="highlighted = i"
|
||||
@click="openResult(item)"
|
||||
>
|
||||
<span class="smodal__result-title" v-html="item.title" />
|
||||
<span class="smodal__result-type">{{ item.subtype }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.smodal__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 100000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 12vh;
|
||||
}
|
||||
.smodal {
|
||||
width: min(620px, calc(100vw - 40px));
|
||||
align-self: flex-start;
|
||||
background: var(--aurora-frame-bg, #1c1c20);
|
||||
border: 1px solid var(--aurora-frame-border, #34343b);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
.smodal__inputwrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--aurora-border, #27272a);
|
||||
}
|
||||
.smodal__icon { color: var(--aurora-text-muted); }
|
||||
.smodal__input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.smodal__esc {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--aurora-text-muted);
|
||||
border: 1px solid var(--aurora-border, #27272a);
|
||||
border-radius: 5px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.smodal__state {
|
||||
padding: 18px 16px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.smodal__results {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.smodal__result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
.smodal__result--active { background: var(--aurora-accent-soft); }
|
||||
.smodal__result-title {
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.875rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.smodal__result-type {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--aurora-text-muted);
|
||||
border: 1px solid var(--aurora-border, #27272a);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,414 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
menu: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
logo: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
darkLogo: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isDark: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
auroraNav: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
// Custom logo from Settings > General. Dark mode prefers the dark variant
|
||||
// and falls back to the standard logo; no logo set = "Aurora" wordmark.
|
||||
const activeLogo = computed(() =>
|
||||
props.isDark ? props.darkLogo || props.logo : props.logo
|
||||
);
|
||||
|
||||
// Normalize any admin URL down to the part after /wp-admin/ so we can
|
||||
// compare the current page against menu targets regardless of host/scheme.
|
||||
const normalize = (url) => {
|
||||
if (!url) return '';
|
||||
const idx = url.indexOf('/wp-admin/');
|
||||
const path = idx === -1 ? url : url.slice(idx + '/wp-admin/'.length);
|
||||
// The bare admin root ("/wp-admin/" with nothing after it) IS the
|
||||
// dashboard, same as an explicit index.php — normalize them to one form
|
||||
// so the Dashboard section correctly reads as active/expanded there.
|
||||
return path === '' || path.startsWith('?') ? 'index.php' + path : path;
|
||||
};
|
||||
|
||||
const current = computed(() => normalize(props.currentUrl));
|
||||
|
||||
const isItemActive = (item) => {
|
||||
if (!item.url) return false;
|
||||
return normalize(item.url) === current.value;
|
||||
};
|
||||
|
||||
const isSectionActive = (item) => {
|
||||
if (isItemActive(item)) return true;
|
||||
return (item.submenu || []).some((sub) => normalize(sub.url) === current.value);
|
||||
};
|
||||
|
||||
// Which sections are manually expanded. Active section starts expanded.
|
||||
const expanded = ref(new Set());
|
||||
|
||||
const isExpanded = (item) => {
|
||||
return expanded.value.has(item.id) || isSectionActive(item);
|
||||
};
|
||||
|
||||
const toggle = (item) => {
|
||||
const next = new Set(expanded.value);
|
||||
if (next.has(item.id)) {
|
||||
next.delete(item.id);
|
||||
} else {
|
||||
next.add(item.id);
|
||||
}
|
||||
expanded.value = next;
|
||||
};
|
||||
|
||||
// Aurora's own branded nav section (Settings, Menu Creator, Admin Notices,
|
||||
// Role Editor, Activity Log, Database Explorer) — separate from the
|
||||
// WP-native menu list above it. Expanded by default; per-browser preference
|
||||
// kept in localStorage, same pattern as the main collapsed-menu state.
|
||||
const auroraExpanded = ref(localStorage.getItem('aurora-nav-expanded') !== '0');
|
||||
const toggleAuroraNav = () => {
|
||||
auroraExpanded.value = !auroraExpanded.value;
|
||||
localStorage.setItem('aurora-nav-expanded', auroraExpanded.value ? '1' : '0');
|
||||
};
|
||||
const isAuroraActive = (item) => normalize(item.url) === current.value;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="sidebar" :class="{ 'sidebar--collapsed': collapsed }">
|
||||
<div class="sidebar__brand">
|
||||
<img v-if="activeLogo" :src="activeLogo" alt="" class="sidebar__brand-img" />
|
||||
<template v-else>{{ collapsed ? 'A' : 'Aurora' }}</template>
|
||||
</div>
|
||||
|
||||
<ul class="sidebar__list">
|
||||
<template v-for="(item, index) in menu" :key="item.id || index">
|
||||
<li v-if="item.separator" class="sidebar__separator" />
|
||||
|
||||
<li v-else class="sidebar__item">
|
||||
<div
|
||||
class="sidebar__row"
|
||||
:class="{ 'sidebar__row--active': isSectionActive(item) }"
|
||||
>
|
||||
<a :href="item.url" class="sidebar__link" :title="collapsed ? item.title : null">
|
||||
<span class="sidebar__icon">
|
||||
<span
|
||||
v-if="item.icon && item.icon.type === 'dashicon'"
|
||||
class="dashicons"
|
||||
:class="item.icon.value"
|
||||
/>
|
||||
<img
|
||||
v-else-if="item.icon && item.icon.type === 'image'"
|
||||
:src="item.icon.value"
|
||||
alt=""
|
||||
class="sidebar__icon-img"
|
||||
/>
|
||||
<span v-else class="sidebar__icon-dot" />
|
||||
</span>
|
||||
<span class="sidebar__title">{{ item.title }}</span>
|
||||
<span v-if="item.count" class="sidebar__count">{{ item.count }}</span>
|
||||
</a>
|
||||
<button
|
||||
v-if="item.submenu && item.submenu.length"
|
||||
type="button"
|
||||
class="sidebar__chevron"
|
||||
:class="{ 'sidebar__chevron--open': isExpanded(item) }"
|
||||
:aria-label="`Toggle ${item.title} submenu`"
|
||||
@click="toggle(item)"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
v-if="item.submenu && item.submenu.length && isExpanded(item)"
|
||||
class="sidebar__sublist"
|
||||
>
|
||||
<li
|
||||
v-for="(sub, subIndex) in item.submenu"
|
||||
:key="sub.id || subIndex"
|
||||
class="sidebar__subitem"
|
||||
>
|
||||
<a
|
||||
:href="sub.url"
|
||||
class="sidebar__sublink"
|
||||
:class="{ 'sidebar__sublink--active': normalize(sub.url) === current }"
|
||||
>
|
||||
{{ sub.title }}
|
||||
<span v-if="sub.count" class="sidebar__count">{{ sub.count }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
|
||||
<div v-if="auroraNav.length" class="sidebar__separator" />
|
||||
|
||||
<div v-if="auroraNav.length" class="sidebar__aurora">
|
||||
<div class="sidebar__row sidebar__aurora-brand" @click="toggleAuroraNav">
|
||||
<a href="#" class="sidebar__link" @click.prevent :title="collapsed ? 'Aurora' : null">
|
||||
<span class="sidebar__icon">
|
||||
<span class="dashicons dashicons-superhero-alt" />
|
||||
</span>
|
||||
<span class="sidebar__title">Aurora</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar__chevron"
|
||||
:class="{ 'sidebar__chevron--open': auroraExpanded }"
|
||||
aria-label="Toggle Aurora menu"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul v-if="auroraExpanded" class="sidebar__list sidebar__aurora-list">
|
||||
<li v-for="item in auroraNav" :key="item.id" class="sidebar__item">
|
||||
<div class="sidebar__row" :class="{ 'sidebar__row--active': isAuroraActive(item) }">
|
||||
<a :href="item.url" class="sidebar__link" :title="collapsed ? item.title : null">
|
||||
<span class="sidebar__icon">
|
||||
<span class="dashicons" :class="item.icon?.value" />
|
||||
</span>
|
||||
<span class="sidebar__title">{{ item.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--aurora-sidebar-w);
|
||||
height: 100vh;
|
||||
background: var(--aurora-app-bg);
|
||||
overflow-y: auto;
|
||||
transition: width 0.2s ease;
|
||||
z-index: 99999;
|
||||
padding: 12px 0;
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar__brand {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--aurora-text);
|
||||
padding: 8px 20px 16px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sidebar__brand-img {
|
||||
display: block;
|
||||
max-height: 30px;
|
||||
max-width: 190px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sidebar__list,
|
||||
.sidebar__sublist {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar__separator {
|
||||
height: 1px;
|
||||
background: var(--aurora-border);
|
||||
margin: 8px 16px;
|
||||
}
|
||||
|
||||
.sidebar__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
margin: 1px 8px;
|
||||
}
|
||||
|
||||
.sidebar__row--active {
|
||||
background: var(--aurora-accent-soft);
|
||||
}
|
||||
|
||||
.sidebar__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
text-decoration: none;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.875rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar__row--active .sidebar__link {
|
||||
color: var(--aurora-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text-muted);
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.sidebar__row--active .sidebar__icon {
|
||||
background: var(--aurora-accent-soft);
|
||||
color: var(--aurora-accent);
|
||||
}
|
||||
|
||||
.sidebar__icon .dashicons {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.sidebar__icon-img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sidebar__icon-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--aurora-text-muted);
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar__count {
|
||||
background: var(--aurora-accent);
|
||||
color: var(--aurora-accent-text);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__chevron {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar__chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sidebar__sublist {
|
||||
padding: 2px 0 6px;
|
||||
}
|
||||
|
||||
.sidebar__sublink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px 6px 42px;
|
||||
text-decoration: none;
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
border-radius: 6px;
|
||||
margin: 1px 8px;
|
||||
}
|
||||
|
||||
.sidebar__sublink:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
|
||||
.sidebar__sublink--active {
|
||||
color: var(--aurora-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Aurora branded nav (right under the native menu's own divider) ---- */
|
||||
.sidebar__aurora {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.sidebar__aurora-brand {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sidebar__aurora-brand .sidebar__title {
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.sidebar__aurora-brand .sidebar__icon {
|
||||
background: var(--aurora-accent-soft);
|
||||
color: var(--aurora-accent);
|
||||
}
|
||||
|
||||
/* --- Collapsed (icon-only) rail --------------------------------------- */
|
||||
.sidebar--collapsed {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.sidebar--collapsed .sidebar__brand {
|
||||
padding: 8px 0 16px;
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.sidebar--collapsed .sidebar__brand-img {
|
||||
margin: 0 auto;
|
||||
max-width: 40px;
|
||||
}
|
||||
.sidebar--collapsed .sidebar__title,
|
||||
.sidebar--collapsed .sidebar__count,
|
||||
.sidebar--collapsed .sidebar__chevron,
|
||||
.sidebar--collapsed .sidebar__sublist {
|
||||
display: none;
|
||||
}
|
||||
.sidebar--collapsed .sidebar__row {
|
||||
margin: 1px 10px;
|
||||
}
|
||||
.sidebar--collapsed .sidebar__link {
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,341 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import SearchModal from './SearchModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isDark: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle-dark', 'toggle-menu']);
|
||||
|
||||
const menuOpen = ref(false);
|
||||
const searchOpen = ref(false);
|
||||
const sitesOpen = ref(false);
|
||||
|
||||
// Settings > General > "Disable global search" hides the search entirely.
|
||||
const searchDisabled = computed(
|
||||
() => !!props.serverData.settings?.disable_global_search
|
||||
);
|
||||
|
||||
// Remote sites (secrets already stripped server-side). The switcher only
|
||||
// shows for users holding the configured capability (canSwitchSites).
|
||||
const remoteSites = computed(() => {
|
||||
const sites = props.serverData.settings?.remote_sites;
|
||||
return Array.isArray(sites) ? sites.filter((s) => s && s.url) : [];
|
||||
});
|
||||
const showSwitcher = computed(
|
||||
() => !!props.serverData.canSwitchSites && remoteSites.value.length > 0
|
||||
);
|
||||
|
||||
const siteAdminUrl = (site) => {
|
||||
let url = (site.url || '').trim();
|
||||
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
|
||||
return url.replace(/\/+$/, '') + '/wp-admin/';
|
||||
};
|
||||
const siteHost = (site) => {
|
||||
try {
|
||||
return new URL(siteAdminUrl(site)).host;
|
||||
} catch {
|
||||
return site.url;
|
||||
}
|
||||
};
|
||||
|
||||
const onKeydown = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
if (searchDisabled.value) return;
|
||||
e.preventDefault();
|
||||
searchOpen.value = true;
|
||||
}
|
||||
};
|
||||
onMounted(() => document.addEventListener('keydown', onKeydown));
|
||||
onUnmounted(() => document.removeEventListener('keydown', onKeydown));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="toolbar">
|
||||
<div class="toolbar__left">
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar__icon-btn"
|
||||
title="Toggle menu"
|
||||
@click="emit('toggle-menu')"
|
||||
>
|
||||
<span class="dashicons dashicons-menu" />
|
||||
</button>
|
||||
<a
|
||||
:href="serverData.siteUrl || '/'"
|
||||
class="toolbar__home"
|
||||
title="View website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="dashicons dashicons-admin-home" />
|
||||
</a>
|
||||
<a :href="serverData.adminUrl" class="toolbar__home" title="Dashboard">
|
||||
<span class="dashicons dashicons-dashboard" />
|
||||
</a>
|
||||
<button
|
||||
v-if="!searchDisabled"
|
||||
type="button"
|
||||
class="toolbar__search"
|
||||
@click="searchOpen = true"
|
||||
>
|
||||
<span class="dashicons dashicons-search toolbar__search-icon" />
|
||||
<span class="toolbar__search-text">Search…</span>
|
||||
<span class="toolbar__search-kbd">Ctrl K</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar__right">
|
||||
<div v-if="showSwitcher" class="toolbar__sites">
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar__icon-btn"
|
||||
title="Switch site"
|
||||
@click="sitesOpen = !sitesOpen"
|
||||
>
|
||||
<span class="dashicons dashicons-admin-multisite" />
|
||||
</button>
|
||||
<div v-if="sitesOpen" class="toolbar__dropdown">
|
||||
<div class="toolbar__dropdown-header">
|
||||
<div class="toolbar__dropdown-name">Remote sites</div>
|
||||
</div>
|
||||
<a
|
||||
v-for="(site, i) in remoteSites"
|
||||
:key="i"
|
||||
:href="siteAdminUrl(site)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="toolbar__dropdown-item"
|
||||
>
|
||||
{{ siteHost(site) }}
|
||||
<span v-if="site.username" class="toolbar__site-user">{{ site.username }}</span>
|
||||
</a>
|
||||
</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>
|
||||
|
||||
<SearchModal
|
||||
:server-data="serverData"
|
||||
:open="searchOpen"
|
||||
@close="searchOpen = false"
|
||||
/>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: var(--aurora-sidebar-w);
|
||||
right: 0;
|
||||
height: var(--aurora-toolbar-h);
|
||||
background: var(--aurora-app-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
z-index: 99998;
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.toolbar__left,
|
||||
.toolbar__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar__home {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
color: var(--aurora-text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.toolbar__home:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
|
||||
.toolbar__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
min-width: 240px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 10px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.toolbar__search-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.toolbar__search-text {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.toolbar__search-kbd {
|
||||
font-size: 0.6875rem;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 5px;
|
||||
padding: 1px 6px;
|
||||
background: var(--aurora-bg);
|
||||
}
|
||||
|
||||
.toolbar__icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
|
||||
.toolbar__icon-btn:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
|
||||
.toolbar__theme-toggle {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.toolbar__user,
|
||||
.toolbar__sites {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar__site-user {
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.75rem;
|
||||
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 {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
background: var(--aurora-bg);
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
z-index: 100000;
|
||||
}
|
||||
|
||||
.toolbar__dropdown-header {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
}
|
||||
|
||||
.toolbar__dropdown-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
|
||||
.toolbar__dropdown-email {
|
||||
font-size: 0.75rem;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
|
||||
.toolbar__dropdown-item {
|
||||
display: block;
|
||||
padding: 10px 14px;
|
||||
text-decoration: none;
|
||||
color: var(--aurora-text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.toolbar__dropdown-item:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user