Initial commit: Aurora Admin plugin

This commit is contained in:
Kris Parish
2026-07-03 02:53:29 -05:00
commit 03cabe2fe4
73 changed files with 9591 additions and 0 deletions
+1363
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -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"
}
}
+76
View File
@@ -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>
+10
View File
@@ -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);
}
+10
View File
@@ -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);
}
+12
View File
@@ -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);
}
+10
View File
@@ -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);
}
+10
View File
@@ -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);
}
+10
View File
@@ -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);
}
+10
View File
@@ -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);
}
+10
View File
@@ -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);
}
+86
View File
@@ -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>
+178
View File
@@ -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>
+153
View File
@@ -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>
+249
View File
@@ -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>
+17
View File
@@ -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>
+259
View File
@@ -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>
+201
View File
@@ -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>
+286
View File
@@ -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 &gt; Profile &gt; 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>
+261
View File
@@ -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.',
},
],
},
];
+95
View File
@@ -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>
+219
View File
@@ -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>
+414
View File
@@ -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>
+341
View File
@@ -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>
+109
View File
@@ -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.
*/
+39
View File
@@ -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]',
},
},
},
});