Initial commit: Aurora Admin plugin
This commit is contained in:
Generated
+1363
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "aurora-admin-app",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.26"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="toggle">
|
||||
<span v-if="label" class="toggle__label">{{ label }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle__switch"
|
||||
:class="{ 'toggle__switch--on': modelValue }"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
>
|
||||
<span class="toggle__thumb" />
|
||||
</button>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle__label {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.toggle__switch {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: var(--aurora-border);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle__switch--on {
|
||||
background: var(--aurora-accent);
|
||||
}
|
||||
|
||||
.toggle__thumb {
|
||||
position: absolute;
|
||||
top: 0.15rem;
|
||||
left: 0.15rem;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.toggle__switch--on .toggle__thumb {
|
||||
transform: translateX(1.1rem);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import ActivityLog from '@/pages/activity-log/ActivityLog.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-activity-log-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(ActivityLog, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import AdminNotices from '@/pages/admin-notices/AdminNotices.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-admin-notices-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(AdminNotices, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue';
|
||||
// Pulls in the shared --aurora-* theme variables. Harmless if the site-wide
|
||||
// shell already loaded them (identical values); keeps this page self-sufficient.
|
||||
import '@/shell/shell-frame.css';
|
||||
import DashboardPage from '@/pages/dashboard/Dashboard.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-dashboard-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(DashboardPage, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import DatabaseExplorer from '@/pages/database-explorer/DatabaseExplorer.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-database-explorer-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(DatabaseExplorer, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import MenuCreator from '@/pages/menu-creator/MenuCreator.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-menu-creator-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(MenuCreator, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import RoleEditor from '@/pages/role-editor/RoleEditor.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-role-editor-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(RoleEditor, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import SettingsPage from '@/pages/settings/Settings.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-settings-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(SettingsPage, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import SiteShell from '@/shell/SiteShell.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-shell-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(SiteShell, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
icon: { type: String, default: '' },
|
||||
title: { type: String, required: true },
|
||||
subtitle: { type: String, default: '' },
|
||||
active: { type: Boolean, default: false },
|
||||
});
|
||||
defineEmits(['click']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="app-list-item"
|
||||
:class="{ 'app-list-item--active': active }"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<span v-if="icon" class="app-list-item__icon">
|
||||
<span class="dashicons" :class="icon" />
|
||||
</span>
|
||||
<span class="app-list-item__text">
|
||||
<span class="app-list-item__title">{{ title }}</span>
|
||||
<span v-if="subtitle" class="app-list-item__subtitle">{{ subtitle }}</span>
|
||||
</span>
|
||||
<slot name="trailing" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.app-list-item:hover {
|
||||
background: var(--aurora-bg-subtle);
|
||||
}
|
||||
.app-list-item--active {
|
||||
background: var(--aurora-accent-soft);
|
||||
}
|
||||
.app-list-item__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.app-list-item--active .app-list-item__icon {
|
||||
color: var(--aurora-accent);
|
||||
}
|
||||
.app-list-item__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.app-list-item__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--aurora-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.app-list-item--active .app-list-item__title {
|
||||
color: var(--aurora-accent);
|
||||
}
|
||||
.app-list-item__subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--aurora-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
searchPlaceholder: { type: String, default: 'Search…' },
|
||||
countLabel: { type: String, default: '' },
|
||||
page: { type: Number, default: 1 },
|
||||
hasPrev: { type: Boolean, default: false },
|
||||
hasNext: { type: Boolean, default: false },
|
||||
// Right-panel placeholder, shown until something is selected.
|
||||
detailIcon: { type: String, default: 'dashicons-admin-generic' },
|
||||
detailTitle: { type: String, default: '' },
|
||||
detailText: { type: String, default: '' },
|
||||
hasSelection: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineEmits(['prev-page', 'next-page']);
|
||||
|
||||
const search = defineModel('search', { type: String, default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-list-shell">
|
||||
<!-- Left: list panel -->
|
||||
<aside class="app-list-shell__nav">
|
||||
<div class="app-list-shell__nav-head">
|
||||
<h1 class="app-list-shell__title">{{ title }}</h1>
|
||||
<div class="app-list-shell__actions">
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-list-shell__search">
|
||||
<span class="dashicons dashicons-search" />
|
||||
<input v-model="search" type="text" :placeholder="searchPlaceholder" />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.filters" class="app-list-shell__filters">
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
|
||||
<div v-if="countLabel || hasPrev || hasNext" class="app-list-shell__meta">
|
||||
<span>{{ countLabel }}</span>
|
||||
<span v-if="hasPrev || hasNext" class="app-list-shell__pager">
|
||||
Page {{ page }}
|
||||
<button type="button" :disabled="!hasPrev" @click="$emit('prev-page')">‹</button>
|
||||
<button type="button" :disabled="!hasNext" @click="$emit('next-page')">›</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="app-list-shell__list">
|
||||
<slot name="list" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right: detail panel -->
|
||||
<section class="app-list-shell__content">
|
||||
<slot v-if="hasSelection" name="detail" />
|
||||
<div v-else class="app-list-shell__empty">
|
||||
<span class="app-list-shell__empty-icon">
|
||||
<span class="dashicons" :class="detailIcon" />
|
||||
</span>
|
||||
<h2 class="app-list-shell__empty-title">{{ detailTitle }}</h2>
|
||||
<p class="app-list-shell__empty-text">{{ detailText }}</p>
|
||||
<slot name="detail-empty-actions" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-list-shell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
min-height: calc(100vh - var(--aurora-toolbar-h) - 60px);
|
||||
margin: -32px;
|
||||
padding: 22px;
|
||||
box-sizing: border-box;
|
||||
background: var(--aurora-app-bg);
|
||||
}
|
||||
|
||||
/* Left — list box */
|
||||
.app-list-shell__nav {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
background: var(--aurora-frame-bg);
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 16px;
|
||||
padding: 24px 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.app-list-shell__nav-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.app-list-shell__title { font-size: 1.35rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
|
||||
.app-list-shell__actions { display: flex; gap: 6px; align-items: center; }
|
||||
.app-list-shell__actions :deep(button) {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
height: 30px; padding: 0 10px; border: none; border-radius: 7px;
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
|
||||
font-size: 0.75rem; font-weight: 500; cursor: pointer; gap: 6px;
|
||||
}
|
||||
.app-list-shell__actions :deep(button.is-primary) {
|
||||
background: var(--aurora-accent); color: #fff;
|
||||
}
|
||||
.app-list-shell__actions :deep(button):hover { opacity: 0.85; }
|
||||
|
||||
.app-list-shell__search {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 10px; padding: 8px 12px; margin-bottom: 14px;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.app-list-shell__search input {
|
||||
border: none; background: none; outline: none; color: var(--aurora-text);
|
||||
font-size: 0.8125rem; width: 100%;
|
||||
}
|
||||
|
||||
.app-list-shell__filters {
|
||||
display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.app-list-shell__filters :deep(button) {
|
||||
border: none; border-radius: 999px; padding: 5px 12px;
|
||||
font-size: 0.75rem; font-weight: 500; cursor: pointer;
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
|
||||
}
|
||||
.app-list-shell__filters :deep(button.is-active) {
|
||||
background: var(--aurora-text); color: var(--aurora-app-bg);
|
||||
}
|
||||
|
||||
.app-list-shell__meta {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.03em;
|
||||
text-transform: uppercase; color: var(--aurora-text-muted);
|
||||
margin-bottom: 8px; padding: 0 2px;
|
||||
}
|
||||
.app-list-shell__pager { display: flex; align-items: center; gap: 4px; text-transform: none; }
|
||||
.app-list-shell__pager button {
|
||||
border: none; background: none; color: var(--aurora-text-muted);
|
||||
cursor: pointer; font-size: 0.9rem; padding: 0 2px;
|
||||
}
|
||||
.app-list-shell__pager button:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.app-list-shell__list { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
/* Right — content box */
|
||||
.app-list-shell__content {
|
||||
flex: 1; min-width: 0;
|
||||
background: var(--aurora-frame-bg);
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 16px;
|
||||
padding: 28px 32px;
|
||||
min-height: 480px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-list-shell__empty {
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
text-align: center; gap: 6px;
|
||||
}
|
||||
.app-list-shell__empty-icon {
|
||||
width: 56px; height: 56px; border-radius: 14px;
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px; margin-bottom: 10px;
|
||||
}
|
||||
.app-list-shell__empty-title { font-size: 1.05rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
|
||||
.app-list-shell__empty-text { font-size: 0.8125rem; color: var(--aurora-text-muted); max-width: 320px; margin: 0; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.app-list-shell { flex-direction: column; }
|
||||
.app-list-shell__nav { width: auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import AppListShell from '../_shared/AppListShell.vue';
|
||||
import AppListItem from '../_shared/AppListItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const api = (path) =>
|
||||
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
|
||||
headers: { 'X-WP-Nonce': props.serverData.restNonce },
|
||||
}).then((r) => {
|
||||
if (!r.ok) throw new Error('Request failed');
|
||||
return r.json();
|
||||
});
|
||||
|
||||
const ACTION_TYPES = [
|
||||
['', 'All Actions'],
|
||||
['post_created', 'Post created'],
|
||||
['post_updated', 'Post updated'],
|
||||
['post_deleted', 'Post deleted'],
|
||||
['user_registered', 'User registered'],
|
||||
['user_updated', 'User updated'],
|
||||
['login', 'Login'],
|
||||
['plugin_activated', 'Plugin activated'],
|
||||
['plugin_deactivated', 'Plugin deactivated'],
|
||||
['theme_switched', 'Theme switched'],
|
||||
['settings_updated', 'Settings updated'],
|
||||
];
|
||||
const OBJECT_TYPES = [
|
||||
['', 'All Types'],
|
||||
['post', 'Post'],
|
||||
['user', 'User'],
|
||||
['plugin', 'Plugin'],
|
||||
['theme', 'Theme'],
|
||||
['settings', 'Settings'],
|
||||
];
|
||||
|
||||
const items = ref([]);
|
||||
const loading = ref(true);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const totalPages = ref(1);
|
||||
const actionFilter = ref('');
|
||||
const objectFilter = ref('');
|
||||
const search = ref('');
|
||||
const selectedId = ref(null);
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (actionFilter.value) params.set('action_type', actionFilter.value);
|
||||
if (objectFilter.value) params.set('object_type', objectFilter.value);
|
||||
if (search.value.trim()) params.set('search', search.value.trim());
|
||||
params.set('page', page.value);
|
||||
const data = await api(`/activity-log?${params.toString()}`);
|
||||
items.value = data.items;
|
||||
total.value = data.total;
|
||||
totalPages.value = data.totalPages;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
watch([actionFilter, objectFilter, search], () => {
|
||||
page.value = 1;
|
||||
load();
|
||||
});
|
||||
watch(page, load);
|
||||
|
||||
const selected = computed(() => items.value.find((i) => i.id === selectedId.value) || null);
|
||||
|
||||
const formatDate = (s) => (s ? new Date(s.replace(' ', 'T')).toLocaleString() : '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppListShell
|
||||
v-model:search="search"
|
||||
title="Activity Log"
|
||||
search-placeholder="Search activities…"
|
||||
:count-label="`${total} ITEMS`"
|
||||
:page="page"
|
||||
:has-prev="page > 1"
|
||||
:has-next="page < totalPages"
|
||||
detail-icon="dashicons-clock"
|
||||
detail-title="Activity Details"
|
||||
detail-text="Select an activity from the list to view detailed information."
|
||||
:has-selection="!!selected"
|
||||
@prev-page="page--"
|
||||
@next-page="page++"
|
||||
>
|
||||
<template #header-actions>
|
||||
<button type="button" @click="load">
|
||||
<span class="dashicons dashicons-update" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<select v-model="actionFilter" class="activity-log__select">
|
||||
<option v-for="[v, l] in ACTION_TYPES" :key="v" :value="v">{{ l }}</option>
|
||||
</select>
|
||||
<select v-model="objectFilter" class="activity-log__select">
|
||||
<option v-for="[v, l] in OBJECT_TYPES" :key="v" :value="v">{{ l }}</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<template #list>
|
||||
<p v-if="!loading && !items.length" style="font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 20px 4px; text-align: center;">
|
||||
No activities found
|
||||
</p>
|
||||
<AppListItem
|
||||
v-for="i in items"
|
||||
:key="i.id"
|
||||
icon="dashicons-clock"
|
||||
:title="i.objectLabel || i.actionType"
|
||||
:subtitle="`${i.userName || 'System'} · ${formatDate(i.createdAt)}`"
|
||||
:active="i.id === selectedId"
|
||||
@click="selectedId = i.id"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #detail>
|
||||
<div v-if="selected" class="activity-detail">
|
||||
<h2>{{ selected.objectLabel || selected.actionType }}</h2>
|
||||
<dl>
|
||||
<dt>Action</dt><dd>{{ selected.actionType }}</dd>
|
||||
<dt>Object type</dt><dd>{{ selected.objectType }}</dd>
|
||||
<dt>User</dt><dd>{{ selected.userName || 'System' }}</dd>
|
||||
<dt>Date</dt><dd>{{ formatDate(selected.createdAt) }}</dd>
|
||||
</dl>
|
||||
<pre v-if="selected.details">{{ JSON.stringify(selected.details, null, 2) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</AppListShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.activity-log__select {
|
||||
padding: 5px 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.75rem;
|
||||
}
|
||||
.activity-detail h2 { margin: 0 0 16px; font-size: 1.1rem; color: var(--aurora-text); }
|
||||
.activity-detail dl { display: grid; grid-template-columns: 120px 1fr; gap: 8px 12px; font-size: 0.8125rem; margin: 0; }
|
||||
.activity-detail dt { color: var(--aurora-text-muted); }
|
||||
.activity-detail dd { margin: 0; color: var(--aurora-text); }
|
||||
.activity-detail pre {
|
||||
margin-top: 18px; padding: 12px; background: var(--aurora-bg-subtle);
|
||||
border-radius: 8px; font-size: 0.75rem; overflow-x: auto; color: var(--aurora-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import AppListShell from '../_shared/AppListShell.vue';
|
||||
import AppListItem from '../_shared/AppListItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const api = (path, opts = {}) =>
|
||||
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
|
||||
...opts,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
...(opts.headers || {}),
|
||||
},
|
||||
}).then((r) => {
|
||||
if (!r.ok) throw new Error('Request failed');
|
||||
return r.json();
|
||||
});
|
||||
|
||||
const notices = ref([]);
|
||||
const loading = ref(true);
|
||||
const search = ref('');
|
||||
const selectedId = ref(null);
|
||||
const isCreating = ref(false);
|
||||
const roleOptions = ref([]);
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
notices.value = await api('/notices');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
roleOptions.value = props.serverData.roles || [];
|
||||
});
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (!q) return notices.value;
|
||||
return notices.value.filter((n) => n.title.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const selected = computed(() => notices.value.find((n) => n.id === selectedId.value) || null);
|
||||
|
||||
const draft = reactive({
|
||||
title: '', message: '', type: 'info', roles: [], pages: '', dismissible: true, active: true,
|
||||
});
|
||||
|
||||
const selectNotice = (n) => {
|
||||
selectedId.value = n.id;
|
||||
isCreating.value = false;
|
||||
Object.assign(draft, {
|
||||
title: n.title, message: n.message, type: n.type, roles: [...n.roles],
|
||||
pages: n.pages.join(', '), dismissible: n.dismissible, active: n.active,
|
||||
});
|
||||
};
|
||||
|
||||
const newNotice = () => {
|
||||
selectedId.value = null;
|
||||
isCreating.value = true;
|
||||
Object.assign(draft, { title: 'New notice', message: '', type: 'info', roles: [], pages: '', dismissible: true, active: true });
|
||||
};
|
||||
|
||||
const toggleRole = (slug) => {
|
||||
const idx = draft.roles.indexOf(slug);
|
||||
if (idx === -1) draft.roles.push(slug);
|
||||
else draft.roles.splice(idx, 1);
|
||||
};
|
||||
|
||||
const saving = ref(false);
|
||||
const save = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
title: draft.title,
|
||||
message: draft.message,
|
||||
type: draft.type,
|
||||
roles: draft.roles,
|
||||
pages: draft.pages.split(',').map((p) => p.trim()).filter(Boolean),
|
||||
dismissible: draft.dismissible,
|
||||
active: draft.active,
|
||||
};
|
||||
if (isCreating.value) {
|
||||
const created = await api('/notices', { method: 'POST', body: JSON.stringify(payload) });
|
||||
await load();
|
||||
selectNotice(created);
|
||||
} else if (selectedId.value) {
|
||||
await api(`/notices/${selectedId.value}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
await load();
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!selectedId.value) return;
|
||||
await api(`/notices/${selectedId.value}`, { method: 'DELETE' });
|
||||
selectedId.value = null;
|
||||
await load();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppListShell
|
||||
v-model:search="search"
|
||||
title="Admin Notices"
|
||||
search-placeholder="Search notices…"
|
||||
detail-icon="dashicons-megaphone"
|
||||
detail-title="Notice Editor"
|
||||
detail-text="Select a notice from the list to edit it, or create a new one to get started."
|
||||
:has-selection="!!selected || isCreating"
|
||||
>
|
||||
<template #header-actions>
|
||||
<button type="button" @click="newNotice">+</button>
|
||||
</template>
|
||||
|
||||
<template #detail-empty-actions>
|
||||
<button type="button" class="is-primary" style="margin-top: 14px;" @click="newNotice">
|
||||
Create Your First Notice
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #list>
|
||||
<AppListItem
|
||||
v-for="n in filtered"
|
||||
:key="n.id"
|
||||
icon="dashicons-megaphone"
|
||||
:title="n.title"
|
||||
:subtitle="n.active ? n.type : `${n.type} (inactive)`"
|
||||
:active="n.id === selectedId"
|
||||
@click="selectNotice(n)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #detail>
|
||||
<div class="notice-editor">
|
||||
<div class="notice-editor__row">
|
||||
<input v-model="draft.title" type="text" placeholder="Notice title" class="notice-editor__title" />
|
||||
<select v-model="draft.type">
|
||||
<option value="info">Info</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<textarea v-model="draft.message" placeholder="Message" rows="4" />
|
||||
|
||||
<div class="notice-editor__section">
|
||||
<h3>Target roles (empty = all)</h3>
|
||||
<div class="notice-editor__roles">
|
||||
<label v-for="r in roleOptions" :key="r.value" class="notice-editor__role">
|
||||
<input type="checkbox" :checked="draft.roles.includes(r.value)" @change="toggleRole(r.value)" />
|
||||
{{ r.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notice-editor__section">
|
||||
<h3>Target pages (comma-separated, e.g. index.php, edit.php — empty = all)</h3>
|
||||
<input v-model="draft.pages" type="text" placeholder="index.php, edit.php" />
|
||||
</div>
|
||||
|
||||
<div class="notice-editor__toggles">
|
||||
<label><input type="checkbox" v-model="draft.dismissible" /> Dismissible</label>
|
||||
<label><input type="checkbox" v-model="draft.active" /> Active</label>
|
||||
</div>
|
||||
|
||||
<div class="notice-editor__actions">
|
||||
<button type="button" class="is-danger" :disabled="!selectedId" @click="remove">Delete</button>
|
||||
<button type="button" class="is-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AppListShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notice-editor { display: flex; flex-direction: column; gap: 18px; }
|
||||
.notice-editor__row { display: flex; gap: 10px; }
|
||||
.notice-editor__title {
|
||||
flex: 1; font-size: 1.05rem; font-weight: 700; padding: 8px 10px;
|
||||
border: 1px solid var(--aurora-border); border-radius: 8px;
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text);
|
||||
}
|
||||
.notice-editor select, .notice-editor input[type='text'], .notice-editor textarea {
|
||||
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.8125rem;
|
||||
font-family: inherit; width: 100%; box-sizing: border-box;
|
||||
}
|
||||
.notice-editor__section h3 {
|
||||
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em;
|
||||
color: var(--aurora-text-muted); margin: 0 0 8px;
|
||||
}
|
||||
.notice-editor__roles { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.notice-editor__role { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; color: var(--aurora-text); }
|
||||
.notice-editor__toggles { display: flex; gap: 20px; font-size: 0.8125rem; color: var(--aurora-text); }
|
||||
.notice-editor__toggles label { display: flex; align-items: center; gap: 6px; }
|
||||
.notice-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.notice-editor__actions button {
|
||||
border: none; border-radius: 8px; padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text);
|
||||
}
|
||||
.notice-editor__actions .is-primary { background: var(--aurora-accent); color: #fff; }
|
||||
.notice-editor__actions .is-danger { color: #ef4444; }
|
||||
.notice-editor__actions button:disabled { opacity: 0.5; cursor: default; }
|
||||
</style>
|
||||
@@ -0,0 +1,249 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useDashboardData } from './useDashboardData.js';
|
||||
import { useAnalyticsData } from './useAnalyticsData.js';
|
||||
import RecentContent from './cards/RecentContent.vue';
|
||||
import ScheduledContent from './cards/ScheduledContent.vue';
|
||||
import RecentComments from './cards/RecentComments.vue';
|
||||
import UserAnalytics from './cards/UserAnalytics.vue';
|
||||
import MediaAnalytics from './cards/MediaAnalytics.vue';
|
||||
import ServerHealth from './cards/ServerHealth.vue';
|
||||
import PageViews from './cards/PageViews.vue';
|
||||
import ActiveUsers from './cards/ActiveUsers.vue';
|
||||
import DeviceUsage from './cards/DeviceUsage.vue';
|
||||
import Engagement from './cards/Engagement.vue';
|
||||
import TopList from './cards/TopList.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const { data, loading, load } = useDashboardData(props.serverData);
|
||||
const { data: analytics, load: loadAnalytics } = useAnalyticsData(props.serverData);
|
||||
|
||||
// Date range — default last 7 days, with simple presets.
|
||||
const today = new Date();
|
||||
const range = ref([new Date(new Date().setDate(today.getDate() - 7)), new Date()]);
|
||||
const presetsOpen = ref(false);
|
||||
|
||||
const fmtLabel = (d) =>
|
||||
d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
const reload = (from, to) => {
|
||||
load(from, to);
|
||||
loadAnalytics(from, to);
|
||||
};
|
||||
|
||||
const applyPreset = (days) => {
|
||||
const to = new Date();
|
||||
const from = new Date();
|
||||
from.setDate(to.getDate() - days);
|
||||
range.value = [from, to];
|
||||
presetsOpen.value = false;
|
||||
reload(from, to);
|
||||
};
|
||||
|
||||
const activeTab = ref('overview');
|
||||
|
||||
onMounted(() => reload(range.value[0], range.value[1]));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="aurora-dashboard">
|
||||
<header class="dash__head">
|
||||
<div>
|
||||
<h1 class="dash__title">Dashboard</h1>
|
||||
<p class="dash__subtitle">Overview of your site activity</p>
|
||||
</div>
|
||||
|
||||
<div class="dash__range">
|
||||
<button type="button" class="dash__range-btn" @click="presetsOpen = !presetsOpen">
|
||||
<span class="dash__range-icon">▦</span>
|
||||
{{ fmtLabel(range[0]) }} — {{ fmtLabel(range[1]) }}
|
||||
</button>
|
||||
<div v-if="presetsOpen" class="dash__presets">
|
||||
<button type="button" @click="applyPreset(7)">Last 7 days</button>
|
||||
<button type="button" @click="applyPreset(30)">Last 30 days</button>
|
||||
<button type="button" @click="applyPreset(90)">Last 90 days</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dash__tabs">
|
||||
<button
|
||||
type="button"
|
||||
:class="['dash__tab', { 'dash__tab--active': activeTab === 'overview' }]"
|
||||
@click="activeTab = 'overview'"
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['dash__tab', { 'dash__tab--active': activeTab === 'analytics' }]"
|
||||
@click="activeTab = 'analytics'"
|
||||
>
|
||||
Analytics
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'overview'" class="dash__grid">
|
||||
<div class="dash__cell" style="grid-column: span 4">
|
||||
<RecentContent :data="data ? data.recentContent : {}" />
|
||||
</div>
|
||||
<div class="dash__cell" style="grid-column: span 4">
|
||||
<ScheduledContent :data="data ? data.scheduledContent : {}" />
|
||||
</div>
|
||||
<div class="dash__cell" style="grid-column: span 4">
|
||||
<RecentComments :data="data ? data.recentComments : {}" />
|
||||
</div>
|
||||
<div class="dash__cell" style="grid-column: span 6">
|
||||
<UserAnalytics :data="data ? data.userAnalytics : {}" />
|
||||
</div>
|
||||
<div class="dash__cell" style="grid-column: span 6">
|
||||
<MediaAnalytics :data="data ? data.mediaAnalytics : {}" />
|
||||
</div>
|
||||
<div class="dash__cell" style="grid-column: span 6">
|
||||
<ServerHealth :data="data ? data.serverHealth : {}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="dash__grid">
|
||||
<div class="dash__cell" style="grid-column: span 8">
|
||||
<PageViews :data="analytics ? analytics.pageViews : {}" />
|
||||
</div>
|
||||
<div class="dash__cell" style="grid-column: span 4">
|
||||
<ActiveUsers :count="analytics ? analytics.activeUsers : 0" />
|
||||
</div>
|
||||
<div class="dash__cell" style="grid-column: span 4">
|
||||
<DeviceUsage :items="analytics ? analytics.deviceUsage : []" />
|
||||
</div>
|
||||
<div class="dash__cell" style="grid-column: span 4">
|
||||
<Engagement :bounce="analytics ? analytics.bounce : {}" />
|
||||
</div>
|
||||
<div class="dash__cell" style="grid-column: span 4">
|
||||
<TopList
|
||||
title="Top Countries"
|
||||
subtitle="Where visitors are from"
|
||||
:items="analytics ? analytics.topCountries : []"
|
||||
empty-text="No location data yet."
|
||||
/>
|
||||
</div>
|
||||
<div class="dash__cell" style="grid-column: span 6">
|
||||
<TopList
|
||||
title="Top Pages"
|
||||
subtitle="Most viewed pages"
|
||||
:items="analytics ? analytics.topPages : []"
|
||||
/>
|
||||
</div>
|
||||
<div class="dash__cell" style="grid-column: span 6">
|
||||
<TopList
|
||||
title="Top Referrers"
|
||||
subtitle="Where traffic comes from"
|
||||
:items="analytics ? analytics.topReferrers : []"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.aurora-dashboard {
|
||||
padding: 24px var(--aurora-gutter-r, 44px) 40px 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dash__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.dash__title {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--aurora-text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.dash__subtitle { margin: 4px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
|
||||
.dash__range { position: relative; }
|
||||
.dash__range-btn {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--aurora-card-bg);
|
||||
border: 1px solid var(--aurora-card-border);
|
||||
color: var(--aurora-text);
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dash__range-icon { color: var(--aurora-text-muted); }
|
||||
.dash__presets {
|
||||
position: absolute; right: 0; top: calc(100% + 6px);
|
||||
background: var(--aurora-card-bg);
|
||||
border: 1px solid var(--aurora-card-border);
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
z-index: 10;
|
||||
min-width: 160px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
|
||||
}
|
||||
.dash__presets button {
|
||||
display: block; width: 100%; text-align: left;
|
||||
background: none; border: none; color: var(--aurora-text);
|
||||
padding: 8px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8125rem;
|
||||
}
|
||||
.dash__presets button:hover { background: var(--aurora-bg-subtle); }
|
||||
.dash__tabs {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.dash__tab {
|
||||
border: none; background: none; cursor: pointer;
|
||||
padding: 6px 16px; border-radius: 8px;
|
||||
font-size: 0.8125rem; font-weight: 500;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.dash__tab--active { background: var(--aurora-card-bg); color: var(--aurora-text); }
|
||||
.dash__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.dash__cell { min-width: 0; display: flex; }
|
||||
.dash__cell > * { width: 100%; }
|
||||
.dash__analytics-empty {
|
||||
color: var(--aurora-text-muted);
|
||||
font-size: 0.875rem;
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.dash__cell { grid-column: span 12 !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Shared bits used inside the card slots (head-right badges, footer links).
|
||||
Scoped under .aurora-dashboard so they can't collide with WordPress or
|
||||
leak elsewhere, while still reaching slotted content in child cards. */
|
||||
.aurora-dashboard .dot {
|
||||
display: inline-block;
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.aurora-dashboard .dot--accent { background: var(--aurora-accent); }
|
||||
.aurora-dashboard .dot--green { background: #34d399; }
|
||||
.aurora-dashboard .viewall {
|
||||
font-size: 0.75rem;
|
||||
color: var(--aurora-text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.aurora-dashboard .viewall:hover { color: var(--aurora-accent); }
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import AuroraCard from '../components/AuroraCard.vue';
|
||||
|
||||
defineProps({ count: { type: Number, default: 0 } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuroraCard title="Active Users" subtitle="Visitors in the last 5 minutes">
|
||||
<div class="au">
|
||||
<span class="au__pulse" />
|
||||
<div class="au__num">{{ count }}</div>
|
||||
<div class="au__label">active now</div>
|
||||
</div>
|
||||
</AuroraCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.au {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
text-align: center; padding: 24px 0; height: 100%;
|
||||
}
|
||||
.au__pulse {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: #34d399; margin-bottom: 14px;
|
||||
box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.6);
|
||||
animation: au-pulse 1.8s infinite;
|
||||
}
|
||||
@keyframes au-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(52,211,153,0.5); }
|
||||
70% { box-shadow: 0 0 0 12px rgba(52,211,153,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(52,211,153,0); }
|
||||
}
|
||||
.au__num { font-size: 2.5rem; font-weight: 700; color: var(--aurora-text); line-height: 1; }
|
||||
.au__label { margin-top: 6px; font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import AuroraCard from '../components/AuroraCard.vue';
|
||||
import BarList from '../components/BarList.vue';
|
||||
|
||||
const props = defineProps({ items: { type: Array, default: () => [] } });
|
||||
|
||||
const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
||||
const list = computed(() =>
|
||||
props.items.map((d) => ({ label: `${cap(d.device)} · ${d.pct}%`, count: d.count }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuroraCard title="Device Usage" subtitle="How visitors browse">
|
||||
<BarList :items="list" empty-text="No visits in this range yet." />
|
||||
</AuroraCard>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import AuroraCard from '../components/AuroraCard.vue';
|
||||
import StatRow from '../components/StatRow.vue';
|
||||
|
||||
defineProps({ bounce: { type: Object, default: () => ({}) } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuroraCard title="Engagement" subtitle="Bounce rate & time on site">
|
||||
<StatRow
|
||||
:stats="[
|
||||
{ value: (bounce.rate || 0) + '%', label: 'Bounce Rate' },
|
||||
{ value: bounce.avgTime || '0s', label: 'Avg. Time' },
|
||||
{ value: bounce.sessions || 0, label: 'Sessions' },
|
||||
]"
|
||||
/>
|
||||
</AuroraCard>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
import AuroraCard from '../components/AuroraCard.vue';
|
||||
|
||||
defineProps({ data: { type: Object, default: () => ({}) } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuroraCard title="Media Analytics" subtitle="Media library overview">
|
||||
<div class="ma__top">
|
||||
<div class="ma__big">
|
||||
<div class="ma__big-num">{{ data.totalFiles || 0 }}</div>
|
||||
<div class="ma__big-label">Total Files</div>
|
||||
</div>
|
||||
<div class="ma__big">
|
||||
<div class="ma__big-num">{{ data.totalSize || '0 B' }}</div>
|
||||
<div class="ma__big-label">Total Size</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ma__grid">
|
||||
<div class="ma__box">
|
||||
<div class="ma__box-num">{{ data.recent30d || 0 }}</div>
|
||||
<div class="ma__box-label">Recent (30d)</div>
|
||||
</div>
|
||||
<div class="ma__box">
|
||||
<div class="ma__box-num">{{ data.unused || 0 }}</div>
|
||||
<div class="ma__box-label">Unused</div>
|
||||
</div>
|
||||
<div class="ma__box">
|
||||
<div class="ma__box-num">{{ data.largeFiles || 0 }}</div>
|
||||
<div class="ma__box-label">Large Files</div>
|
||||
</div>
|
||||
<div class="ma__box">
|
||||
<div class="ma__box-num">{{ data.diskUsage || '0 B' }}</div>
|
||||
<div class="ma__box-label">Disk Usage</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<a :href="data.manageUrl" class="viewall">Manage Media ›</a>
|
||||
</template>
|
||||
</AuroraCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ma__top {
|
||||
display: grid; grid-template-columns: 1fr 1fr; text-align: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.ma__big-num { font-size: 1.75rem; font-weight: 700; color: var(--aurora-text); }
|
||||
.ma__big-label { margin-top: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); }
|
||||
.ma__grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.ma__box {
|
||||
background: var(--aurora-bg-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.ma__box-num { font-size: 1.25rem; font-weight: 700; color: var(--aurora-text); }
|
||||
.ma__box-label { margin-top: 2px; font-size: 0.6875rem; color: var(--aurora-text-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import AuroraCard from '../components/AuroraCard.vue';
|
||||
import StatRow from '../components/StatRow.vue';
|
||||
import AreaChart from '../components/AreaChart.vue';
|
||||
|
||||
defineProps({ data: { type: Object, default: () => ({}) } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuroraCard title="Page Views" subtitle="Traffic over the selected range">
|
||||
<StatRow
|
||||
:boxed="false"
|
||||
:stats="[
|
||||
{ value: data.total || 0, label: 'Total Views' },
|
||||
{ value: data.unique || 0, label: 'Unique Visitors' },
|
||||
]"
|
||||
/>
|
||||
<div class="pv__chart">
|
||||
<AreaChart :series="data.series || []" :height="150" />
|
||||
</div>
|
||||
</AuroraCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pv__chart { margin-top: 20px; }
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import AuroraCard from '../components/AuroraCard.vue';
|
||||
import StatRow from '../components/StatRow.vue';
|
||||
|
||||
defineProps({ data: { type: Object, default: () => ({}) } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuroraCard title="Recent Comments" subtitle="Latest comments and discussions">
|
||||
<template #head-right>
|
||||
<span class="dot dot--accent" /> {{ data.total || 0 }} total
|
||||
</template>
|
||||
|
||||
<StatRow
|
||||
:stats="[
|
||||
{ value: data.approved || 0, label: 'Approved' },
|
||||
{ value: data.pending || 0, label: 'Pending' },
|
||||
{ value: data.spam || 0, label: 'Spam' },
|
||||
]"
|
||||
/>
|
||||
|
||||
<ul class="cm__list">
|
||||
<li v-for="c in data.latest" :key="c.id" class="cm__item">
|
||||
<p class="cm__excerpt">{{ c.excerpt }}</p>
|
||||
<div class="cm__meta">on <span class="cm__post">{{ c.postTitle }}</span> · {{ c.date }}</div>
|
||||
</li>
|
||||
<li v-if="!data.latest || !data.latest.length" class="cm__empty">No comments yet.</li>
|
||||
</ul>
|
||||
|
||||
<template #footer>
|
||||
<a :href="data.listUrl" class="viewall">View all ›</a>
|
||||
</template>
|
||||
</AuroraCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cm__list { list-style: none; margin: 16px 0 0; padding: 0; }
|
||||
.cm__item { padding: 8px 0; }
|
||||
.cm__excerpt { margin: 0; font-size: 0.8125rem; color: var(--aurora-text); }
|
||||
.cm__meta { margin-top: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); }
|
||||
.cm__post { color: var(--aurora-accent); }
|
||||
.cm__empty { font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 12px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import AuroraCard from '../components/AuroraCard.vue';
|
||||
import StatRow from '../components/StatRow.vue';
|
||||
|
||||
const props = defineProps({ data: { type: Object, default: () => ({}) } });
|
||||
|
||||
const statusLabel = (s) =>
|
||||
({ publish: 'Published', draft: 'Draft', future: 'Scheduled' }[s] || s);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuroraCard title="Recent content" subtitle="Latest content activity">
|
||||
<template #head-right>
|
||||
<span class="dot dot--accent" /> {{ data.total || 0 }} total
|
||||
</template>
|
||||
|
||||
<StatRow
|
||||
:stats="[
|
||||
{ value: data.published || 0, label: 'Published' },
|
||||
{ value: data.draft || 0, label: 'Draft' },
|
||||
{ value: data.scheduled || 0, label: 'Scheduled' },
|
||||
]"
|
||||
/>
|
||||
|
||||
<ul class="rc__list">
|
||||
<li v-for="p in data.latest" :key="p.id" class="rc__item">
|
||||
<div class="rc__main">
|
||||
<a :href="p.editUrl" class="rc__title">{{ p.title || '(no title)' }}</a>
|
||||
<div class="rc__meta">{{ p.author }} · {{ p.date }}</div>
|
||||
</div>
|
||||
<span class="rc__badge" :class="`rc__badge--${p.status}`">
|
||||
{{ statusLabel(p.status) }}
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="!data.latest || !data.latest.length" class="rc__empty">No content yet.</li>
|
||||
</ul>
|
||||
|
||||
<template #footer>
|
||||
<a :href="data.listUrl" class="viewall">View all ›</a>
|
||||
</template>
|
||||
</AuroraCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rc__list { list-style: none; margin: 16px 0 0; padding: 0; }
|
||||
.rc__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.rc__title {
|
||||
color: var(--aurora-text);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.rc__title:hover { color: var(--aurora-accent); }
|
||||
.rc__meta { font-size: 0.75rem; color: var(--aurora-text-muted); margin-top: 2px; }
|
||||
.rc__badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rc__badge--publish { color: #34d399; }
|
||||
.rc__badge--future { color: var(--aurora-accent); }
|
||||
.rc__badge--draft { color: var(--aurora-text-muted); }
|
||||
.rc__empty { font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 12px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import AuroraCard from '../components/AuroraCard.vue';
|
||||
|
||||
const props = defineProps({ data: { type: Object, default: () => ({}) } });
|
||||
|
||||
const view = ref(new Date());
|
||||
|
||||
const monthLabel = computed(() =>
|
||||
view.value.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||
);
|
||||
|
||||
const weekdays = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
||||
|
||||
// Build a calendar grid (Mon-first) for the viewed month.
|
||||
const grid = computed(() => {
|
||||
const year = view.value.getFullYear();
|
||||
const month = view.value.getMonth();
|
||||
const first = new Date(year, month, 1);
|
||||
const startOffset = (first.getDay() + 6) % 7; // Mon = 0
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const cells = [];
|
||||
for (let i = 0; i < startOffset; i++) cells.push(null);
|
||||
const today = new Date();
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
const scheduled = props.data.days && props.data.days[key] ? props.data.days[key].length : 0;
|
||||
const isToday =
|
||||
d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
|
||||
cells.push({ d, key, scheduled, isToday });
|
||||
}
|
||||
return cells;
|
||||
});
|
||||
|
||||
const move = (delta) => {
|
||||
const next = new Date(view.value);
|
||||
next.setMonth(next.getMonth() + delta);
|
||||
view.value = next;
|
||||
};
|
||||
|
||||
// Clicking a day: with scheduled content, open the posts list filtered to
|
||||
// that day; otherwise show a transient popup on the dashboard.
|
||||
const toast = ref('');
|
||||
let toastTimer;
|
||||
const onDayClick = (cell) => {
|
||||
if (!cell) return;
|
||||
if (cell.scheduled > 0) {
|
||||
// Relative to /wp-admin/ (the dashboard is at index.php).
|
||||
window.location.href = `edit.php?post_status=future&aurora_day=${cell.key}`;
|
||||
} else {
|
||||
toast.value = 'No scheduled content for this day.';
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => (toast.value = ''), 3000);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuroraCard title="Scheduled Content" :subtitle="monthLabel">
|
||||
<template #head-right>
|
||||
<div class="sc__nav">
|
||||
<button type="button" @click="move(-1)" aria-label="Previous month">‹</button>
|
||||
<button type="button" @click="move(1)" aria-label="Next month">›</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="sc__count">
|
||||
<span class="sc__count-num">{{ data.count || 0 }}</span> Scheduled Content
|
||||
</div>
|
||||
|
||||
<div class="sc__weekdays">
|
||||
<span v-for="(w, i) in weekdays" :key="i">{{ w }}</span>
|
||||
</div>
|
||||
<div class="sc__grid">
|
||||
<div v-for="(cell, i) in grid" :key="i" class="sc__cell">
|
||||
<button
|
||||
v-if="cell"
|
||||
type="button"
|
||||
class="sc__day"
|
||||
:class="{ 'sc__day--has': cell.scheduled > 0, 'sc__day--today': cell.isToday }"
|
||||
:title="cell.scheduled > 0 ? `${cell.scheduled} scheduled` : 'No scheduled content'"
|
||||
@click="onDayClick(cell)"
|
||||
>
|
||||
<span class="sc__date">{{ cell.d }}</span>
|
||||
<span v-if="cell.scheduled > 0" class="sc__count">{{ cell.scheduled }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="sc-toast">
|
||||
<div v-if="toast" class="sc__toast">{{ toast }}</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</AuroraCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sc__nav { display: flex; gap: 4px; }
|
||||
.sc__nav button {
|
||||
width: 26px; height: 26px;
|
||||
border: 1px solid var(--aurora-card-border);
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text-muted);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.sc__nav button:hover { color: var(--aurora-text); }
|
||||
.sc__count { font-size: 0.875rem; color: var(--aurora-text-muted); margin-bottom: 16px; }
|
||||
.sc__count-num { font-size: 1.5rem; font-weight: 700; color: var(--aurora-text); margin-right: 6px; }
|
||||
.sc__weekdays {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr);
|
||||
font-size: 0.6875rem; color: var(--aurora-text-muted); margin-bottom: 8px;
|
||||
}
|
||||
.sc__weekdays span { text-align: center; }
|
||||
.sc__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; }
|
||||
.sc__cell { aspect-ratio: 1; }
|
||||
.sc__day {
|
||||
position: relative;
|
||||
width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--aurora-card-border);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.12s ease, background-color 0.12s ease;
|
||||
}
|
||||
.sc__day:hover { border-color: var(--aurora-accent); }
|
||||
.sc__day--has:hover { filter: brightness(1.08); }
|
||||
.sc__date {
|
||||
position: absolute; top: 5px; left: 6px;
|
||||
font-size: 0.7rem; line-height: 1; color: var(--aurora-text-muted);
|
||||
}
|
||||
.sc__count {
|
||||
font-size: 1.05rem; font-weight: 700; color: var(--aurora-text);
|
||||
}
|
||||
.sc__day--has { background: var(--aurora-accent); border-color: var(--aurora-accent); }
|
||||
.sc__day--has .sc__date { color: rgba(255, 255, 255, 0.85); }
|
||||
.sc__day--has .sc__count { color: #fff; }
|
||||
.sc__day--today { border-color: var(--aurora-accent); }
|
||||
.sc__day--today .sc__date { color: var(--aurora-accent); }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Toast is teleported to <body>, so these rules are global (not scoped).
|
||||
They rely on the global --aurora-* theme variables. */
|
||||
.sc__toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100001;
|
||||
background: var(--aurora-card-bg, #1f1f23);
|
||||
color: var(--aurora-text, #f4f4f5);
|
||||
border: 1px solid var(--aurora-border, #2e2e33);
|
||||
border-radius: 10px;
|
||||
padding: 12px 18px;
|
||||
font-size: 0.875rem;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.sc-toast-enter-active,
|
||||
.sc-toast-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||
.sc-toast-enter-from,
|
||||
.sc-toast-leave-to { opacity: 0; transform: translate(-50%, 10px); }
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import AuroraCard from '../components/AuroraCard.vue';
|
||||
import StatRow from '../components/StatRow.vue';
|
||||
import ProgressBar from '../components/ProgressBar.vue';
|
||||
|
||||
defineProps({ data: { type: Object, default: () => ({}) } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuroraCard title="Server Health" subtitle="System performance and updates">
|
||||
<template #head-right>
|
||||
<span class="dot dot--green" /> {{ data.updates || 0 }} updates
|
||||
</template>
|
||||
|
||||
<div class="sh__versions">
|
||||
<div class="sh__ver">
|
||||
<div class="sh__ver-label">‹› WordPress</div>
|
||||
<div class="sh__ver-num">{{ data.wp || '—' }}</div>
|
||||
</div>
|
||||
<div class="sh__ver">
|
||||
<div class="sh__ver-label">⚙ PHP</div>
|
||||
<div class="sh__ver-num">{{ data.php || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sh__bars">
|
||||
<ProgressBar
|
||||
label="Memory Usage"
|
||||
icon="◍"
|
||||
:pct="data.memory ? data.memory.pct : 0"
|
||||
:detail="data.memory ? `${data.memory.used} / ${data.memory.total}` : ''"
|
||||
/>
|
||||
<ProgressBar
|
||||
label="Disk Space"
|
||||
icon="▤"
|
||||
gradient
|
||||
:pct="data.disk ? data.disk.pct : 0"
|
||||
:detail="data.disk ? `${data.disk.used} / ${data.disk.total}` : ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StatRow
|
||||
:stats="[
|
||||
{ value: data.plugins || 0, label: 'Plugins' },
|
||||
{ value: data.themes || 0, label: 'Themes' },
|
||||
{ value: data.core || 0, label: 'Core' },
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="sh__foot">
|
||||
<span>{{ data.ssl ? '🔒 SSL enabled' : '⚠ SSL disabled' }}</span>
|
||||
<span>🕓 Timezone: {{ data.timezone || 'UTC' }}</span>
|
||||
</div>
|
||||
</AuroraCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sh__versions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
|
||||
.sh__ver {
|
||||
background: var(--aurora-bg-subtle);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.sh__ver-label { font-size: 0.6875rem; color: var(--aurora-text-muted); }
|
||||
.sh__ver-num { font-size: 1rem; font-weight: 600; color: var(--aurora-text); margin-top: 2px; }
|
||||
.sh__bars { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; }
|
||||
.sh__foot {
|
||||
display: flex; justify-content: space-between;
|
||||
margin-top: 16px; font-size: 0.6875rem; color: var(--aurora-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
import AuroraCard from '../components/AuroraCard.vue';
|
||||
import BarList from '../components/BarList.vue';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
subtitle: { type: String, default: '' },
|
||||
items: { type: Array, default: () => [] },
|
||||
emptyText: { type: String, default: 'No data in this range yet.' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuroraCard :title="title" :subtitle="subtitle">
|
||||
<BarList :items="items" :empty-text="emptyText" />
|
||||
</AuroraCard>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import AuroraCard from '../components/AuroraCard.vue';
|
||||
import StatRow from '../components/StatRow.vue';
|
||||
import AreaChart from '../components/AreaChart.vue';
|
||||
|
||||
defineProps({ data: { type: Object, default: () => ({}) } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuroraCard title="User Analytics" subtitle="User registration trends">
|
||||
<StatRow
|
||||
:boxed="false"
|
||||
:stats="[
|
||||
{ value: data.total || 0, label: 'Total Users' },
|
||||
{ value: data.inRange || 0, label: 'In Range' },
|
||||
{ value: data.recent7d || 0, label: 'Recent (7d)' },
|
||||
]"
|
||||
/>
|
||||
<div class="ua__chart">
|
||||
<AreaChart :series="data.series || []" :height="150" />
|
||||
</div>
|
||||
</AuroraCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ua__chart { margin-top: 20px; }
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
// series: [{ date, count }]
|
||||
const props = defineProps({
|
||||
series: { type: Array, default: () => [] },
|
||||
height: { type: Number, default: 140 },
|
||||
});
|
||||
|
||||
const W = 600;
|
||||
const PAD = 10;
|
||||
|
||||
const view = computed(() => {
|
||||
const s = props.series;
|
||||
if (!s.length) return null;
|
||||
const max = Math.max(1, ...s.map((d) => d.count));
|
||||
const H = props.height;
|
||||
const innerW = W - PAD * 2;
|
||||
const innerH = H - PAD * 2;
|
||||
const stepX = s.length > 1 ? innerW / (s.length - 1) : 0;
|
||||
const points = s.map((d, i) => {
|
||||
const x = PAD + i * stepX;
|
||||
const y = PAD + innerH - (d.count / max) * innerH;
|
||||
return [x, y];
|
||||
});
|
||||
const line = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
|
||||
const area = `${line} L${points[points.length - 1][0].toFixed(1)},${(H - PAD).toFixed(1)} L${points[0][0].toFixed(1)},${(H - PAD).toFixed(1)} Z`;
|
||||
return { points, line, area, H };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="areachart" v-if="view">
|
||||
<svg :viewBox="`0 0 ${W} ${view.H}`" preserveAspectRatio="none" class="areachart__svg">
|
||||
<defs>
|
||||
<linearGradient id="aurora-area-grad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="var(--aurora-accent)" stop-opacity="0.35" />
|
||||
<stop offset="100%" stop-color="var(--aurora-accent)" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path :d="view.area" fill="url(#aurora-area-grad)" />
|
||||
<path :d="view.line" fill="none" stroke="var(--aurora-accent)" stroke-width="2"
|
||||
vector-effect="non-scaling-stroke" />
|
||||
<circle v-for="(p, i) in view.points" :key="i" :cx="p[0]" :cy="p[1]" r="3"
|
||||
fill="var(--aurora-accent)" vector-effect="non-scaling-stroke" />
|
||||
</svg>
|
||||
<div class="areachart__labels">
|
||||
<span v-for="(d, i) in series" :key="i">{{ d.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.areachart__svg {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
.areachart__labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.areachart__labels span {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.areachart__labels span:first-child { text-align: left; }
|
||||
.areachart__labels span:last-child { text-align: right; }
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
subtitle: { type: String, default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="acard">
|
||||
<header class="acard__head">
|
||||
<div class="acard__titles">
|
||||
<h2 class="acard__title">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="acard__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="acard__head-right">
|
||||
<slot name="head-right" />
|
||||
</div>
|
||||
</header>
|
||||
<div class="acard__body">
|
||||
<slot />
|
||||
</div>
|
||||
<footer v-if="$slots.footer" class="acard__foot">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.acard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--aurora-card-bg);
|
||||
border: 1px solid var(--aurora-card-border);
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.acard__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.acard__title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.acard__subtitle {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.acard__head-right {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.acard__body {
|
||||
flex: 1;
|
||||
}
|
||||
.acard__foot {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
// items: [{ label, count }]
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
emptyText: { type: String, default: 'No data yet.' },
|
||||
});
|
||||
|
||||
const max = computed(() => Math.max(1, ...props.items.map((i) => i.count)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="barlist">
|
||||
<div v-for="(it, i) in items" :key="i" class="barlist__row">
|
||||
<div class="barlist__head">
|
||||
<span class="barlist__label" :title="it.label">{{ it.label }}</span>
|
||||
<span class="barlist__count">{{ it.count }}</span>
|
||||
</div>
|
||||
<div class="barlist__track">
|
||||
<div class="barlist__fill" :style="{ width: (it.count / max) * 100 + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!items.length" class="barlist__empty">{{ emptyText }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.barlist__row { margin-bottom: 12px; }
|
||||
.barlist__head {
|
||||
display: flex; justify-content: space-between; gap: 12px; margin-bottom: 5px;
|
||||
}
|
||||
.barlist__label {
|
||||
font-size: 0.8125rem; color: var(--aurora-text);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.barlist__count { font-size: 0.8125rem; color: var(--aurora-text-muted); flex-shrink: 0; }
|
||||
.barlist__track {
|
||||
height: 6px; border-radius: 999px;
|
||||
background: rgba(127,127,127,0.18); overflow: hidden;
|
||||
}
|
||||
.barlist__fill { height: 100%; border-radius: 999px; background: var(--aurora-accent); }
|
||||
.barlist__empty { font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 8px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: { type: String, required: true },
|
||||
pct: { type: Number, default: 0 },
|
||||
detail: { type: String, default: '' },
|
||||
gradient: { type: Boolean, default: false },
|
||||
icon: { type: String, default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pbar">
|
||||
<div class="pbar__top">
|
||||
<span class="pbar__label">
|
||||
<span v-if="icon" class="pbar__icon">{{ icon }}</span>{{ label }}
|
||||
</span>
|
||||
<span class="pbar__pct">{{ pct }}%</span>
|
||||
</div>
|
||||
<div class="pbar__track">
|
||||
<div
|
||||
class="pbar__fill"
|
||||
:class="{ 'pbar__fill--gradient': gradient }"
|
||||
:style="{ width: Math.min(100, pct) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="detail" class="pbar__detail">{{ detail }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pbar {
|
||||
background: var(--aurora-bg-subtle);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.pbar__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pbar__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.pbar__icon { color: var(--aurora-text-muted); }
|
||||
.pbar__pct {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #34d399;
|
||||
}
|
||||
.pbar__track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(127, 127, 127, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
.pbar__fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--aurora-accent);
|
||||
}
|
||||
.pbar__fill--gradient {
|
||||
background: linear-gradient(90deg, #4f7bf5 0%, #a855f7 55%, #ef4444 100%);
|
||||
}
|
||||
.pbar__detail {
|
||||
margin-top: 6px;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
// stats: [{ value, label }]
|
||||
defineProps({
|
||||
stats: { type: Array, required: true },
|
||||
boxed: { type: Boolean, default: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="statrow" :class="{ 'statrow--boxed': boxed }">
|
||||
<div v-for="(s, i) in stats" :key="i" class="statrow__item">
|
||||
<div class="statrow__value">{{ s.value }}</div>
|
||||
<div class="statrow__label">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.statrow {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
.statrow--boxed {
|
||||
background: var(--aurora-bg-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 16px 8px;
|
||||
}
|
||||
.statrow__value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--aurora-text);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.statrow__label {
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
/** Fetches aggregated analytics from aurora-admin/v1/analytics for a range. */
|
||||
export function useAnalyticsData(serverData) {
|
||||
const data = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(false);
|
||||
|
||||
const fmt = (d) => {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
|
||||
const load = async (from, to) => {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
const url = new URL(`${serverData.restUrl}aurora-admin/v1/analytics`);
|
||||
url.searchParams.set('from', fmt(from));
|
||||
url.searchParams.set('to', fmt(to));
|
||||
const res = await fetch(url, { headers: { 'X-WP-Nonce': serverData.restNonce } });
|
||||
if (!res.ok) throw new Error('Request failed');
|
||||
data.value = await res.json();
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return { data, loading, error, load };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
/**
|
||||
* Fetches all dashboard card data from aurora-admin/v1/dashboard for a
|
||||
* given date range. Re-callable when the range changes.
|
||||
*/
|
||||
export function useDashboardData(serverData) {
|
||||
const data = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(false);
|
||||
|
||||
const fmt = (d) => {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
|
||||
const load = async (from, to) => {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
const url = new URL(`${serverData.restUrl}aurora-admin/v1/dashboard`);
|
||||
url.searchParams.set('from', fmt(from));
|
||||
url.searchParams.set('to', fmt(to));
|
||||
const res = await fetch(url, {
|
||||
headers: { 'X-WP-Nonce': serverData.restNonce },
|
||||
});
|
||||
if (!res.ok) throw new Error('Request failed');
|
||||
data.value = await res.json();
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return { data, loading, error, load };
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import AppListShell from '../_shared/AppListShell.vue';
|
||||
import AppListItem from '../_shared/AppListItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const restBase = `${props.serverData.restUrl}aurora-admin/v1`;
|
||||
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce };
|
||||
const api = (path, opts = {}) =>
|
||||
fetch(`${restBase}${path}`, { ...opts, headers }).then(async (r) => {
|
||||
const body = await r.json();
|
||||
if (!r.ok) throw new Error(body?.message || 'Request failed');
|
||||
return body;
|
||||
});
|
||||
|
||||
const tables = ref([]);
|
||||
const search = ref('');
|
||||
const selectedTable = ref(null);
|
||||
const tableData = ref(null);
|
||||
const tablePage = ref(1);
|
||||
const loadingTable = ref(false);
|
||||
|
||||
const sqlMode = ref(false);
|
||||
const sqlQuery = ref('SELECT * FROM ' + (props.serverData.tablePrefix || 'wp_') + 'options LIMIT 10');
|
||||
const sqlResult = ref(null);
|
||||
const sqlError = ref('');
|
||||
const sqlRunning = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
tables.value = await api('/db-tables');
|
||||
});
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (!q) return tables.value;
|
||||
return tables.value.filter((t) => t.name.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
const loadTable = async (name, page = 1) => {
|
||||
sqlMode.value = false;
|
||||
selectedTable.value = name;
|
||||
tablePage.value = page;
|
||||
loadingTable.value = true;
|
||||
try {
|
||||
tableData.value = await api(`/db-tables/${encodeURIComponent(name)}?page=${page}`);
|
||||
} finally {
|
||||
loadingTable.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(tablePage, (p) => {
|
||||
if (selectedTable.value) loadTable(selectedTable.value, p);
|
||||
});
|
||||
|
||||
const openSqlEditor = () => {
|
||||
sqlMode.value = true;
|
||||
selectedTable.value = null;
|
||||
};
|
||||
|
||||
const runQuery = async () => {
|
||||
sqlRunning.value = true;
|
||||
sqlError.value = '';
|
||||
sqlResult.value = null;
|
||||
try {
|
||||
sqlResult.value = await api('/db-query', { method: 'POST', body: JSON.stringify({ query: sqlQuery.value }) });
|
||||
} catch (e) {
|
||||
sqlError.value = e.message;
|
||||
} finally {
|
||||
sqlRunning.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const hasSelection = computed(() => !!selectedTable.value || sqlMode.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppListShell
|
||||
v-model:search="search"
|
||||
title="Database Explorer"
|
||||
search-placeholder="Search tables…"
|
||||
:count-label="`${filtered.length} TABLES`"
|
||||
detail-icon="dashicons-database"
|
||||
detail-title="Table Details"
|
||||
detail-text="Select a table from the list to view its structure and data."
|
||||
:has-selection="hasSelection"
|
||||
>
|
||||
<template #header-actions>
|
||||
<button type="button" @click="openSqlEditor">
|
||||
<span class="dashicons dashicons-editor-code" /> SQL
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #detail-empty-actions>
|
||||
<button type="button" class="is-primary" style="margin-top: 14px;" @click="openSqlEditor">
|
||||
Open SQL Editor
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #list>
|
||||
<AppListItem
|
||||
v-for="t in filtered"
|
||||
:key="t.name"
|
||||
icon="dashicons-database"
|
||||
:title="t.name"
|
||||
:subtitle="`${t.rows} rows · ${formatSize(t.sizeBytes)}`"
|
||||
:active="t.name === selectedTable"
|
||||
@click="loadTable(t.name)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #detail>
|
||||
<div v-if="sqlMode" class="db-sql">
|
||||
<h2>SQL Editor</h2>
|
||||
<p class="db-sql__hint">Only SELECT / SHOW / DESCRIBE / EXPLAIN queries are allowed.</p>
|
||||
<textarea v-model="sqlQuery" rows="4" spellcheck="false" />
|
||||
<div class="db-sql__actions">
|
||||
<button type="button" class="is-primary" :disabled="sqlRunning" @click="runQuery">
|
||||
{{ sqlRunning ? 'Running…' : 'Run query' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="sqlError" class="db-sql__error">{{ sqlError }}</p>
|
||||
<div v-if="sqlResult" class="db-table-wrap">
|
||||
<p class="db-table__count">{{ sqlResult.count }} rows</p>
|
||||
<table v-if="sqlResult.rows.length" class="db-table">
|
||||
<thead>
|
||||
<tr><th v-for="k in Object.keys(sqlResult.rows[0])" :key="k">{{ k }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, i) in sqlResult.rows" :key="i">
|
||||
<td v-for="(v, k) in row" :key="k">{{ v }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedTable" class="db-table-detail">
|
||||
<h2>{{ selectedTable }}</h2>
|
||||
<div v-if="loadingTable">Loading…</div>
|
||||
<template v-else-if="tableData">
|
||||
<div class="db-table-wrap">
|
||||
<table class="db-table">
|
||||
<thead>
|
||||
<tr><th v-for="c in tableData.columns" :key="c.name">{{ c.name }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, i) in tableData.rows" :key="i">
|
||||
<td v-for="c in tableData.columns" :key="c.name">{{ row[c.name] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="db-table__pager">
|
||||
<span>Page {{ tableData.page }} of {{ tableData.totalPages || 1 }} · {{ tableData.total }} rows</span>
|
||||
<button type="button" :disabled="tablePage <= 1" @click="tablePage--">‹</button>
|
||||
<button type="button" :disabled="tablePage >= tableData.totalPages" @click="tablePage++">›</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</AppListShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.db-sql h2, .db-table-detail h2 { margin: 0 0 10px; font-size: 1.1rem; color: var(--aurora-text); }
|
||||
.db-sql__hint { font-size: 0.75rem; color: var(--aurora-text-muted); margin: 0 0 10px; }
|
||||
.db-sql textarea {
|
||||
width: 100%; box-sizing: border-box; font-family: monospace; font-size: 0.8125rem;
|
||||
padding: 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text);
|
||||
}
|
||||
.db-sql__actions { display: flex; justify-content: flex-end; margin: 10px 0; }
|
||||
.db-sql__actions button, .db-table__pager button {
|
||||
border: none; border-radius: 8px; padding: 8px 16px; font-size: 0.8125rem; cursor: pointer;
|
||||
background: var(--aurora-accent); color: #fff;
|
||||
}
|
||||
.db-sql__actions button:disabled { opacity: 0.5; cursor: default; }
|
||||
.db-sql__error { color: #ef4444; font-size: 0.8125rem; }
|
||||
|
||||
.db-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; border: 1px solid var(--aurora-frame-border); border-radius: 8px; }
|
||||
.db-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; }
|
||||
.db-table th, .db-table td {
|
||||
padding: 6px 10px; text-align: left; white-space: nowrap;
|
||||
border-bottom: 1px solid var(--aurora-frame-border); color: var(--aurora-text);
|
||||
}
|
||||
.db-table th { background: var(--aurora-bg-subtle); position: sticky; top: 0; }
|
||||
.db-table__count { font-size: 0.75rem; color: var(--aurora-text-muted); margin: 8px 0; }
|
||||
.db-table__pager {
|
||||
display: flex; align-items: center; gap: 8px; margin-top: 12px;
|
||||
font-size: 0.75rem; color: var(--aurora-text-muted);
|
||||
}
|
||||
.db-table__pager button { background: var(--aurora-bg-subtle); color: var(--aurora-text); padding: 4px 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import AppListShell from '../_shared/AppListShell.vue';
|
||||
import AppListItem from '../_shared/AppListItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const api = (path, opts = {}) =>
|
||||
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
|
||||
...opts,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
...(opts.headers || {}),
|
||||
},
|
||||
}).then((r) => {
|
||||
if (!r.ok) throw new Error('Request failed');
|
||||
return r.json();
|
||||
});
|
||||
|
||||
const menus = ref([]);
|
||||
const nativeItems = ref([]);
|
||||
const loading = ref(true);
|
||||
const search = ref('');
|
||||
const statusFilter = ref('all');
|
||||
const selectedId = ref(null);
|
||||
const roleOptions = ref([]);
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
menus.value = await api('/menus');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
load();
|
||||
api('/menus/native-items').then((items) => (nativeItems.value = items));
|
||||
roleOptions.value = props.serverData.roles || [];
|
||||
});
|
||||
|
||||
const filteredMenus = computed(() => {
|
||||
let list = menus.value;
|
||||
if (statusFilter.value !== 'all') {
|
||||
list = list.filter((m) => m.status === statusFilter.value);
|
||||
}
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (q) list = list.filter((m) => m.name.toLowerCase().includes(q));
|
||||
return list;
|
||||
});
|
||||
|
||||
const selected = computed(() => menus.value.find((m) => m.id === selectedId.value) || null);
|
||||
|
||||
const draft = reactive({ name: '', status: 'draft', roles: [], items: [] });
|
||||
|
||||
const selectMenu = (menu) => {
|
||||
selectedId.value = menu.id;
|
||||
Object.assign(draft, {
|
||||
name: menu.name,
|
||||
status: menu.status,
|
||||
roles: [...menu.roles],
|
||||
items: menu.items.map((i) => ({ ...i })),
|
||||
});
|
||||
};
|
||||
|
||||
const isCreating = ref(false);
|
||||
const newMenu = () => {
|
||||
selectedId.value = null;
|
||||
isCreating.value = true;
|
||||
Object.assign(draft, { name: 'New menu', status: 'draft', roles: [], items: [] });
|
||||
};
|
||||
|
||||
const itemState = (nativeId) => draft.items.find((i) => i.native_id === nativeId);
|
||||
const toggleHidden = (nativeId) => {
|
||||
let item = itemState(nativeId);
|
||||
if (!item) {
|
||||
item = { native_id: nativeId, label: '', hidden: false, order: 0 };
|
||||
draft.items.push(item);
|
||||
}
|
||||
item.hidden = !item.hidden;
|
||||
};
|
||||
const setLabel = (nativeId, label) => {
|
||||
let item = itemState(nativeId);
|
||||
if (!item) {
|
||||
item = { native_id: nativeId, label: '', hidden: false, order: 0 };
|
||||
draft.items.push(item);
|
||||
}
|
||||
item.label = label;
|
||||
};
|
||||
|
||||
const saving = ref(false);
|
||||
const save = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = { name: draft.name, status: draft.status, roles: draft.roles, items: draft.items };
|
||||
if (isCreating.value) {
|
||||
const created = await api('/menus', { method: 'POST', body: JSON.stringify(payload) });
|
||||
await load();
|
||||
selectMenu(created);
|
||||
isCreating.value = false;
|
||||
} else if (selectedId.value) {
|
||||
await api(`/menus/${selectedId.value}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
await load();
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!selectedId.value) return;
|
||||
await api(`/menus/${selectedId.value}`, { method: 'DELETE' });
|
||||
selectedId.value = null;
|
||||
await load();
|
||||
};
|
||||
|
||||
const toggleRole = (slug) => {
|
||||
const idx = draft.roles.indexOf(slug);
|
||||
if (idx === -1) draft.roles.push(slug);
|
||||
else draft.roles.splice(idx, 1);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppListShell
|
||||
v-model:search="search"
|
||||
title="Menu Creator"
|
||||
search-placeholder="Search menus…"
|
||||
:count-label="`${filteredMenus.length} MENUS`"
|
||||
detail-icon="dashicons-menu-alt"
|
||||
detail-title="Menu Editor"
|
||||
detail-text="Select a menu from the list to edit its structure and settings."
|
||||
:has-selection="!!selected || isCreating"
|
||||
>
|
||||
<template #header-actions>
|
||||
<button type="button" @click="newMenu">+</button>
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<button
|
||||
v-for="f in ['all', 'published', 'draft']"
|
||||
:key="f"
|
||||
type="button"
|
||||
:class="{ 'is-active': statusFilter === f }"
|
||||
@click="statusFilter = f"
|
||||
>
|
||||
{{ f === 'all' ? 'All' : f[0].toUpperCase() + f.slice(1) }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #list>
|
||||
<p v-if="!loading && !filteredMenus.length" style="font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 20px 4px; text-align: center;">
|
||||
No menus yet
|
||||
</p>
|
||||
<AppListItem
|
||||
v-for="m in filteredMenus"
|
||||
:key="m.id"
|
||||
icon="dashicons-menu-alt"
|
||||
:title="m.name"
|
||||
:subtitle="m.status"
|
||||
:active="m.id === selectedId"
|
||||
@click="selectMenu(m)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #detail>
|
||||
<div class="menu-editor">
|
||||
<div class="menu-editor__row">
|
||||
<input v-model="draft.name" type="text" class="menu-editor__name" placeholder="Menu name" />
|
||||
<select v-model="draft.status" class="menu-editor__status">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="menu-editor__section">
|
||||
<h3>Target roles</h3>
|
||||
<div class="menu-editor__roles">
|
||||
<label v-for="r in roleOptions" :key="r.value" class="menu-editor__role">
|
||||
<input type="checkbox" :checked="draft.roles.includes(r.value)" @change="toggleRole(r.value)" />
|
||||
{{ r.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-editor__section">
|
||||
<h3>Menu items</h3>
|
||||
<div class="menu-editor__items">
|
||||
<div v-for="ni in nativeItems" :key="ni.native_id" class="menu-editor__item">
|
||||
<span class="dashicons" :class="ni.icon?.value || 'dashicons-admin-generic'" />
|
||||
<input
|
||||
type="text"
|
||||
:value="itemState(ni.native_id)?.label || ni.title"
|
||||
@input="setLabel(ni.native_id, $event.target.value)"
|
||||
/>
|
||||
<label class="menu-editor__hide">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="!!itemState(ni.native_id)?.hidden"
|
||||
@change="toggleHidden(ni.native_id)"
|
||||
/>
|
||||
Hide
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-editor__actions">
|
||||
<button type="button" class="is-danger" :disabled="!selectedId" @click="remove">Delete</button>
|
||||
<button type="button" class="is-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AppListShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.menu-editor { display: flex; flex-direction: column; gap: 22px; }
|
||||
.menu-editor__row { display: flex; gap: 10px; }
|
||||
.menu-editor__name {
|
||||
flex: 1; font-size: 1.1rem; font-weight: 700; padding: 8px 10px;
|
||||
border: 1px solid var(--aurora-border); border-radius: 8px;
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text);
|
||||
}
|
||||
.menu-editor__status {
|
||||
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text);
|
||||
}
|
||||
.menu-editor__section h3 {
|
||||
font-size: 0.8125rem; text-transform: uppercase; letter-spacing: 0.03em;
|
||||
color: var(--aurora-text-muted); margin: 0 0 10px;
|
||||
}
|
||||
.menu-editor__roles { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.menu-editor__role { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; color: var(--aurora-text); }
|
||||
.menu-editor__items { display: flex; flex-direction: column; gap: 6px; max-height: 360px; overflow-y: auto; }
|
||||
.menu-editor__item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 6px 4px;
|
||||
border-bottom: 1px solid var(--aurora-frame-border);
|
||||
}
|
||||
.menu-editor__item input[type='text'] {
|
||||
flex: 1; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.8125rem;
|
||||
}
|
||||
.menu-editor__hide { display: flex; align-items: center; gap: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); }
|
||||
.menu-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.menu-editor__actions button {
|
||||
border: none; border-radius: 8px; padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text);
|
||||
}
|
||||
.menu-editor__actions .is-primary { background: var(--aurora-accent); color: #fff; }
|
||||
.menu-editor__actions .is-danger { color: #ef4444; }
|
||||
.menu-editor__actions button:disabled { opacity: 0.5; cursor: default; }
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import AppListShell from '../_shared/AppListShell.vue';
|
||||
import AppListItem from '../_shared/AppListItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const api = (path, opts = {}) =>
|
||||
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
|
||||
...opts,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
...(opts.headers || {}),
|
||||
},
|
||||
}).then((r) => {
|
||||
if (!r.ok) throw new Error('Request failed');
|
||||
return r.json();
|
||||
});
|
||||
|
||||
const roles = ref([]);
|
||||
const allCapabilities = ref([]);
|
||||
const search = ref('');
|
||||
const selectedSlug = ref(null);
|
||||
const isCreating = ref(false);
|
||||
const errorMsg = ref('');
|
||||
|
||||
const load = async () => {
|
||||
const data = await api('/roles');
|
||||
roles.value = data.roles;
|
||||
allCapabilities.value = data.allCapabilities;
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (!q) return roles.value;
|
||||
return roles.value.filter((r) => r.name.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const selected = computed(() => roles.value.find((r) => r.slug === selectedSlug.value) || null);
|
||||
|
||||
const draft = reactive({ slug: '', name: '', capabilities: [] });
|
||||
|
||||
const selectRole = (r) => {
|
||||
selectedSlug.value = r.slug;
|
||||
isCreating.value = false;
|
||||
errorMsg.value = '';
|
||||
Object.assign(draft, { slug: r.slug, name: r.name, capabilities: [...r.capabilities] });
|
||||
};
|
||||
|
||||
const newRole = () => {
|
||||
selectedSlug.value = null;
|
||||
isCreating.value = true;
|
||||
errorMsg.value = '';
|
||||
Object.assign(draft, { slug: '', name: '', capabilities: ['read'] });
|
||||
};
|
||||
|
||||
const toggleCap = (cap) => {
|
||||
const idx = draft.capabilities.indexOf(cap);
|
||||
if (idx === -1) draft.capabilities.push(cap);
|
||||
else draft.capabilities.splice(idx, 1);
|
||||
};
|
||||
|
||||
const saving = ref(false);
|
||||
const save = async () => {
|
||||
saving.value = true;
|
||||
errorMsg.value = '';
|
||||
try {
|
||||
if (isCreating.value) {
|
||||
await api('/roles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ slug: draft.slug, displayName: draft.name, capabilities: draft.capabilities }),
|
||||
});
|
||||
await load();
|
||||
isCreating.value = false;
|
||||
selectRole(roles.value.find((r) => r.slug === draft.slug));
|
||||
} else if (selectedSlug.value) {
|
||||
await api(`/roles/${selectedSlug.value}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ displayName: draft.name, capabilities: draft.capabilities }),
|
||||
});
|
||||
await load();
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg.value = 'Could not save — check the role slug is unique.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!selectedSlug.value || selected.value?.isDefault) return;
|
||||
await api(`/roles/${selectedSlug.value}`, { method: 'DELETE' });
|
||||
selectedSlug.value = null;
|
||||
await load();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppListShell
|
||||
v-model:search="search"
|
||||
title="Role Editor"
|
||||
search-placeholder="Search roles…"
|
||||
:count-label="`${filtered.length} ROLES`"
|
||||
detail-icon="dashicons-groups"
|
||||
detail-title="Role Details"
|
||||
detail-text="Select a role from the list to view and edit its capabilities."
|
||||
:has-selection="!!selected || isCreating"
|
||||
>
|
||||
<template #header-actions>
|
||||
<button type="button" class="is-primary" @click="newRole">+ New Role</button>
|
||||
</template>
|
||||
|
||||
<template #list>
|
||||
<AppListItem
|
||||
v-for="r in filtered"
|
||||
:key="r.slug"
|
||||
icon="dashicons-groups"
|
||||
:title="r.name"
|
||||
:subtitle="`${r.userCount} user${r.userCount === 1 ? '' : 's'}`"
|
||||
:active="r.slug === selectedSlug"
|
||||
@click="selectRole(r)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #detail>
|
||||
<div class="role-editor">
|
||||
<div class="role-editor__row">
|
||||
<input
|
||||
v-if="isCreating"
|
||||
v-model="draft.slug"
|
||||
type="text"
|
||||
placeholder="role_slug"
|
||||
class="role-editor__slug"
|
||||
/>
|
||||
<input v-model="draft.name" type="text" placeholder="Display name" class="role-editor__name" />
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="role-editor__error">{{ errorMsg }}</p>
|
||||
|
||||
<div class="role-editor__section">
|
||||
<h3>Capabilities</h3>
|
||||
<div class="role-editor__caps">
|
||||
<label v-for="cap in allCapabilities" :key="cap" class="role-editor__cap">
|
||||
<input type="checkbox" :checked="draft.capabilities.includes(cap)" @change="toggleCap(cap)" />
|
||||
{{ cap }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="role-editor__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="is-danger"
|
||||
:disabled="!selectedSlug || selected?.isDefault"
|
||||
:title="selected?.isDefault ? 'Default WordPress roles cannot be deleted' : ''"
|
||||
@click="remove"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button type="button" class="is-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AppListShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-editor { display: flex; flex-direction: column; gap: 18px; }
|
||||
.role-editor__row { display: flex; gap: 10px; }
|
||||
.role-editor__slug, .role-editor__name {
|
||||
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.875rem;
|
||||
}
|
||||
.role-editor__name { flex: 1; font-weight: 700; }
|
||||
.role-editor__slug { width: 160px; }
|
||||
.role-editor__error { color: #ef4444; font-size: 0.8125rem; margin: 0; }
|
||||
.role-editor__section h3 {
|
||||
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em;
|
||||
color: var(--aurora-text-muted); margin: 0 0 8px;
|
||||
}
|
||||
.role-editor__caps {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 8px 16px; max-height: 380px; overflow-y: auto;
|
||||
}
|
||||
.role-editor__cap { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; color: var(--aurora-text); }
|
||||
.role-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.role-editor__actions button {
|
||||
border: none; border-radius: 8px; padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text);
|
||||
}
|
||||
.role-editor__actions .is-primary { background: var(--aurora-accent); color: #fff; }
|
||||
.role-editor__actions .is-danger { color: #ef4444; }
|
||||
.role-editor__actions button:disabled { opacity: 0.5; cursor: default; }
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { settingsCategories } from './settingsConfig.js';
|
||||
import SettingsField from './components/SettingsField.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
// Working copy of the settings, seeded from the server + field defaults.
|
||||
const settings = reactive({ ...(props.serverData.settings || {}) });
|
||||
settingsCategories.forEach((cat) =>
|
||||
cat.fields.forEach((f) => {
|
||||
if (settings[f.key] === undefined && f.default !== undefined) {
|
||||
settings[f.key] = f.default;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Active category, synced with the URL ?category= param.
|
||||
const initialCat = new URLSearchParams(window.location.search).get('category');
|
||||
const activeId = ref(
|
||||
settingsCategories.some((c) => c.id === initialCat) ? initialCat : settingsCategories[0].id
|
||||
);
|
||||
const activeCategory = computed(
|
||||
() => settingsCategories.find((c) => c.id === activeId.value) || settingsCategories[0]
|
||||
);
|
||||
|
||||
const selectCategory = (id) => {
|
||||
activeId.value = id;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('category', id);
|
||||
window.history.replaceState({}, '', url);
|
||||
};
|
||||
|
||||
// Search filter over the nav.
|
||||
const query = ref('');
|
||||
const filteredCategories = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q) return settingsCategories;
|
||||
return settingsCategories.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(q) || c.subtitle.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
// Options for multiselect fields, resolved from server-provided lists.
|
||||
const optionsFor = (field) => {
|
||||
if (field.optionsSource === 'roles') return props.serverData.roles || [];
|
||||
if (field.optionsSource === 'postTypes') return props.serverData.postTypes || [];
|
||||
return field.options || [];
|
||||
};
|
||||
|
||||
// Live dark-mode preview.
|
||||
watch(
|
||||
() => settings.dark_mode,
|
||||
(v) => document.documentElement.classList.toggle('aurora-dark', !!v)
|
||||
);
|
||||
|
||||
// Save -------------------------------------------------------------------
|
||||
const saving = ref(false);
|
||||
const savedMsg = ref('');
|
||||
const save = async () => {
|
||||
saving.value = true;
|
||||
savedMsg.value = '';
|
||||
try {
|
||||
const res = await fetch(`${props.serverData.restUrl}wp/v2/settings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce },
|
||||
body: JSON.stringify({ aurora_admin_settings: { ...settings } }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
savedMsg.value = 'Saved';
|
||||
setTimeout(() => (savedMsg.value = ''), 2500);
|
||||
} catch {
|
||||
savedMsg.value = 'Could not save';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Import / export --------------------------------------------------------
|
||||
const exportSettings = () => {
|
||||
const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'aurora-admin-settings.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
};
|
||||
const importInput = ref(null);
|
||||
const importSettings = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const obj = JSON.parse(reader.result);
|
||||
Object.assign(settings, obj);
|
||||
} catch {
|
||||
savedMsg.value = 'Invalid file';
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings">
|
||||
<!-- Left nav panel -->
|
||||
<aside class="settings__nav">
|
||||
<div class="settings__nav-head">
|
||||
<h1 class="settings__title">Settings</h1>
|
||||
<div class="settings__actions">
|
||||
<button type="button" title="Export settings" @click="exportSettings">
|
||||
<span class="dashicons dashicons-download" />
|
||||
</button>
|
||||
<button type="button" title="Import settings" @click="importInput.click()">
|
||||
<span class="dashicons dashicons-upload" />
|
||||
</button>
|
||||
<input
|
||||
ref="importInput"
|
||||
type="file"
|
||||
accept="application/json"
|
||||
class="settings__file"
|
||||
@change="importSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings__search">
|
||||
<span class="dashicons dashicons-search" />
|
||||
<input v-model="query" type="text" placeholder="Search settings…" />
|
||||
</div>
|
||||
|
||||
<ul class="settings__list">
|
||||
<li v-for="cat in filteredCategories" :key="cat.id">
|
||||
<button
|
||||
type="button"
|
||||
class="settings__cat"
|
||||
:class="{ 'settings__cat--active': cat.id === activeId }"
|
||||
@click="selectCategory(cat.id)"
|
||||
>
|
||||
<span class="settings__cat-icon"><span class="dashicons" :class="cat.icon" /></span>
|
||||
<span class="settings__cat-text">
|
||||
<span class="settings__cat-title">{{ cat.title }}</span>
|
||||
<span class="settings__cat-sub">{{ cat.subtitle }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- Right content panel -->
|
||||
<section class="settings__content">
|
||||
<header class="settings__content-head">
|
||||
<div>
|
||||
<h2 class="settings__content-title">{{ activeCategory.title }}</h2>
|
||||
<p class="settings__content-sub">{{ activeCategory.subtitle }}</p>
|
||||
</div>
|
||||
<div class="settings__save-wrap">
|
||||
<span v-if="savedMsg" class="settings__saved">{{ savedMsg }}</span>
|
||||
<button type="button" class="settings__save" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="settings__fields">
|
||||
<SettingsField
|
||||
v-for="f in activeCategory.fields"
|
||||
:key="f.key"
|
||||
:field="f"
|
||||
:options="optionsFor(f)"
|
||||
v-model="settings[f.key]"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
min-height: calc(100vh - var(--aurora-toolbar-h) - 60px);
|
||||
margin: -32px; /* cancel the frame padding */
|
||||
padding: 22px; /* inset the two boxes; the frame's right gutter adds the rest */
|
||||
box-sizing: border-box;
|
||||
/* Darker backdrop so the two boxes read as distinct panels. */
|
||||
background: var(--aurora-app-bg);
|
||||
}
|
||||
|
||||
/* Left nav — its own box */
|
||||
.settings__nav {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
background: var(--aurora-frame-bg);
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 16px;
|
||||
padding: 24px 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.settings__nav-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.settings__title { font-size: 1.35rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
|
||||
.settings__actions { display: flex; gap: 4px; }
|
||||
.settings__actions button {
|
||||
width: 30px; height: 30px; border: none; background: none;
|
||||
color: var(--aurora-text-muted); cursor: pointer; border-radius: 7px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.settings__actions button:hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
|
||||
.settings__file { display: none; }
|
||||
|
||||
.settings__search {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 10px; padding: 8px 12px; margin-bottom: 16px;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.settings__search input {
|
||||
border: none; background: none; outline: none; color: var(--aurora-text);
|
||||
font-size: 0.8125rem; width: 100%;
|
||||
}
|
||||
|
||||
.settings__list { list-style: none; margin: 0; padding: 0; }
|
||||
.settings__cat {
|
||||
display: flex; align-items: center; gap: 12px; width: 100%;
|
||||
padding: 10px; border: none; background: none; cursor: pointer;
|
||||
border-radius: 12px; text-align: left; margin-bottom: 2px;
|
||||
}
|
||||
.settings__cat:hover { background: var(--aurora-bg-subtle); }
|
||||
.settings__cat--active { background: var(--aurora-accent-soft); }
|
||||
.settings__cat-icon {
|
||||
width: 34px; height: 34px; flex-shrink: 0;
|
||||
border-radius: 9px; background: var(--aurora-bg-subtle);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--aurora-text-muted);
|
||||
}
|
||||
.settings__cat--active .settings__cat-icon { color: var(--aurora-accent); }
|
||||
.settings__cat-text { display: flex; flex-direction: column; min-width: 0; }
|
||||
.settings__cat-title { font-size: 0.875rem; font-weight: 600; color: var(--aurora-text); }
|
||||
.settings__cat--active .settings__cat-title { color: var(--aurora-accent); }
|
||||
.settings__cat-sub {
|
||||
font-size: 0.75rem; color: var(--aurora-text-muted);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Right content — its own box */
|
||||
.settings__content {
|
||||
flex: 1; min-width: 0;
|
||||
background: var(--aurora-frame-bg);
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: 16px;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
.settings__content-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding-bottom: 22px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--aurora-frame-border);
|
||||
}
|
||||
.settings__content-title { font-size: 1.6rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
|
||||
.settings__content-sub { margin: 6px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
|
||||
.settings__save-wrap { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
||||
.settings__saved { font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||
.settings__save {
|
||||
background: var(--aurora-accent); color: #fff; border: none;
|
||||
border-radius: 9px; padding: 9px 20px; font-size: 0.875rem; font-weight: 500; cursor: pointer;
|
||||
}
|
||||
.settings__save:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
.settings__fields > :last-child { border-bottom: none; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.settings { flex-direction: column; }
|
||||
.settings__nav { width: auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,315 @@
|
||||
<script setup>
|
||||
import { computed, reactive } from 'vue';
|
||||
import Toggle from '@/components/Toggle.vue';
|
||||
|
||||
const props = defineProps({
|
||||
field: { type: Object, required: true },
|
||||
modelValue: { default: undefined },
|
||||
// Options for multiselect fields (resolved by the page from optionsSource).
|
||||
options: { type: Array, default: () => [] },
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const val = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
});
|
||||
|
||||
// Repeater helpers ---------------------------------------------------------
|
||||
const rows = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []));
|
||||
const addRow = () => emit('update:modelValue', [...rows.value, { find: '', replace: '' }]);
|
||||
const updateRow = (i, key, v) => {
|
||||
const next = rows.value.map((r, idx) => (idx === i ? { ...r, [key]: v } : r));
|
||||
emit('update:modelValue', next);
|
||||
};
|
||||
const removeRow = (i) => emit('update:modelValue', rows.value.filter((_, idx) => idx !== i));
|
||||
|
||||
// Multiselect helpers -------------------------------------------------------
|
||||
const selectedValues = computed(() =>
|
||||
Array.isArray(props.modelValue) ? props.modelValue : []
|
||||
);
|
||||
const toggleValue = (value) => {
|
||||
const next = selectedValues.value.includes(value)
|
||||
? selectedValues.value.filter((v) => v !== value)
|
||||
: [...selectedValues.value, value];
|
||||
emit('update:modelValue', next);
|
||||
};
|
||||
|
||||
// Remote sites helpers -------------------------------------------------------
|
||||
const sites = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []));
|
||||
const newSite = reactive({ url: '', username: '', app_password: '' });
|
||||
const addSite = () => {
|
||||
if (!newSite.url.trim()) return;
|
||||
emit('update:modelValue', [
|
||||
...sites.value,
|
||||
{ url: newSite.url.trim(), username: newSite.username.trim(), app_password: newSite.app_password },
|
||||
]);
|
||||
newSite.url = '';
|
||||
newSite.username = '';
|
||||
newSite.app_password = '';
|
||||
};
|
||||
const removeSite = (i) => emit('update:modelValue', sites.value.filter((_, idx) => idx !== i));
|
||||
|
||||
// Media picker (image fields) ---------------------------------------------
|
||||
const pickImage = () => {
|
||||
const wp = window.wp;
|
||||
if (!wp || !wp.media) {
|
||||
// Fallback: prompt for a URL if the media library isn't available.
|
||||
const url = window.prompt('Image URL');
|
||||
if (url) emit('update:modelValue', url);
|
||||
return;
|
||||
}
|
||||
const frame = wp.media({ title: props.field.label, multiple: false });
|
||||
frame.on('select', () => {
|
||||
const att = frame.state().get('selection').first().toJSON();
|
||||
emit('update:modelValue', att.url);
|
||||
});
|
||||
frame.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="field">
|
||||
<div class="field__info">
|
||||
<div class="field__label">{{ field.label }}</div>
|
||||
<p v-if="field.description" class="field__desc">{{ field.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field__control">
|
||||
<!-- Text -->
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
type="text"
|
||||
class="field__input"
|
||||
:placeholder="field.placeholder || ''"
|
||||
:value="val || ''"
|
||||
@input="val = $event.target.value"
|
||||
/>
|
||||
|
||||
<!-- Toggle -->
|
||||
<Toggle
|
||||
v-else-if="field.type === 'toggle'"
|
||||
:model-value="!!val"
|
||||
@update:model-value="val = $event"
|
||||
/>
|
||||
|
||||
<!-- Color -->
|
||||
<div v-else-if="field.type === 'color'" class="field__color">
|
||||
<input
|
||||
type="color"
|
||||
:value="val || field.default || '#4772fa'"
|
||||
@input="val = $event.target.value"
|
||||
/>
|
||||
<span class="field__color-hex">{{ val || field.default || '#4772fa' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Image / favicon -->
|
||||
<button
|
||||
v-else-if="field.type === 'image'"
|
||||
type="button"
|
||||
class="field__image"
|
||||
@click="pickImage"
|
||||
>
|
||||
<img v-if="val" :src="val" alt="" />
|
||||
<span v-else class="dashicons dashicons-format-image field__image-ph" />
|
||||
<span class="field__image-label">{{ val ? 'Change' : (field.label + ' url') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Multiselect (checkbox list) -->
|
||||
<div v-else-if="field.type === 'multiselect'" class="field__multi">
|
||||
<label v-for="opt in options" :key="opt.value" class="field__multi-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedValues.includes(opt.value)"
|
||||
@change="toggleValue(opt.value)"
|
||||
/>
|
||||
<span>{{ opt.label }}</span>
|
||||
</label>
|
||||
<p v-if="!options.length" class="field__multi-empty">No options available.</p>
|
||||
</div>
|
||||
|
||||
<!-- Segmented No/Yes -->
|
||||
<div v-else-if="field.type === 'segmented'" class="field__seg">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'field__seg--active': !val }"
|
||||
@click="val = false"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'field__seg--active': !!val }"
|
||||
@click="val = true"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Remote sites -->
|
||||
<div v-else-if="field.type === 'remote-sites'" class="field__sites">
|
||||
<div class="field__sites-add">
|
||||
<input v-model="newSite.url" type="text" class="field__input" placeholder="Site URL (e.g., https://example.com)" />
|
||||
<div class="field__sites-row">
|
||||
<input v-model="newSite.username" type="text" class="field__input" placeholder="Username" />
|
||||
<input v-model="newSite.app_password" type="password" class="field__input" placeholder="Application Password" />
|
||||
<button type="button" class="field__newitem" @click="addSite">Add Site</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field__sites-list">
|
||||
<div v-if="!sites.length" class="field__sites-empty">No remote sites configured yet</div>
|
||||
<div v-for="(site, i) in sites" :key="i" class="field__sites-item">
|
||||
<span class="field__sites-url">{{ site.url }}</span>
|
||||
<span class="field__sites-user">{{ site.username }}</span>
|
||||
<button type="button" class="field__repeater-remove" @click="removeSite(i)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field__sites-help">
|
||||
Application passwords can be created in WordPress under Users > Profile > Application Passwords
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Repeater -->
|
||||
<div v-else-if="field.type === 'repeater'" class="field__repeater">
|
||||
<div v-for="(row, i) in rows" :key="i" class="field__repeater-row">
|
||||
<input
|
||||
type="text"
|
||||
class="field__input"
|
||||
:placeholder="field.columns ? field.columns[0] : 'Find'"
|
||||
:value="row.find"
|
||||
@input="updateRow(i, 'find', $event.target.value)"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="field__input"
|
||||
:placeholder="field.columns ? field.columns[1] : 'Replace'"
|
||||
:value="row.replace"
|
||||
@input="updateRow(i, 'replace', $event.target.value)"
|
||||
/>
|
||||
<button type="button" class="field__repeater-remove" @click="removeRow(i)">×</button>
|
||||
</div>
|
||||
<button type="button" class="field__newitem" @click="addRow">New item</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
padding: 28px 0;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
align-items: start;
|
||||
}
|
||||
.field__label { font-size: 0.95rem; font-weight: 600; color: var(--aurora-text); }
|
||||
.field__desc { margin: 6px 0 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.5; }
|
||||
.field__control { display: flex; justify-content: flex-start; }
|
||||
|
||||
.field__input {
|
||||
width: 100%;
|
||||
background: var(--aurora-bg-subtle);
|
||||
border: 1px solid var(--aurora-border);
|
||||
color: var(--aurora-text);
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.field__input:focus { outline: none; border-color: var(--aurora-accent); }
|
||||
|
||||
.field__color { display: flex; align-items: center; gap: 10px; }
|
||||
.field__color input[type='color'] {
|
||||
width: 40px; height: 32px; border: 1px solid var(--aurora-border);
|
||||
border-radius: 8px; background: none; cursor: pointer; padding: 2px;
|
||||
}
|
||||
.field__color-hex { font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||
|
||||
.field__image {
|
||||
width: 160px; height: 120px;
|
||||
border: 1px dashed var(--aurora-border);
|
||||
border-radius: 12px;
|
||||
background: var(--aurora-bg-subtle);
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 8px; cursor: pointer; color: var(--aurora-text-muted);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.field__image img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
|
||||
.field__image-ph { font-size: 28px; width: 28px; height: 28px; }
|
||||
.field__image-label {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
background: var(--aurora-card-bg); padding: 6px; font-size: 0.75rem;
|
||||
border-top: 1px solid var(--aurora-border);
|
||||
}
|
||||
|
||||
.field__repeater { width: 100%; }
|
||||
.field__repeater-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.field__repeater-remove {
|
||||
flex-shrink: 0; width: 34px; border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
|
||||
border-radius: 8px; cursor: pointer; font-size: 1.1rem;
|
||||
}
|
||||
.field__newitem {
|
||||
border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text);
|
||||
border-radius: 8px; padding: 8px 16px; font-size: 0.8125rem; cursor: pointer;
|
||||
}
|
||||
.field__newitem:hover { border-color: var(--aurora-accent); color: var(--aurora-accent); }
|
||||
|
||||
/* Multiselect */
|
||||
.field__multi { display: flex; flex-direction: column; gap: 8px; width: 100%; }
|
||||
.field__multi-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 0.8125rem; color: var(--aurora-text); cursor: pointer;
|
||||
}
|
||||
.field__multi-item input { accent-color: var(--aurora-accent); }
|
||||
.field__multi-empty { font-size: 0.8125rem; color: var(--aurora-text-muted); margin: 0; }
|
||||
|
||||
/* Segmented No/Yes */
|
||||
.field__seg {
|
||||
display: inline-flex;
|
||||
background: var(--aurora-bg-subtle);
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 9px;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
.field__seg button {
|
||||
border: none; background: none; cursor: pointer;
|
||||
padding: 6px 26px; border-radius: 7px;
|
||||
font-size: 0.8125rem; color: var(--aurora-text-muted);
|
||||
}
|
||||
.field__seg--active { background: var(--aurora-accent); color: #fff !important; }
|
||||
|
||||
/* Remote sites */
|
||||
.field__sites { width: 100%; }
|
||||
.field__sites-add .field__input { margin-bottom: 8px; }
|
||||
.field__sites-row { display: flex; gap: 8px; }
|
||||
.field__sites-row .field__input { margin-bottom: 0; }
|
||||
.field__sites-row .field__newitem { flex-shrink: 0; white-space: nowrap; }
|
||||
.field__sites-list {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--aurora-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.field__sites-empty {
|
||||
padding: 18px; text-align: center;
|
||||
font-size: 0.8125rem; color: var(--aurora-text-muted);
|
||||
}
|
||||
.field__sites-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--aurora-border);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.field__sites-item:last-child { border-bottom: none; }
|
||||
.field__sites-url { color: var(--aurora-text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.field__sites-user { color: var(--aurora-text-muted); }
|
||||
.field__sites-help { margin: 10px 0 0; font-size: 0.75rem; color: var(--aurora-text-muted); }
|
||||
|
||||
@media (max-width: 782px) {
|
||||
.field { grid-template-columns: 1fr; gap: 12px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Declarative settings schema for the Aurora Admin settings page.
|
||||
* Each category renders as an item in the left nav; its fields render in
|
||||
* the right panel. Field `key`s map directly into the aurora_admin_settings
|
||||
* option, saved via the wp/v2/settings REST route.
|
||||
*/
|
||||
export const settingsCategories = [
|
||||
{
|
||||
id: 'general',
|
||||
title: 'General',
|
||||
subtitle: 'Core plugin settings and license management',
|
||||
icon: 'dashicons-admin-settings',
|
||||
fields: [
|
||||
{
|
||||
key: 'license_key',
|
||||
type: 'text',
|
||||
label: 'License key',
|
||||
description: 'Enter your license key to enable automatic updates.',
|
||||
placeholder: 'AA-XXXX-XXXX-XXXX',
|
||||
},
|
||||
{
|
||||
key: 'logo',
|
||||
type: 'image',
|
||||
label: 'Logo',
|
||||
description: 'Optional logo to be displayed in the menu.',
|
||||
},
|
||||
{
|
||||
key: 'dark_logo',
|
||||
type: 'image',
|
||||
label: 'Dark mode logo',
|
||||
description:
|
||||
"Set an alternative logo to use in dark mode. If one isn't set it will fall back to the standard logo.",
|
||||
},
|
||||
{
|
||||
key: 'remote_sites',
|
||||
type: 'remote-sites',
|
||||
label: 'Remote Sites',
|
||||
description: 'Configure remote WordPress sites for cross-site operations.',
|
||||
},
|
||||
{
|
||||
key: 'remote_site_switcher_capability',
|
||||
type: 'text',
|
||||
label: 'Remote Site Switcher Capability',
|
||||
description:
|
||||
'WordPress capability required to see the remote site switcher in the toolbar. Defaults to "manage_options" (admin users). Users with this capability or below will see the switcher.',
|
||||
placeholder: 'manage_options',
|
||||
},
|
||||
{
|
||||
key: 'disable_theme',
|
||||
type: 'multiselect',
|
||||
label: 'Disable theme',
|
||||
description: 'Choose roles to disable the theme for.',
|
||||
optionsSource: 'roles',
|
||||
},
|
||||
{
|
||||
key: 'search_post_types',
|
||||
type: 'multiselect',
|
||||
label: 'Search post types',
|
||||
description: 'Choose which post types are available in the global search.',
|
||||
optionsSource: 'postTypes',
|
||||
},
|
||||
{
|
||||
key: 'disable_global_search',
|
||||
type: 'segmented',
|
||||
label: 'Disable global search',
|
||||
description: 'Disable global search for all users.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'login',
|
||||
title: 'Login',
|
||||
subtitle: 'Customize login page appearance and security',
|
||||
icon: 'dashicons-lock',
|
||||
fields: [
|
||||
{
|
||||
key: 'style_login',
|
||||
type: 'toggle',
|
||||
label: 'Style login page',
|
||||
description: 'Apply the Aurora theme to the WordPress login screen.',
|
||||
},
|
||||
{
|
||||
key: 'login_logo',
|
||||
type: 'image',
|
||||
label: 'Login logo',
|
||||
description: 'Replace the WordPress logo on the login screen.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'theme',
|
||||
title: 'Theme',
|
||||
subtitle: 'Colors, styling, and visual customization',
|
||||
icon: 'dashicons-admin-appearance',
|
||||
fields: [
|
||||
{
|
||||
key: 'dark_mode',
|
||||
type: 'toggle',
|
||||
label: 'Dark mode',
|
||||
description: 'Use the dark interface theme across the admin.',
|
||||
},
|
||||
{
|
||||
key: 'accent_color',
|
||||
type: 'color',
|
||||
label: 'Accent color',
|
||||
description: 'Primary accent color used throughout the interface.',
|
||||
default: '#4772fa',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'whitelabel',
|
||||
title: 'White Label',
|
||||
subtitle: 'Rebrand and customize interface text and icons',
|
||||
icon: 'dashicons-tag',
|
||||
fields: [
|
||||
{
|
||||
key: 'admin_favicon',
|
||||
type: 'image',
|
||||
label: 'Admin favicon',
|
||||
description: 'Optional favicon to replace the WordPress favicon in the admin.',
|
||||
},
|
||||
{
|
||||
key: 'plugin_name',
|
||||
type: 'text',
|
||||
label: 'Rename Aurora Admin',
|
||||
description:
|
||||
"This will rename Aurora Admin in the plugins list as well as renaming its settings link.",
|
||||
placeholder: 'Aurora Admin',
|
||||
},
|
||||
{
|
||||
key: 'text_replacements',
|
||||
type: 'repeater',
|
||||
label: 'Text Replacement',
|
||||
description:
|
||||
'Customize the text displayed in the WordPress admin area. Enter original words or phrases and their replacements to white-label the interface.',
|
||||
columns: ['Find', 'Replace with'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'menu',
|
||||
title: 'Menu',
|
||||
subtitle: 'Admin menu behavior and interaction settings',
|
||||
icon: 'dashicons-menu',
|
||||
fields: [
|
||||
{
|
||||
key: 'menu_search',
|
||||
type: 'toggle',
|
||||
label: 'Menu search',
|
||||
description: 'Show a search box at the top of the admin menu.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'posts',
|
||||
title: 'Posts',
|
||||
subtitle: 'Post management and display preferences',
|
||||
icon: 'dashicons-admin-post',
|
||||
fields: [
|
||||
{
|
||||
key: 'modern_posts',
|
||||
type: 'toggle',
|
||||
label: 'Modern posts table',
|
||||
description: 'Use the Aurora posts management interface.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'plugins',
|
||||
title: 'Plugins',
|
||||
subtitle: 'Plugin management interface options',
|
||||
icon: 'dashicons-admin-plugins',
|
||||
fields: [
|
||||
{
|
||||
key: 'modern_plugins',
|
||||
type: 'toggle',
|
||||
label: 'Modern plugins page',
|
||||
description: 'Use the Aurora plugins management interface.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dashboard',
|
||||
title: 'Dashboard',
|
||||
subtitle: 'Custom dashboard and admin interface settings',
|
||||
icon: 'dashicons-dashboard',
|
||||
fields: [
|
||||
{
|
||||
key: 'use_custom_dashboard',
|
||||
type: 'toggle',
|
||||
label: 'Custom dashboard',
|
||||
description: "Replace the WordPress dashboard with Aurora's dashboard.",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
title: 'Analytics',
|
||||
subtitle: 'Built-in analytics and tracking settings',
|
||||
icon: 'dashicons-chart-bar',
|
||||
fields: [
|
||||
{
|
||||
key: 'enable_analytics',
|
||||
type: 'toggle',
|
||||
label: 'Enable analytics',
|
||||
description: 'Track first-party visitor analytics for the dashboard.',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
key: 'track_admins',
|
||||
type: 'toggle',
|
||||
label: 'Track logged-in admins',
|
||||
description: 'Include administrator visits in analytics (off by default).',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'media',
|
||||
title: 'Media',
|
||||
subtitle: 'Media library and upload management settings',
|
||||
icon: 'dashicons-admin-media',
|
||||
fields: [
|
||||
{
|
||||
key: 'enable_svg',
|
||||
type: 'toggle',
|
||||
label: 'Allow SVG uploads',
|
||||
description: 'Permit SVG files to be uploaded to the media library.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
title: 'Users',
|
||||
subtitle: 'User management and administration settings',
|
||||
icon: 'dashicons-admin-users',
|
||||
fields: [
|
||||
{
|
||||
key: 'modern_users',
|
||||
type: 'toggle',
|
||||
label: 'Modern users page',
|
||||
description: 'Use the Aurora users management interface.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'comments',
|
||||
title: 'Comments',
|
||||
subtitle: 'Comment management and administration',
|
||||
icon: 'dashicons-admin-comments',
|
||||
fields: [
|
||||
{
|
||||
key: 'modern_comments',
|
||||
type: 'toggle',
|
||||
label: 'Modern comments page',
|
||||
description: 'Use the Aurora comments management interface.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import Sidebar from './components/Sidebar.vue';
|
||||
import Toolbar from './components/Toolbar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isDark = ref(props.serverData?.settings?.dark_mode === 'on');
|
||||
|
||||
const applyTheme = () => {
|
||||
document.documentElement.classList.toggle('aurora-dark', isDark.value);
|
||||
};
|
||||
|
||||
const persistDark = async () => {
|
||||
try {
|
||||
await fetch(`${props.serverData.restUrl}wp/v2/settings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
},
|
||||
// Partial payload: the option's sanitize callback merges this into
|
||||
// the stored settings, so we never write back the (secret-stripped)
|
||||
// settings copy this page received.
|
||||
body: JSON.stringify({
|
||||
aurora_admin_settings: {
|
||||
dark_mode: isDark.value ? 'on' : 'off',
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// Non-critical: theme still applied for this session.
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDark = () => {
|
||||
isDark.value = !isDark.value;
|
||||
applyTheme();
|
||||
persistDark();
|
||||
};
|
||||
|
||||
// Minified (icon-only) menu — a per-browser preference kept in
|
||||
// localStorage; the html class drives all layout via --aurora-sidebar-w.
|
||||
const collapsed = ref(localStorage.getItem('aurora-menu-collapsed') === '1');
|
||||
const applyCollapsed = () => {
|
||||
document.documentElement.classList.toggle('aurora-collapsed', collapsed.value);
|
||||
};
|
||||
const toggleMenu = () => {
|
||||
collapsed.value = !collapsed.value;
|
||||
applyCollapsed();
|
||||
localStorage.setItem('aurora-menu-collapsed', collapsed.value ? '1' : '0');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
applyTheme();
|
||||
applyCollapsed();
|
||||
// Signal the shell is live: reveals the framed content and commits to
|
||||
// keeping native chrome hidden (the PHP failsafe stops watching once
|
||||
// aurora-ready is present).
|
||||
document.documentElement.classList.add('aurora-ready');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="site-shell">
|
||||
<Sidebar
|
||||
:menu="serverData.menu"
|
||||
:aurora-nav="serverData.auroraNav"
|
||||
:current-url="serverData.currentUrl"
|
||||
:logo="serverData.settings?.logo || ''"
|
||||
:dark-logo="serverData.settings?.dark_logo || ''"
|
||||
:is-dark="isDark"
|
||||
:collapsed="collapsed"
|
||||
/>
|
||||
<Toolbar
|
||||
:server-data="serverData"
|
||||
:is-dark="isDark"
|
||||
@toggle-dark="toggleDark"
|
||||
@toggle-menu="toggleMenu"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Sidebar and Toolbar are both position:fixed, so this wrapper has no
|
||||
layout role — it just groups them under one mounted root. */
|
||||
.site-shell {
|
||||
display: contents;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* The ONLY global stylesheet in the plugin. Every rule here either defines
|
||||
* an --aurora-* theme variable or targets a specific WordPress structural ID
|
||||
* to frame the native content. No element/tag resets, no utility classes —
|
||||
* so it cannot leak onto or restyle WordPress's own page content the way the
|
||||
* old plugin's Tailwind preflight did.
|
||||
*
|
||||
* Theme colors live here as plain CSS variables we fully control. Dark mode
|
||||
* is a single class on <html> (.aurora-dark) that swaps the variable values.
|
||||
*/
|
||||
|
||||
/* Palette carried over from the original plugin's design tokens: a zinc
|
||||
neutral scale with a blue/indigo accent, large rounded surfaces. */
|
||||
:root {
|
||||
--aurora-sidebar-w: 264px;
|
||||
--aurora-toolbar-h: 56px;
|
||||
/* Breathing room between page content and the right browser edge. */
|
||||
--aurora-gutter-r: 44px;
|
||||
|
||||
--aurora-radius: 14px;
|
||||
--aurora-radius-lg: 24px;
|
||||
|
||||
/* Light theme */
|
||||
--aurora-app-bg: #fafafa; /* zinc-50 */
|
||||
--aurora-bg: #ffffff; /* frame */
|
||||
--aurora-card-bg: #fafafa; /* zinc-50 */
|
||||
--aurora-bg-subtle: #f4f4f5; /* zinc-100 */
|
||||
--aurora-text: #18181b; /* zinc-900 */
|
||||
--aurora-text-muted: #71717a; /* zinc-500 */
|
||||
--aurora-border: #e4e4e7; /* zinc-200 */
|
||||
--aurora-card-border: #ececef;
|
||||
--aurora-frame-bg: #ffffff; /* native-page content panel */
|
||||
--aurora-frame-border: #e0e0e3;
|
||||
--aurora-accent: rgb(71 114 250);
|
||||
--aurora-accent-soft: rgb(71 114 250 / 0.1);
|
||||
--aurora-accent-text: #ffffff;
|
||||
}
|
||||
|
||||
/* Minified menu: the toolbar's collapse button toggles this class; every
|
||||
layout offset derives from --aurora-sidebar-w, so this one override
|
||||
reflows the toolbar and content automatically. */
|
||||
html.aurora-collapsed {
|
||||
--aurora-sidebar-w: 68px;
|
||||
}
|
||||
|
||||
html.aurora-dark {
|
||||
/* Dark theme — the immersive near-black look of the original */
|
||||
--aurora-app-bg: #09090b; /* zinc-950 */
|
||||
--aurora-bg: #18181b; /* zinc-900 frame */
|
||||
--aurora-card-bg: #1f1f23; /* slightly elevated */
|
||||
--aurora-bg-subtle: #27272a; /* zinc-800 */
|
||||
--aurora-text: #f4f4f5; /* zinc-100 */
|
||||
--aurora-text-muted: #a1a1aa; /* zinc-400 */
|
||||
--aurora-border: #27272a; /* zinc-800 */
|
||||
--aurora-card-border: #2e2e33;
|
||||
--aurora-frame-bg: #1c1c20; /* slightly elevated panel */
|
||||
--aurora-frame-border: #34343b;
|
||||
--aurora-accent: rgb(125 158 255);
|
||||
--aurora-accent-soft: rgb(125 158 255 / 0.14);
|
||||
}
|
||||
|
||||
/* --- Framing: only applies once the Vue shell has signalled ready --- */
|
||||
|
||||
html.aurora-ready,
|
||||
html.aurora-ready body {
|
||||
background: var(--aurora-app-bg);
|
||||
}
|
||||
|
||||
/* !important here defeats WordPress's own responsive #wpcontent margin
|
||||
rules (.auto-fold / folded menu states live in media queries at equal
|
||||
specificity). This is positioning the frame, not restyling content.
|
||||
The toolbar clearance lives here (on #wpcontent, which holds everything)
|
||||
rather than on #wpbody, so content rendered above #wpbody — like the
|
||||
takeover dashboard — also clears the fixed toolbar. */
|
||||
html.aurora-ready #wpcontent {
|
||||
margin-left: var(--aurora-sidebar-w) !important;
|
||||
padding-left: 0 !important;
|
||||
padding-top: var(--aurora-toolbar-h);
|
||||
transition: margin-left 0.2s ease;
|
||||
}
|
||||
|
||||
html.aurora-ready #wpbody-content {
|
||||
background: var(--aurora-frame-bg);
|
||||
color: var(--aurora-text);
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: var(--aurora-radius-lg);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
margin: 16px var(--aurora-gutter-r) 24px 24px;
|
||||
padding: 32px;
|
||||
min-height: calc(100vh - var(--aurora-toolbar-h) - 40px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* The settings page renders its own two boxed panels, so drop the outer
|
||||
content frame there — leaving just those panels on the dark backdrop. */
|
||||
html.aurora-ready body.toplevel_page_aurora-admin #wpbody-content {
|
||||
background: var(--aurora-app-bg);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Native WordPress pages are fully dark-themed by a dedicated stylesheet
|
||||
* (admin/assets/native-dark.css), enqueued only on native pages and gated
|
||||
* on dark mode. It restyles WP's postboxes, forms, tables, buttons, etc.
|
||||
* Keeping that large surface in one isolated, native-only file means it
|
||||
* can never leak onto Aurora's own pages or affect light mode.
|
||||
*/
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
// Deliberately simple: one entry in this first slice (dashboard). Add more
|
||||
// entries here as additional pages get rebuilt — same flat pattern each
|
||||
// time, no shared scoping/theme-remap layer to keep in sync across files.
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL('./src', import.meta.url)),
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: fileURLToPath(new URL('./dist', import.meta.url)),
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
manifest: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
shell: fileURLToPath(new URL('./src/entries/shell.js', import.meta.url)),
|
||||
dashboard: fileURLToPath(new URL('./src/entries/dashboard.js', import.meta.url)),
|
||||
settings: fileURLToPath(new URL('./src/entries/settings.js', import.meta.url)),
|
||||
'menu-creator': fileURLToPath(new URL('./src/entries/menu-creator.js', import.meta.url)),
|
||||
'admin-notices': fileURLToPath(new URL('./src/entries/admin-notices.js', import.meta.url)),
|
||||
'role-editor': fileURLToPath(new URL('./src/entries/role-editor.js', import.meta.url)),
|
||||
'activity-log': fileURLToPath(new URL('./src/entries/activity-log.js', import.meta.url)),
|
||||
'database-explorer': fileURLToPath(new URL('./src/entries/database-explorer.js', import.meta.url)),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].[hash].js',
|
||||
chunkFileNames: 'assets/[name].[hash].js',
|
||||
assetFileNames: 'assets/[name].[hash][extname]',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user