Advance full admin takeover from v1.1 to v1.12
Batches everything built since the 1.0 WP.org submission reset: Login Redirect, the Tools/Export/Import screen takeovers, Modules rebuilt as an addon marketplace, Menu Creator's reorder fix, Activity Log redesigned into a filterable table with Admin/User tabs, dark-theme fixes for Site Health/Personal Data/Updates, and an opt-in Disable Gutenberg toggle. Continues the "nothing stays native" takeover goal and closes gaps found via live testing and the WordPress Plugin Check tool. See CHANGELOG.md for the full per-version breakdown (1.1-1.12). Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
faf9f35992
commit
24be114f9a
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import Export from '@/pages/export/Export.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-export-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(Export, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import Import from '@/pages/import/Import.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-import-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(Import, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import Modules from '@/pages/modules/Modules.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-modules-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(Modules, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import Tools from '@/pages/tools/Tools.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-tools-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(Tools, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -80,7 +80,10 @@ const search = defineModel('search', { type: String, default: '' });
|
||||
current scroll position, looking like nothing happened until you
|
||||
scrolled all the way back up. */
|
||||
height: calc(100vh - var(--aurora-toolbar-h) - 60px);
|
||||
margin: -32px;
|
||||
/* -20px cancels the scroller's 20px padding (shell-frame.css's two-layer
|
||||
scroll rule) so the shell still bleeds to the rounded frame's inner
|
||||
edge. Was -32px back when #wpbody-content padded the content directly. */
|
||||
margin: -20px;
|
||||
padding: 22px;
|
||||
box-sizing: border-box;
|
||||
/* No background — html/body already paint the page gradient; this was
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<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 },
|
||||
@@ -15,28 +13,67 @@ const api = (path) =>
|
||||
return r.json();
|
||||
});
|
||||
|
||||
const ACTION_TYPES = [
|
||||
// Two tabs: "Admin" = site/content activity, "User" = account activity
|
||||
// (login/logout/registration/profile updates) — object_type 'user' is the
|
||||
// dividing line the REST route's `scope` param filters on.
|
||||
const TABS = [
|
||||
{ key: 'admin', label: 'Admin' },
|
||||
{ key: 'user', label: 'User' },
|
||||
];
|
||||
|
||||
const ADMIN_ACTIONS = [
|
||||
['', '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'],
|
||||
['created', 'Created'],
|
||||
['updated', 'Updated'],
|
||||
['deleted', 'Deleted'],
|
||||
['activated', 'Activated'],
|
||||
['deactivated', 'Deactivated'],
|
||||
['switched', 'Switched'],
|
||||
];
|
||||
const USER_ACTIONS = [
|
||||
['', 'All Actions'],
|
||||
['registered', 'Registered'],
|
||||
['profile_updated', 'Profile updated'],
|
||||
['login', 'Logged in'],
|
||||
['logout', 'Logged out'],
|
||||
];
|
||||
const OBJECT_TYPES = [
|
||||
['', 'All Types'],
|
||||
['post', 'Post'],
|
||||
['user', 'User'],
|
||||
['page', 'Page'],
|
||||
['attachment', 'Attachment'],
|
||||
['plugin', 'Plugin'],
|
||||
['theme', 'Theme'],
|
||||
['settings', 'Settings'],
|
||||
];
|
||||
|
||||
const TYPE_LABELS = {
|
||||
post: 'Post',
|
||||
page: 'Page',
|
||||
attachment: 'Attachment',
|
||||
user: 'User',
|
||||
plugin: 'Plugin',
|
||||
theme: 'Theme',
|
||||
settings: 'Settings',
|
||||
};
|
||||
const ACTION_LABELS = {
|
||||
created: 'Created',
|
||||
updated: 'Updated',
|
||||
deleted: 'Deleted',
|
||||
registered: 'Registered',
|
||||
profile_updated: 'Profile updated',
|
||||
login: 'Logged in',
|
||||
logout: 'Logged out',
|
||||
activated: 'Activated',
|
||||
deactivated: 'Deactivated',
|
||||
switched: 'Switched',
|
||||
};
|
||||
const typeLabel = (t) => TYPE_LABELS[t] || (t ? t.charAt(0).toUpperCase() + t.slice(1) : '—');
|
||||
const actionLabel = (a) => ACTION_LABELS[a] || a;
|
||||
|
||||
const scope = ref('admin');
|
||||
const actionOptions = computed(() => (scope.value === 'user' ? USER_ACTIONS : ADMIN_ACTIONS));
|
||||
|
||||
const items = ref([]);
|
||||
const loading = ref(true);
|
||||
const total = ref(0);
|
||||
@@ -45,14 +82,13 @@ 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();
|
||||
const params = new URLSearchParams({ scope: scope.value });
|
||||
if (actionFilter.value) params.set('action_type', actionFilter.value);
|
||||
if (objectFilter.value) params.set('object_type', objectFilter.value);
|
||||
if (scope.value === 'admin' && 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()}`);
|
||||
@@ -71,83 +107,218 @@ watch([actionFilter, objectFilter, search], () => {
|
||||
});
|
||||
watch(page, load);
|
||||
|
||||
const selected = computed(() => items.value.find((i) => i.id === selectedId.value) || null);
|
||||
const setScope = (key) => {
|
||||
if (scope.value === key) return;
|
||||
scope.value = key;
|
||||
actionFilter.value = '';
|
||||
objectFilter.value = '';
|
||||
page.value = 1;
|
||||
load();
|
||||
};
|
||||
|
||||
const formatDate = (s) => (s ? new Date(s.replace(' ', 'T')).toLocaleString() : '');
|
||||
|
||||
const rowRange = computed(() => {
|
||||
if (!total.value) return '0';
|
||||
const start = (page.value - 1) * 20 + 1;
|
||||
const end = Math.min(page.value * 20, total.value);
|
||||
return `${start}–${end}`;
|
||||
});
|
||||
const goPage = (p) => {
|
||||
page.value = Math.min(Math.max(1, p), totalPages.value);
|
||||
};
|
||||
</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">
|
||||
<main class="activity-page">
|
||||
<header class="activity-page__header">
|
||||
<div>
|
||||
<p class="activity-page__eyebrow">Aurora Admin</p>
|
||||
<h1>Activity Log</h1>
|
||||
</div>
|
||||
<button type="button" class="icon-button" title="Refresh" :disabled="loading" @click="load">
|
||||
<span class="dashicons dashicons-update" />
|
||||
</button>
|
||||
</template>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<div class="activity-page__tabs">
|
||||
<button
|
||||
v-for="tab in TABS"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="filter-pill"
|
||||
:class="{ 'is-active': scope === tab.key }"
|
||||
@click="setScope(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<section class="activity-page__toolbar" aria-label="Activity filters and search">
|
||||
<div class="activity-page__filters">
|
||||
<select v-model="actionFilter" class="activity-page__select">
|
||||
<option v-for="[v, l] in actionOptions" :key="v" :value="v">{{ l }}</option>
|
||||
</select>
|
||||
<select v-if="scope === 'admin'" v-model="objectFilter" class="activity-page__select">
|
||||
<option v-for="[v, l] in OBJECT_TYPES" :key="v" :value="v">{{ l }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
</AppListShell>
|
||||
<label class="activity-page__search">
|
||||
<span class="dashicons dashicons-search" />
|
||||
<input v-model="search" type="search" placeholder="Search activity…" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="activity-table" aria-label="Activity Log">
|
||||
<div class="activity-table__head">
|
||||
<div>Date</div>
|
||||
<div>Author</div>
|
||||
<div>IP Address</div>
|
||||
<div>Type</div>
|
||||
<div>Action</div>
|
||||
<div>Description</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="activity-table__empty">Loading activity...</div>
|
||||
<div v-else-if="!items.length" class="activity-table__empty">No activity found.</div>
|
||||
|
||||
<article v-for="i in items" v-else :key="i.id" class="activity-row">
|
||||
<div class="activity-row__cell" data-label="Date">{{ formatDate(i.createdAt) }}</div>
|
||||
<div class="activity-row__cell activity-row__author" data-label="Author">
|
||||
<template v-if="i.userId">
|
||||
<img class="activity-row__avatar" :src="i.userAvatar" :alt="i.userName" width="32" height="32" />
|
||||
<div class="activity-row__author-info">
|
||||
<a :href="i.userEditUrl">{{ i.userName }}</a>
|
||||
<span v-if="i.userRole" class="activity-row__role">{{ i.userRole }}</span>
|
||||
<span v-if="i.userEmail" class="activity-row__email">{{ i.userEmail }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else>System</span>
|
||||
</div>
|
||||
<div class="activity-row__cell" data-label="IP Address">{{ i.ipAddress || '—' }}</div>
|
||||
<div class="activity-row__cell" data-label="Type">
|
||||
<span class="type-badge">{{ typeLabel(i.objectType) }}</span>
|
||||
</div>
|
||||
<div class="activity-row__cell" data-label="Action">{{ actionLabel(i.actionType) }}</div>
|
||||
<div class="activity-row__cell activity-row__desc" data-label="Description">{{ i.objectLabel || '—' }}</div>
|
||||
</article>
|
||||
|
||||
<footer v-if="!loading && items.length" class="activity-table__footer">
|
||||
<div>{{ rowRange }} of {{ total }}</div>
|
||||
<div class="activity-table__pager">
|
||||
<button type="button" :disabled="page <= 1" @click="goPage(1)">
|
||||
<span class="dashicons dashicons-controls-skipback" />
|
||||
</button>
|
||||
<button type="button" :disabled="page <= 1" @click="goPage(page - 1)">
|
||||
<span class="dashicons dashicons-arrow-left-alt2" />
|
||||
</button>
|
||||
<span>Page {{ page }} of {{ totalPages }}</span>
|
||||
<button type="button" :disabled="page >= totalPages" @click="goPage(page + 1)">
|
||||
<span class="dashicons dashicons-arrow-right-alt2" />
|
||||
</button>
|
||||
<button type="button" :disabled="page >= totalPages" @click="goPage(totalPages)">
|
||||
<span class="dashicons dashicons-controls-skipforward" />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.activity-log__select {
|
||||
padding: 5px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.75rem;
|
||||
.activity-page { min-height: calc(100vh - 112px); padding: 0; color: var(--aurora-text); background: transparent; }
|
||||
.activity-page__header {
|
||||
display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 18px;
|
||||
}
|
||||
.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: var(--aurora-radius-sm); font-size: 0.75rem; overflow-x: auto; color: var(--aurora-text);
|
||||
.activity-page__eyebrow {
|
||||
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
|
||||
font-weight: 800; letter-spacing: 0; text-transform: uppercase;
|
||||
}
|
||||
.activity-page h1 { margin: 0; color: var(--aurora-text); font-size: 1.45rem; line-height: 1.15; }
|
||||
|
||||
.icon-button {
|
||||
width: 42px; min-height: 42px; border: 1px solid var(--aurora-border); border-radius: var(--aurora-radius-sm);
|
||||
color: var(--aurora-text); background: var(--aurora-bg-subtle); display: inline-flex;
|
||||
align-items: center; justify-content: center; cursor: pointer;
|
||||
}
|
||||
.icon-button:hover:not(:disabled) { border-color: var(--aurora-text-muted); }
|
||||
.icon-button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
.activity-page__tabs { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||
.filter-pill {
|
||||
display: inline-flex; align-items: center; gap: 8px; min-height: 34px;
|
||||
border: 1px solid var(--aurora-border); border-radius: 999px; background: var(--aurora-bg-subtle);
|
||||
color: var(--aurora-text-muted); padding: 0 16px; font-size: 0.78rem; font-weight: 800; cursor: pointer;
|
||||
}
|
||||
.filter-pill.is-active { border-color: var(--aurora-text-muted); background: var(--aurora-frame-bg); color: var(--aurora-text); }
|
||||
|
||||
.activity-page__toolbar { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 12px; }
|
||||
.activity-page__filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.activity-page__select {
|
||||
padding: 7px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.78rem;
|
||||
}
|
||||
.activity-page__search {
|
||||
width: min(280px, 100%); min-height: 38px; border: 1px solid var(--aurora-border); border-radius: var(--aurora-radius-sm);
|
||||
background: var(--aurora-bg-subtle); display: flex; align-items: center; gap: 8px; padding: 0 12px; color: var(--aurora-text-muted);
|
||||
}
|
||||
.activity-page__search input {
|
||||
width: 100%; min-width: 0; border: 0; outline: 0; box-shadow: none; background: transparent;
|
||||
color: var(--aurora-text); font-size: 0.84rem;
|
||||
}
|
||||
.activity-page__search input::placeholder { color: var(--aurora-text-muted); }
|
||||
|
||||
.activity-table { overflow: hidden; border: 1px solid var(--aurora-frame-border); border-radius: var(--aurora-radius-sm); background: var(--aurora-frame-bg); }
|
||||
.activity-table__head, .activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 190px minmax(160px, 0.8fr) 130px 110px 140px minmax(180px, 1.6fr);
|
||||
align-items: center;
|
||||
}
|
||||
.activity-table__head {
|
||||
min-height: 44px; border-bottom: 1px solid var(--aurora-border); color: var(--aurora-text);
|
||||
font-size: 0.79rem; font-weight: 850;
|
||||
}
|
||||
.activity-table__head > div { min-height: 44px; padding: 0 16px; display: flex; align-items: center; }
|
||||
.activity-row { min-height: 76px; border-bottom: 1px solid var(--aurora-border); }
|
||||
.activity-row:hover { background: var(--aurora-bg-subtle); }
|
||||
.activity-row__cell {
|
||||
min-width: 0; padding: 12px 16px; color: var(--aurora-text-muted); font-size: 0.8rem;
|
||||
line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.activity-row__desc { color: var(--aurora-text); white-space: normal; }
|
||||
|
||||
.activity-row__author { display: flex; align-items: center; gap: 10px; white-space: normal; }
|
||||
.activity-row__avatar { border-radius: 50%; flex-shrink: 0; display: block; }
|
||||
.activity-row__author-info { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||
.activity-row__author-info a {
|
||||
color: var(--aurora-accent); font-weight: 700; font-size: 0.8125rem; text-decoration: none;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.activity-row__author-info a:hover { text-decoration: underline; }
|
||||
.activity-row__role, .activity-row__email {
|
||||
font-size: 0.71rem; color: var(--aurora-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.type-badge {
|
||||
display: inline-block; border-radius: 999px; background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
|
||||
padding: 2px 10px; font-size: 0.68rem; font-weight: 850; box-shadow: inset 0 0 0 1px var(--aurora-border);
|
||||
}
|
||||
.activity-table__empty { padding: 38px 18px; color: var(--aurora-text-muted); font-size: 0.85rem; text-align: center; }
|
||||
.activity-table__footer {
|
||||
min-height: 50px; border-top: 1px solid var(--aurora-border); display: flex; align-items: center;
|
||||
justify-content: flex-end; gap: 26px; padding: 0 16px; color: var(--aurora-text-muted); font-size: 0.8rem;
|
||||
}
|
||||
.activity-table__pager { display: flex; align-items: center; gap: 8px; }
|
||||
.activity-table__pager button {
|
||||
width: 28px; height: 28px; border: 0; border-radius: var(--aurora-radius-sm);
|
||||
background: transparent; color: var(--aurora-text-muted); cursor: pointer;
|
||||
}
|
||||
.activity-table__pager button:not(:disabled):hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
|
||||
.activity-table__pager button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.activity-table__head { display: none; }
|
||||
.activity-row { grid-template-columns: 1fr; padding: 10px 0; }
|
||||
.activity-row__cell { white-space: normal; }
|
||||
.activity-row__cell::before { content: attr(data-label) ': '; font-weight: 700; color: var(--aurora-text); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const contentType = ref('all');
|
||||
|
||||
const filters = reactive({
|
||||
category: '',
|
||||
post_author: '',
|
||||
post_start_date: '',
|
||||
post_end_date: '',
|
||||
post_status: '',
|
||||
page_author: '',
|
||||
page_start_date: '',
|
||||
page_end_date: '',
|
||||
page_status: '',
|
||||
attachment_start_date: '',
|
||||
attachment_end_date: '',
|
||||
});
|
||||
|
||||
// Standard WP post statuses (matches core's own get_post_stati({internal: false})).
|
||||
const STATUSES = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'publish', label: 'Published' },
|
||||
{ value: 'future', label: 'Scheduled' },
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'pending', label: 'Pending Review' },
|
||||
{ value: 'private', label: 'Private' },
|
||||
{ value: 'trash', label: 'Trash' },
|
||||
];
|
||||
|
||||
const categories = ref([]);
|
||||
const authors = ref([]);
|
||||
const loadingOptions = ref(true);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const headers = { 'X-WP-Nonce': props.serverData.restNonce };
|
||||
const [catsRes, usersRes] = await Promise.all([
|
||||
fetch(`${props.serverData.restUrl}wp/v2/categories?per_page=100`, { headers }),
|
||||
fetch(`${props.serverData.restUrl}wp/v2/users?per_page=100`, { headers }),
|
||||
]);
|
||||
categories.value = catsRes.ok ? await catsRes.json() : [];
|
||||
authors.value = usersRes.ok ? await usersRes.json() : [];
|
||||
} finally {
|
||||
loadingOptions.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Building and navigating to a real export.php?download=true&... URL (not a
|
||||
// fetch) so the browser's normal file-download flow handles the response —
|
||||
// this is the exact same request native WordPress's own form would submit,
|
||||
// so the file it returns is unmodified.
|
||||
//
|
||||
// Every relevant param is always sent, even when blank. Native's own HTML
|
||||
// <select> elements always submit a name=value pair regardless of which
|
||||
// option is picked, and export.php's own PHP reads $_GET['cat'] etc.
|
||||
// directly with no isset() guard — omitting a param when "All" is selected
|
||||
// (as this used to) triggers PHP "Undefined array key" warnings that, with
|
||||
// display_errors on (any site with debugging enabled), get echoed straight
|
||||
// into the downloaded file before the XML, corrupting it.
|
||||
const download = () => {
|
||||
const params = new URLSearchParams({ download: 'true', content: contentType.value });
|
||||
|
||||
if (contentType.value === 'posts') {
|
||||
params.set('cat', filters.category);
|
||||
params.set('post_author', filters.post_author);
|
||||
params.set('post_start_date', filters.post_start_date);
|
||||
params.set('post_end_date', filters.post_end_date);
|
||||
params.set('post_status', filters.post_status);
|
||||
} else if (contentType.value === 'pages') {
|
||||
params.set('page_author', filters.page_author);
|
||||
params.set('page_start_date', filters.page_start_date);
|
||||
params.set('page_end_date', filters.page_end_date);
|
||||
params.set('page_status', filters.page_status);
|
||||
} else if (contentType.value === 'attachment') {
|
||||
params.set('attachment_start_date', filters.attachment_start_date);
|
||||
params.set('attachment_end_date', filters.attachment_end_date);
|
||||
}
|
||||
|
||||
window.location.href = `${props.serverData.exportUrl}?${params.toString()}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="export-page">
|
||||
<header class="export-page__header">
|
||||
<p class="export-page__eyebrow">Aurora Admin</p>
|
||||
<h1>Export</h1>
|
||||
<p class="export-page__lead">
|
||||
Create an XML file (WordPress eXtended RSS, or WXR) containing your posts, pages, comments,
|
||||
custom fields, categories, and tags. Import it into another WordPress site to bring this
|
||||
content over.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="export-page__card">
|
||||
<h2>Choose what to export</h2>
|
||||
|
||||
<div class="export-page__options">
|
||||
<label class="export-page__radio">
|
||||
<input type="radio" value="all" v-model="contentType" />
|
||||
<div>
|
||||
<strong>All content</strong>
|
||||
<p>All of your posts, pages, comments, custom fields, terms, navigation menus, and custom posts.</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="export-page__radio">
|
||||
<input type="radio" value="posts" v-model="contentType" />
|
||||
<strong>Posts</strong>
|
||||
</label>
|
||||
<div v-if="contentType === 'posts'" class="export-page__filters">
|
||||
<label>
|
||||
Category
|
||||
<select v-model="filters.category" :disabled="loadingOptions">
|
||||
<option value="">All</option>
|
||||
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Author
|
||||
<select v-model="filters.post_author" :disabled="loadingOptions">
|
||||
<option value="">All</option>
|
||||
<option v-for="a in authors" :key="a.id" :value="a.id">{{ a.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Start date <input type="date" v-model="filters.post_start_date" /></label>
|
||||
<label>End date <input type="date" v-model="filters.post_end_date" /></label>
|
||||
<label>
|
||||
Status
|
||||
<select v-model="filters.post_status">
|
||||
<option v-for="s in STATUSES" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="export-page__radio">
|
||||
<input type="radio" value="pages" v-model="contentType" />
|
||||
<strong>Pages</strong>
|
||||
</label>
|
||||
<div v-if="contentType === 'pages'" class="export-page__filters">
|
||||
<label>
|
||||
Author
|
||||
<select v-model="filters.page_author" :disabled="loadingOptions">
|
||||
<option value="">All</option>
|
||||
<option v-for="a in authors" :key="a.id" :value="a.id">{{ a.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Start date <input type="date" v-model="filters.page_start_date" /></label>
|
||||
<label>End date <input type="date" v-model="filters.page_end_date" /></label>
|
||||
<label>
|
||||
Status
|
||||
<select v-model="filters.page_status">
|
||||
<option v-for="s in STATUSES" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="export-page__radio">
|
||||
<input type="radio" value="attachment" v-model="contentType" />
|
||||
<strong>Media</strong>
|
||||
</label>
|
||||
<div v-if="contentType === 'attachment'" class="export-page__filters">
|
||||
<label>Start date <input type="date" v-model="filters.attachment_start_date" /></label>
|
||||
<label>End date <input type="date" v-model="filters.attachment_end_date" /></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="export-page__submit" @click="download">Download Export File</button>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.export-page { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
|
||||
.export-page__eyebrow {
|
||||
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
|
||||
font-weight: 800; text-transform: uppercase;
|
||||
}
|
||||
.export-page__header h1 { margin: 0; font-size: 1.45rem; line-height: 1.15; }
|
||||
.export-page__lead {
|
||||
margin: 12px 0 0; max-width: 640px; color: var(--aurora-text-muted);
|
||||
font-size: 0.875rem; line-height: 1.6;
|
||||
}
|
||||
|
||||
.export-page__card {
|
||||
margin-top: 24px; background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
|
||||
border-radius: var(--aurora-radius-lg); padding: 22px 24px; max-width: 560px;
|
||||
}
|
||||
.export-page__card h2 { margin: 0 0 16px; font-size: 1rem; font-weight: 700; }
|
||||
|
||||
.export-page__options { display: flex; flex-direction: column; gap: 4px; }
|
||||
.export-page__radio {
|
||||
display: flex; align-items: flex-start; gap: 10px; padding: 8px 4px;
|
||||
cursor: pointer; font-size: 0.875rem;
|
||||
}
|
||||
.export-page__radio input { margin-top: 3px; accent-color: var(--aurora-accent); }
|
||||
.export-page__radio p { margin: 4px 0 0; font-size: 0.8125rem; color: var(--aurora-text-muted); font-weight: 400; }
|
||||
|
||||
.export-page__filters {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
|
||||
margin: 0 0 8px 30px; padding: 12px; background: var(--aurora-bg-subtle);
|
||||
border-radius: var(--aurora-radius-md);
|
||||
}
|
||||
.export-page__filters label {
|
||||
display: flex; flex-direction: column; gap: 4px; font-size: 0.75rem;
|
||||
color: var(--aurora-text-muted); font-weight: 600;
|
||||
}
|
||||
.export-page__filters select, .export-page__filters input[type='date'] {
|
||||
padding: 6px 8px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-card-bg); color: var(--aurora-text); font-size: 0.8125rem; font-weight: 400;
|
||||
}
|
||||
|
||||
.export-page__submit {
|
||||
margin-top: 20px; border: none; border-radius: var(--aurora-radius-sm); padding: 10px 20px;
|
||||
font-size: 0.875rem; font-weight: 600; cursor: pointer; background: var(--aurora-accent); color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup>
|
||||
import { reactive } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const api = (path, options = {}) =>
|
||||
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
}).then(async (r) => {
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.message || 'Request failed');
|
||||
return data;
|
||||
});
|
||||
|
||||
// Local, mutable copy so a row's status/action can update in place once
|
||||
// installed/activated, without needing a full page reload.
|
||||
const importers = reactive(props.serverData.importers.map((i) => ({ ...i, busy: false, error: '' })));
|
||||
|
||||
const goToImporter = (importer) => {
|
||||
window.location.href = importer.runUrl;
|
||||
};
|
||||
|
||||
const activate = async (importer) => {
|
||||
importer.busy = true;
|
||||
importer.error = '';
|
||||
try {
|
||||
await api('/plugins/activate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file: importer.pluginFile }),
|
||||
});
|
||||
goToImporter(importer);
|
||||
} catch (e) {
|
||||
importer.error = e.message || 'Could not activate the importer.';
|
||||
} finally {
|
||||
importer.busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
const installAndActivate = async (importer) => {
|
||||
importer.busy = true;
|
||||
importer.error = '';
|
||||
try {
|
||||
// Reuses WordPress core's own admin-ajax "install-plugin" action — the
|
||||
// exact same request native's "Install Now" button triggers — rather
|
||||
// than reimplementing plugin installation.
|
||||
const body = new URLSearchParams({
|
||||
action: 'install-plugin',
|
||||
slug: importer.slug,
|
||||
_ajax_nonce: props.serverData.installNonce,
|
||||
});
|
||||
const res = await fetch(props.serverData.ajaxUrl, { method: 'POST', body });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!data.success) {
|
||||
throw new Error(data.data?.errorMessage || 'Installation failed.');
|
||||
}
|
||||
|
||||
// Find the freshly-installed plugin's file path (rather than assuming a
|
||||
// naming convention) via Aurora's own plugins list, then activate it
|
||||
// through the same route the Plugins screen uses.
|
||||
const list = await api('/plugins');
|
||||
const installed = (Array.isArray(list) ? list : []).find((p) => p.slug === importer.slug);
|
||||
if (!installed) {
|
||||
throw new Error('Installed, but the plugin could not be found afterward.');
|
||||
}
|
||||
await api('/plugins/activate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file: installed.file }),
|
||||
});
|
||||
goToImporter(importer);
|
||||
} catch (e) {
|
||||
importer.error = e.message || 'Could not install the importer.';
|
||||
} finally {
|
||||
importer.busy = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="import-page">
|
||||
<header class="import-page__header">
|
||||
<p class="import-page__eyebrow">Aurora Admin</p>
|
||||
<h1>Import</h1>
|
||||
<p class="import-page__lead">
|
||||
If you have posts or comments in another system, WordPress can import those into this
|
||||
site. Choose a system to import from below.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="import-page__list">
|
||||
<article v-for="imp in importers" :key="imp.id" class="import-card">
|
||||
<div class="import-card__info">
|
||||
<h3>{{ imp.name }}</h3>
|
||||
<p>{{ imp.description }}</p>
|
||||
<p v-if="imp.error" class="import-card__error">{{ imp.error }}</p>
|
||||
</div>
|
||||
<div class="import-card__action">
|
||||
<a v-if="imp.status === 'active'" class="import-card__btn" @click.prevent="goToImporter(imp)" href="#">
|
||||
Run Importer
|
||||
</a>
|
||||
<button
|
||||
v-else-if="imp.status === 'installed_inactive'"
|
||||
type="button"
|
||||
class="import-card__btn"
|
||||
:disabled="imp.busy"
|
||||
@click="activate(imp)"
|
||||
>
|
||||
{{ imp.busy ? 'Activating…' : 'Run Importer' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="import-card__btn"
|
||||
:disabled="imp.busy"
|
||||
@click="installAndActivate(imp)"
|
||||
>
|
||||
{{ imp.busy ? 'Installing…' : 'Install Now' }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p v-if="!importers.length" class="import-page__empty">No importers are available.</p>
|
||||
</div>
|
||||
|
||||
<p v-if="serverData.canInstallPlugins" class="import-page__more">
|
||||
If the importer you need is not listed,
|
||||
<a :href="serverData.pluginSearchUrl">search the plugin directory</a>
|
||||
to see if an importer is available.
|
||||
</p>
|
||||
|
||||
<!-- Rendered from WordPress's own `import_filters` hook — whatever other
|
||||
active plugins add there. Not Aurora-authored content, only
|
||||
Aurora-styled, so nothing another plugin adds here disappears. -->
|
||||
<div v-if="serverData.nativeFiltersHtml" class="import-page__native" v-html="serverData.nativeFiltersHtml" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.import-page { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
|
||||
.import-page__eyebrow {
|
||||
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
|
||||
font-weight: 800; text-transform: uppercase;
|
||||
}
|
||||
.import-page__header h1 { margin: 0; font-size: 1.45rem; line-height: 1.15; }
|
||||
.import-page__lead {
|
||||
margin: 12px 0 0; max-width: 640px; color: var(--aurora-text-muted);
|
||||
font-size: 0.875rem; line-height: 1.6;
|
||||
}
|
||||
|
||||
.import-page__list { margin-top: 24px; display: flex; flex-direction: column; gap: 10px; max-width: 720px; }
|
||||
.import-card {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 20px;
|
||||
background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
|
||||
border-radius: var(--aurora-radius-lg); padding: 16px 20px;
|
||||
}
|
||||
.import-card__info h3 { margin: 0 0 4px; font-size: 0.9375rem; font-weight: 700; }
|
||||
.import-card__info p { margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.5; }
|
||||
.import-card__error { color: #e5484d !important; margin-top: 6px !important; }
|
||||
.import-card__action { flex-shrink: 0; }
|
||||
.import-card__btn {
|
||||
display: inline-block; border: none; border-radius: var(--aurora-radius-sm); padding: 8px 16px;
|
||||
font-size: 0.8125rem; font-weight: 600; cursor: pointer; background: var(--aurora-accent);
|
||||
color: #fff; text-decoration: none; white-space: nowrap;
|
||||
}
|
||||
.import-card__btn:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
.import-page__empty { color: var(--aurora-text-muted); font-size: 0.875rem; }
|
||||
.import-page__more { margin-top: 18px; font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||
.import-page__more a { color: var(--aurora-accent); }
|
||||
|
||||
.import-page__native { margin-top: 18px; }
|
||||
</style>
|
||||
@@ -37,10 +37,12 @@ const load = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
load();
|
||||
api('/menus/native-items').then((items) => (nativeItems.value = items));
|
||||
roleOptions.value = props.serverData.roles || [];
|
||||
// The native top-level menu is provided in serverData (captured server-side
|
||||
// at page load); the REST route can't build the admin menu.
|
||||
nativeItems.value = props.serverData.nativeItems || [];
|
||||
});
|
||||
|
||||
const filteredMenus = computed(() => {
|
||||
@@ -55,48 +57,84 @@ const filteredMenus = computed(() => {
|
||||
|
||||
const selected = computed(() => menus.value.find((m) => m.id === selectedId.value) || null);
|
||||
|
||||
const draft = reactive({ name: '', status: 'draft', roles: [], items: [] });
|
||||
const draft = reactive({ name: '', status: 'draft', roles: [] });
|
||||
|
||||
// The full ordered list of top-level items being edited. Row order IS the
|
||||
// menu order (saved as each item's `order` index). Each row carries its
|
||||
// native title/icon plus the per-item overrides (custom label, hidden).
|
||||
const orderedItems = ref([]);
|
||||
|
||||
// Merge a config's saved items with the live native top-level list: saved
|
||||
// items first, in their saved order, then any native items the config
|
||||
// doesn't mention yet (e.g. a newly installed plugin) appended at the end,
|
||||
// so new menu items are never silently dropped.
|
||||
const buildOrdered = (savedItems = []) => {
|
||||
const byId = Object.fromEntries(nativeItems.value.map((n) => [n.native_id, n]));
|
||||
const saved = [...savedItems].sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const s of saved) {
|
||||
const n = byId[s.native_id];
|
||||
if (!n) continue; // saved item no longer exists in the admin menu
|
||||
result.push({ native_id: n.native_id, title: n.title, icon: n.icon, label: s.label || '', hidden: !!s.hidden });
|
||||
seen.add(s.native_id);
|
||||
}
|
||||
for (const n of nativeItems.value) {
|
||||
if (seen.has(n.native_id)) continue;
|
||||
result.push({ native_id: n.native_id, title: n.title, icon: n.icon, label: '', hidden: false });
|
||||
}
|
||||
orderedItems.value = result;
|
||||
};
|
||||
|
||||
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 })),
|
||||
});
|
||||
Object.assign(draft, { name: menu.name, status: menu.status, roles: [...menu.roles] });
|
||||
buildOrdered(menu.items);
|
||||
};
|
||||
|
||||
const isCreating = ref(false);
|
||||
const newMenu = () => {
|
||||
selectedId.value = null;
|
||||
isCreating.value = true;
|
||||
Object.assign(draft, { name: 'New menu', status: 'draft', roles: [], items: [] });
|
||||
Object.assign(draft, { name: 'New menu', status: 'draft', roles: [] });
|
||||
buildOrdered([]);
|
||||
};
|
||||
|
||||
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;
|
||||
// Reorder controls: drag-and-drop plus keyboard-friendly up/down buttons.
|
||||
const dragIndex = ref(-1);
|
||||
const onDragStart = (i) => { dragIndex.value = i; };
|
||||
const onDrop = (i) => {
|
||||
const from = dragIndex.value;
|
||||
dragIndex.value = -1;
|
||||
if (from < 0 || from === i) return;
|
||||
const arr = orderedItems.value;
|
||||
const [moved] = arr.splice(from, 1);
|
||||
arr.splice(i, 0, moved);
|
||||
};
|
||||
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 move = (i, dir) => {
|
||||
const j = i + dir;
|
||||
const arr = orderedItems.value;
|
||||
if (j < 0 || j >= arr.length) return;
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
};
|
||||
|
||||
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 };
|
||||
const payload = {
|
||||
name: draft.name,
|
||||
status: draft.status,
|
||||
roles: draft.roles,
|
||||
// Row order becomes each item's `order`; only send items that actually
|
||||
// override something (renamed or hidden) or that need an explicit order.
|
||||
items: orderedItems.value.map((it, index) => ({
|
||||
native_id: it.native_id,
|
||||
label: it.label || '',
|
||||
hidden: !!it.hidden,
|
||||
order: index,
|
||||
})),
|
||||
};
|
||||
if (isCreating.value) {
|
||||
const created = await api('/menus', { method: 'POST', body: JSON.stringify(payload) });
|
||||
await load();
|
||||
@@ -188,23 +226,29 @@ const toggleRole = (slug) => {
|
||||
</div>
|
||||
|
||||
<div class="menu-editor__section">
|
||||
<h3>Menu items</h3>
|
||||
<h3>Menu items — drag to reorder</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)"
|
||||
/>
|
||||
<div
|
||||
v-for="(it, i) in orderedItems"
|
||||
:key="it.native_id"
|
||||
class="menu-editor__item"
|
||||
:class="{ 'is-hidden': it.hidden }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(i)"
|
||||
@dragover.prevent
|
||||
@drop="onDrop(i)"
|
||||
>
|
||||
<span class="menu-editor__drag dashicons dashicons-move" title="Drag to reorder" />
|
||||
<span class="dashicons" :class="it.icon?.value || 'dashicons-admin-generic'" />
|
||||
<input type="text" v-model="it.label" :placeholder="it.title" />
|
||||
<label class="menu-editor__hide">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="!!itemState(ni.native_id)?.hidden"
|
||||
@change="toggleHidden(ni.native_id)"
|
||||
/>
|
||||
<input type="checkbox" v-model="it.hidden" />
|
||||
Hide
|
||||
</label>
|
||||
<div class="menu-editor__move">
|
||||
<button type="button" :disabled="i === 0" title="Move up" @click="move(i, -1)">▲</button>
|
||||
<button type="button" :disabled="i === orderedItems.length - 1" title="Move down" @click="move(i, 1)">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,11 +287,21 @@ const toggleRole = (slug) => {
|
||||
display: flex; align-items: center; gap: 10px; padding: 6px 4px;
|
||||
border-bottom: 1px solid var(--aurora-frame-border);
|
||||
}
|
||||
.menu-editor__item.is-hidden { opacity: 0.5; }
|
||||
.menu-editor__drag { cursor: grab; color: var(--aurora-text-muted); flex-shrink: 0; }
|
||||
.menu-editor__drag:active { cursor: grabbing; }
|
||||
.menu-editor__item input[type='text'] {
|
||||
flex: 1; padding: 6px 8px; border-radius: var(--aurora-radius-sm); 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__hide { display: flex; align-items: center; gap: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); flex-shrink: 0; }
|
||||
.menu-editor__move { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; }
|
||||
.menu-editor__move button {
|
||||
border: none; background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
|
||||
border-radius: 4px; width: 20px; height: 15px; line-height: 1; font-size: 9px; cursor: pointer; padding: 0;
|
||||
}
|
||||
.menu-editor__move button:disabled { opacity: 0.35; cursor: default; }
|
||||
.menu-editor__move button:not(:disabled):hover { color: var(--aurora-accent); }
|
||||
.menu-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.menu-editor__actions button {
|
||||
border: none; border-radius: var(--aurora-radius-sm); padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup>
|
||||
import { reactive, onMounted } from 'vue';
|
||||
import { companionPlugins } from './companionPlugins.js';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const api = (path, options = {}) =>
|
||||
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
}).then(async (r) => {
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.message || 'Request failed');
|
||||
return data;
|
||||
});
|
||||
|
||||
// Per-plugin UI state, keyed by slug: { status: {installed,active}|null, busy, error }
|
||||
const state = reactive(
|
||||
Object.fromEntries(companionPlugins.map((p) => [p.slug, { status: null, busy: false, error: '' }]))
|
||||
);
|
||||
|
||||
const loadStatus = async (plugin) => {
|
||||
try {
|
||||
state[plugin.slug].status = await api(plugin.statusEndpoint);
|
||||
} catch {
|
||||
state[plugin.slug].status = { installed: false, active: false };
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
companionPlugins.forEach(loadStatus);
|
||||
});
|
||||
|
||||
const install = async (plugin) => {
|
||||
const s = state[plugin.slug];
|
||||
if (s.busy) return;
|
||||
s.busy = true;
|
||||
s.error = '';
|
||||
try {
|
||||
const result = await api(plugin.installEndpoint, { method: 'POST' });
|
||||
if (!result.success) {
|
||||
s.error = result.message || 'Installation failed.';
|
||||
}
|
||||
await loadStatus(plugin);
|
||||
} catch (e) {
|
||||
s.error = e.message || 'Installation failed.';
|
||||
} finally {
|
||||
s.busy = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="modules">
|
||||
<header class="modules__head">
|
||||
<p class="modules__eyebrow">Aurora Admin</p>
|
||||
<h1>Modules</h1>
|
||||
<p class="modules__lead">
|
||||
Install additional modules to extend Aurora Admin. Each is a separate, standalone plugin —
|
||||
install and activate it here with one click.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="modules__grid">
|
||||
<article v-for="p in companionPlugins" :key="p.slug" class="module-card">
|
||||
<div class="module-card__top">
|
||||
<span class="module-card__icon dashicons" :class="p.icon" />
|
||||
<span
|
||||
v-if="state[p.slug].status"
|
||||
class="module-card__badge"
|
||||
:class="{ 'is-active': state[p.slug].status.active }"
|
||||
>
|
||||
{{ state[p.slug].status.active ? 'Active' : state[p.slug].status.installed ? 'Installed' : 'Not installed' }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="module-card__title">{{ p.label }}</h3>
|
||||
<p class="module-card__desc">{{ p.description }}</p>
|
||||
|
||||
<div class="module-card__action">
|
||||
<div v-if="!state[p.slug].status" class="module-card__checking">Checking…</div>
|
||||
<a v-else-if="state[p.slug].status.active" class="module-card__manage" :href="p.manageUrl">
|
||||
Open {{ p.label }} →
|
||||
</a>
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
class="module-card__install"
|
||||
:disabled="state[p.slug].busy"
|
||||
@click="install(p)"
|
||||
>
|
||||
{{ state[p.slug].busy ? 'Installing…' : state[p.slug].status.installed ? 'Activate' : 'Install & Activate' }}
|
||||
</button>
|
||||
<p v-if="state[p.slug].error" class="module-card__error">{{ state[p.slug].error }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modules { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
|
||||
.modules__eyebrow {
|
||||
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
|
||||
font-weight: 800; text-transform: uppercase;
|
||||
}
|
||||
.modules__head h1 { margin: 0; font-size: 1.45rem; line-height: 1.15; }
|
||||
.modules__lead { margin: 8px 0 0; color: var(--aurora-text-muted); font-size: 0.9rem; max-width: 640px; line-height: 1.6; }
|
||||
.modules__grid {
|
||||
margin-top: 24px; display: grid; gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
.module-card {
|
||||
background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
|
||||
border-radius: var(--aurora-radius-lg); padding: 20px 22px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.module-card__top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.module-card__icon { font-size: 26px; width: 26px; height: 26px; color: var(--aurora-accent); }
|
||||
.module-card__badge {
|
||||
font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em;
|
||||
color: var(--aurora-text-muted); background: var(--aurora-bg-subtle);
|
||||
border-radius: 999px; padding: 3px 10px;
|
||||
}
|
||||
.module-card__badge.is-active { color: var(--aurora-accent); }
|
||||
.module-card__title { margin: 4px 0 0; font-size: 1rem; font-weight: 700; }
|
||||
.module-card__desc { margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.55; flex: 1; }
|
||||
.module-card__action { margin-top: 6px; }
|
||||
.module-card__checking { font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||
.module-card__manage { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-accent); text-decoration: none; }
|
||||
.module-card__manage:hover { text-decoration: underline; }
|
||||
.module-card__install {
|
||||
border: none; border-radius: var(--aurora-radius-sm); padding: 9px 16px; font-size: 0.8125rem;
|
||||
font-weight: 600; cursor: pointer; background: var(--aurora-accent); color: #fff;
|
||||
}
|
||||
.module-card__install:disabled { opacity: 0.6; cursor: default; }
|
||||
.module-card__error { margin: 8px 0 0; font-size: 0.8125rem; color: #e5484d; }
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Aurora's standalone companion plugins, installable from the Modules page.
|
||||
* Single source of truth for both the card metadata and the REST endpoints
|
||||
* that back them (admin/src/Rest/CompanionPluginData.php) — a new companion
|
||||
* plugin only needs an entry here plus a matching PHP-side entry, no other
|
||||
* wiring.
|
||||
*/
|
||||
export const companionPlugins = [
|
||||
{
|
||||
slug: 'file-manager',
|
||||
icon: 'dashicons-portfolio',
|
||||
label: 'Aurora File Manager',
|
||||
description:
|
||||
'Browse, edit, and manage your site’s files (themes, plugins, uploads, and more) directly from wp-admin.',
|
||||
statusEndpoint: '/companion-plugins/file-manager/status',
|
||||
installEndpoint: '/companion-plugins/file-manager/install',
|
||||
manageUrl: 'admin.php?page=aurora-file-manager',
|
||||
},
|
||||
{
|
||||
slug: 'database-explorer',
|
||||
icon: 'dashicons-editor-table',
|
||||
label: 'Aurora Database Explorer',
|
||||
description:
|
||||
'A Supabase-style database browser inside wp-admin — see every table with row counts and sizes, browse paginated data, and run your own read-only queries.',
|
||||
statusEndpoint: '/companion-plugins/database-explorer/status',
|
||||
installEndpoint: '/companion-plugins/database-explorer/install',
|
||||
manageUrl: 'admin.php?page=aurora-database-explorer',
|
||||
},
|
||||
{
|
||||
slug: 'site-backup',
|
||||
icon: 'dashicons-migrate',
|
||||
label: 'Aurora Site Backup',
|
||||
description:
|
||||
'Full site backup (database + files) and restore-in-place, on demand or on a recurring schedule, stored locally or on your own S3-compatible or Google Drive remote storage.',
|
||||
statusEndpoint: '/companion-plugins/site-backup/status',
|
||||
installEndpoint: '/companion-plugins/site-backup/install',
|
||||
manageUrl: 'admin.php?page=aurora-site-backup',
|
||||
},
|
||||
];
|
||||
@@ -81,6 +81,7 @@ const runAction = async (action, plugin) => {
|
||||
busyFile.value = plugin.file;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
let redirecting = false;
|
||||
try {
|
||||
const data = await request(`/plugins/${action}`, {
|
||||
method: action === 'delete' ? 'DELETE' : 'POST',
|
||||
@@ -88,14 +89,21 @@ const runAction = async (action, plugin) => {
|
||||
});
|
||||
notice.value = data.message || 'Done';
|
||||
if (data.redirect) {
|
||||
redirecting = true;
|
||||
window.location.href = data.redirect;
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Action failed';
|
||||
} finally {
|
||||
busyFile.value = '';
|
||||
// Always re-sync the list with the real backend state, even after an
|
||||
// error: activate_plugin() can return a WP_Error for benign "unexpected
|
||||
// output" from a plugin's own activation while still leaving the plugin
|
||||
// active, which previously left the row showing its stale pre-click
|
||||
// state (you clicked Activate but it still read "deactivated"). Skipped
|
||||
// only when navigating away (deactivating Aurora itself redirects).
|
||||
if (!redirecting) await load();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,22 +10,6 @@ const props = defineProps({
|
||||
|
||||
const visibleCategories = settingsCategories;
|
||||
|
||||
// Shared REST fetcher for field types that need their own API calls beyond
|
||||
// the generic wp/v2/settings save flow (currently just 'companion-plugin').
|
||||
const api = (path, options = {}) =>
|
||||
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
}).then(async (r) => {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body.message || 'Request failed');
|
||||
return body;
|
||||
});
|
||||
|
||||
// Working copy of the settings, seeded from the server + field defaults.
|
||||
const settings = reactive({ ...(props.serverData.settings || {}) });
|
||||
visibleCategories.forEach((cat) =>
|
||||
@@ -264,7 +248,6 @@ const importSettings = (e) => {
|
||||
:field="f"
|
||||
:options="optionsFor(f)"
|
||||
:disabled="!!f.dependsOn && !settings[f.dependsOn]"
|
||||
:api="api"
|
||||
v-model="settings[f.key]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,6 @@ const props = defineProps({
|
||||
// True when this field's `dependsOn` key is falsy — greys out the
|
||||
// control without hiding it, so the relationship stays visible.
|
||||
disabled: { type: Boolean, default: false },
|
||||
// Shared REST fetcher, only used by field types that need their own API
|
||||
// calls beyond the generic wp/v2/settings save flow (companion-plugin).
|
||||
api: { type: Function, default: null },
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
@@ -76,43 +73,14 @@ const pickFont = (name) => {
|
||||
fontOpen.value = false;
|
||||
};
|
||||
const openFontList = () => {
|
||||
fontQuery.value = props.modelValue || '';
|
||||
// Start with an empty query so the whole font list is shown on focus.
|
||||
// Seeding it with the current font filtered the list down to just that
|
||||
// one font, making it impossible to pick a different one without first
|
||||
// clearing the box. The active font stays highlighted via `val === name`.
|
||||
fontQuery.value = '';
|
||||
fontOpen.value = true;
|
||||
};
|
||||
|
||||
// Companion plugin (Aurora File Manager) install card -----------------------
|
||||
const companionStatus = ref(null); // { installed, active } | null while loading
|
||||
const companionBusy = ref(false);
|
||||
const companionError = ref('');
|
||||
|
||||
const loadCompanionStatus = async () => {
|
||||
if (props.field.type !== 'companion-plugin' || !props.api) return;
|
||||
try {
|
||||
companionStatus.value = await props.api(props.field.statusEndpoint);
|
||||
} catch {
|
||||
companionStatus.value = { installed: false, active: false };
|
||||
}
|
||||
};
|
||||
if (props.field.type === 'companion-plugin') {
|
||||
loadCompanionStatus();
|
||||
}
|
||||
|
||||
const installCompanion = async () => {
|
||||
if (!props.api) return;
|
||||
companionBusy.value = true;
|
||||
companionError.value = '';
|
||||
try {
|
||||
const result = await props.api(props.field.installEndpoint, { method: 'POST' });
|
||||
if (!result.success) {
|
||||
companionError.value = result.message || 'Installation failed.';
|
||||
}
|
||||
await loadCompanionStatus();
|
||||
} catch (e) {
|
||||
companionError.value = e.message || 'Installation failed.';
|
||||
} finally {
|
||||
companionBusy.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -273,31 +241,6 @@ const installCompanion = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Companion plugin install card -->
|
||||
<div v-else-if="field.type === 'companion-plugin'" class="field__companion">
|
||||
<div v-if="!companionStatus" class="field__companion-status">Checking…</div>
|
||||
<template v-else>
|
||||
<a
|
||||
v-if="companionStatus.active"
|
||||
class="field__companion-manage"
|
||||
:href="field.manageUrl"
|
||||
>
|
||||
Installed & active — open File Manager →
|
||||
</a>
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
class="field__companion-install"
|
||||
:disabled="companionBusy"
|
||||
@click="installCompanion"
|
||||
>
|
||||
{{ companionBusy ? 'Installing…' : (companionStatus.installed ? 'Activate' : 'Install & Activate') }}
|
||||
</button>
|
||||
<p v-if="companionError" class="field__companion-error">{{ companionError }}</p>
|
||||
</template>
|
||||
</template>
|
||||
</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">
|
||||
@@ -460,20 +403,6 @@ const installCompanion = async () => {
|
||||
.field__font-item:hover { background: var(--aurora-bg-subtle); }
|
||||
.field__font-item--active { color: var(--aurora-accent); }
|
||||
|
||||
/* Companion plugin install card */
|
||||
.field__companion-status { font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||
.field__companion-install {
|
||||
border: none; border-radius: var(--aurora-radius-sm); background: var(--aurora-accent); color: #fff;
|
||||
padding: 8px 16px; font-size: 0.8125rem; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.field__companion-install:disabled { opacity: 0.6; cursor: default; }
|
||||
.field__companion-manage {
|
||||
display: inline-block; font-size: 0.8125rem; font-weight: 600;
|
||||
color: var(--aurora-accent); text-decoration: none;
|
||||
}
|
||||
.field__companion-manage:hover { text-decoration: underline; }
|
||||
.field__companion-error { margin: 8px 0 0; font-size: 0.8125rem; color: #e5484d; }
|
||||
|
||||
@media (max-width: 782px) {
|
||||
.field { grid-template-columns: 1fr; gap: 12px; }
|
||||
}
|
||||
|
||||
@@ -99,7 +99,9 @@ export const settingsGroups = [
|
||||
key: 'font_family',
|
||||
type: 'font-picker',
|
||||
label: 'Font family',
|
||||
description: 'Google Font used across the Aurora interface.',
|
||||
description:
|
||||
'Optional. Leave empty to use your system font (no external requests). ' +
|
||||
'Choosing a font loads it from Google Fonts (fonts.googleapis.com) on each admin page.',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -195,6 +197,46 @@ export const settingsGroups = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'login-redirect',
|
||||
title: 'Login Redirect',
|
||||
subtitle: 'Control where visitors and users land, before and after login',
|
||||
icon: 'dashicons-redo',
|
||||
fields: [
|
||||
{
|
||||
key: 'login_redirect_enabled',
|
||||
type: 'toggle',
|
||||
label: 'Enable login redirects',
|
||||
description:
|
||||
'Turns on the redirects below. This never changes, hides, or blocks your login page — wp-login.php always stays reachable at its normal address, so this can’t lock you out of logging in.',
|
||||
},
|
||||
{
|
||||
key: 'redirect_unauthenticated_url',
|
||||
type: 'text',
|
||||
label: 'Redirect logged-out wp-admin visitors',
|
||||
description:
|
||||
'Send visitors who try to access wp-admin without being logged in to this URL instead of the login screen. Leave empty to keep the normal WordPress behavior.',
|
||||
placeholder: 'https://example.com/',
|
||||
dependsOn: 'login_redirect_enabled',
|
||||
},
|
||||
{
|
||||
key: 'redirect_roles',
|
||||
type: 'multiselect',
|
||||
label: 'Redirect these roles after login',
|
||||
description: 'Users with these roles are sent to the URL below after logging in, instead of the dashboard.',
|
||||
optionsSource: 'roles',
|
||||
dependsOn: 'login_redirect_enabled',
|
||||
},
|
||||
{
|
||||
key: 'redirect_after_login_url',
|
||||
type: 'text',
|
||||
label: 'Redirect to',
|
||||
description: 'Where the roles above land after logging in.',
|
||||
placeholder: 'https://example.com/welcome',
|
||||
dependsOn: 'login_redirect_enabled',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'menu',
|
||||
title: 'Menu',
|
||||
@@ -281,6 +323,22 @@ export const settingsGroups = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'editor',
|
||||
title: 'Editor',
|
||||
subtitle: 'Choose the editor used for posts and pages',
|
||||
icon: 'dashicons-edit',
|
||||
fields: [
|
||||
{
|
||||
key: 'disable_gutenberg',
|
||||
type: 'toggle',
|
||||
label: 'Disable Gutenberg',
|
||||
description:
|
||||
'Use WordPress’s classic editor instead of the block editor for all post types. Off by default.',
|
||||
default: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'admin-takeover',
|
||||
title: 'Admin Takeover',
|
||||
@@ -493,51 +551,6 @@ export const settingsGroups = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'add-ons',
|
||||
title: 'Add-ons',
|
||||
icon: 'dashicons-admin-plugins',
|
||||
categories: [
|
||||
{
|
||||
id: 'add-ons',
|
||||
title: 'Add-ons',
|
||||
subtitle: 'Standalone companion plugins',
|
||||
icon: 'dashicons-admin-plugins',
|
||||
fields: [
|
||||
{
|
||||
key: 'file_manager_addon',
|
||||
type: 'companion-plugin',
|
||||
label: 'Aurora File Manager',
|
||||
description:
|
||||
'Browse, edit, and manage your site’s files (themes, plugins, uploads, and more) directly from wp-admin. Distributed as a separate plugin rather than bundled here, since WordPress.org’s Plugin Directory policy doesn’t allow this category of functionality in a directory-listed plugin — restrictions or not.',
|
||||
statusEndpoint: '/companion-plugins/file-manager/status',
|
||||
installEndpoint: '/companion-plugins/file-manager/install',
|
||||
manageUrl: 'admin.php?page=aurora-file-manager',
|
||||
},
|
||||
{
|
||||
key: 'database_explorer_addon',
|
||||
type: 'companion-plugin',
|
||||
label: 'Aurora Database Explorer',
|
||||
description:
|
||||
'A Supabase-style database browser inside wp-admin — see every table with row counts and sizes, browse paginated data, and run your own read-only SELECT/SHOW/DESCRIBE/EXPLAIN queries. Distributed as a separate plugin rather than bundled here, since WordPress.org’s Plugin Directory review treats direct database-query tooling the same way it treats file-management functionality.',
|
||||
statusEndpoint: '/companion-plugins/database-explorer/status',
|
||||
installEndpoint: '/companion-plugins/database-explorer/install',
|
||||
manageUrl: 'admin.php?page=aurora-database-explorer',
|
||||
},
|
||||
{
|
||||
key: 'site_backup_addon',
|
||||
type: 'companion-plugin',
|
||||
label: 'Aurora Site Backup',
|
||||
description:
|
||||
'Full site backup (database + files) and restore-in-place, on demand or on a recurring schedule, stored locally or on your own S3-compatible or Google Drive remote storage. Distributed as a separate plugin rather than bundled here, to keep Aurora Admin’s own footprint away from arbitrary file-write functionality.',
|
||||
statusEndpoint: '/companion-plugins/site-backup/status',
|
||||
installEndpoint: '/companion-plugins/site-backup/install',
|
||||
manageUrl: 'admin.php?page=aurora-site-backup',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,7 +31,9 @@ const steps = [
|
||||
key: 'font_family',
|
||||
type: 'font-picker',
|
||||
label: 'Font family',
|
||||
description: 'A Google Font used across the Aurora interface.',
|
||||
description:
|
||||
'Optional. Leave empty for your system font (no external requests). ' +
|
||||
'Choosing a font loads it from Google Fonts.',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -207,7 +209,6 @@ const finish = async (target) => {
|
||||
v-for="f in current.fields"
|
||||
:key="f.key"
|
||||
:field="f"
|
||||
:api="null"
|
||||
v-model="settings[f.key]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="tools-page">
|
||||
<header class="tools-page__header">
|
||||
<p class="tools-page__eyebrow">Aurora Admin</p>
|
||||
<h1>Tools</h1>
|
||||
</header>
|
||||
|
||||
<div class="tools-page__grid">
|
||||
<!-- Core's Categories and Tags Converter card — not produced by a
|
||||
hook, so it's reproduced here directly (same visibility gate as
|
||||
native tools.php: the `import` capability plus category/tag
|
||||
manage_terms). -->
|
||||
<article v-if="serverData.showCategoriesConverter" class="card">
|
||||
<h2 class="title">Categories and Tags Converter</h2>
|
||||
<p>
|
||||
If you want to convert your categories to tags (or vice versa), use the
|
||||
<a :href="serverData.importUrl">Categories and Tags Converter</a> available from the Import screen.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<!-- Rendered from WordPress's own `tool_box` hook — whatever other
|
||||
active plugins add there (WooCommerce, migration tools, etc.).
|
||||
Not Aurora-authored content, only Aurora-styled, so nothing
|
||||
another plugin adds to this screen silently disappears. -->
|
||||
<div v-if="serverData.nativeToolsHtml" class="tools-page__native" v-html="serverData.nativeToolsHtml" />
|
||||
</div>
|
||||
<p v-if="!serverData.showCategoriesConverter && !serverData.nativeToolsHtml" class="tools-page__empty">
|
||||
No tools are available right now.
|
||||
</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tools-page { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
|
||||
.tools-page__eyebrow {
|
||||
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
|
||||
font-weight: 800; text-transform: uppercase;
|
||||
}
|
||||
.tools-page__header h1 { margin: 0 0 24px; font-size: 1.45rem; line-height: 1.15; }
|
||||
.tools-page__empty { color: var(--aurora-text-muted); font-size: 0.875rem; }
|
||||
|
||||
.tools-page__grid {
|
||||
display: grid; gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
/* The v-html'd wrapper participates as if it weren't there, so cards inside
|
||||
it (there may be several, one per plugin hooked to tool_box) lay out in
|
||||
the same grid as Aurora's own card above instead of stacking inside one
|
||||
grid cell. */
|
||||
.tools-page__native { display: contents; }
|
||||
|
||||
/* Native tool_box markup is `.card > h2.title + p` (core's own Categories &
|
||||
Tags Converter shape), but other plugins' cards may not match exactly —
|
||||
styled broadly by element, with .card/.title as the primary targets. The
|
||||
plain selectors below cover Aurora's own card (real template markup, gets
|
||||
Vue's scoping attribute automatically); :deep() additionally reaches into
|
||||
the v-html'd content, which the scoping attribute can't be applied to. */
|
||||
.card, .tools-page__native :deep(.card) {
|
||||
background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
|
||||
border-radius: var(--aurora-radius-lg); padding: 20px 22px; margin: 0;
|
||||
}
|
||||
.card h2, .card h3, .card .title, .tools-page__native :deep(h2), .tools-page__native :deep(h3), .tools-page__native :deep(.title) {
|
||||
margin: 0 0 10px; font-size: 1rem; font-weight: 700; color: var(--aurora-text);
|
||||
}
|
||||
.card p, .tools-page__native :deep(p) {
|
||||
margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.6;
|
||||
}
|
||||
.card a, .tools-page__native :deep(a) { color: var(--aurora-accent); text-decoration: none; }
|
||||
.card a:hover, .tools-page__native :deep(a:hover) { text-decoration: underline; }
|
||||
</style>
|
||||
@@ -16,6 +16,18 @@ const highlighted = ref(0);
|
||||
let debounceTimer;
|
||||
let requestSeq = 0;
|
||||
|
||||
// wp/v2/search returns titles with HTML entities (e.g. "Ben & Jerry").
|
||||
// Decode them for display WITHOUT rendering markup: a <textarea>'s innerHTML
|
||||
// is RCDATA, so tags stay literal text and only entities are decoded — then
|
||||
// we read them back as a plain string and render with text interpolation.
|
||||
// This replaces a v-html bind on item.title, which would have executed any
|
||||
// HTML an unfiltered_html user put in a post title (admin→admin XSS).
|
||||
const decodeEntities = (str) => {
|
||||
const el = document.createElement('textarea');
|
||||
el.innerHTML = String(str ?? '');
|
||||
return el.value;
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
const q = query.value.trim();
|
||||
if (q.length < 2) {
|
||||
@@ -121,7 +133,7 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown));
|
||||
@mouseenter="highlighted = i"
|
||||
@click="openResult(item)"
|
||||
>
|
||||
<span class="smodal__result-title" v-html="item.title" />
|
||||
<span class="smodal__result-title">{{ decodeEntities(item.title) }}</span>
|
||||
<span class="smodal__result-type">{{ item.subtype }}</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -195,7 +195,11 @@ html.aurora-ready #wpbody-content {
|
||||
color: var(--aurora-text);
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: var(--aurora-radius-xl);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
/* A real soft shadow (not the old barely-there 0.06 one) so the rounded
|
||||
card edge reads against dense, full-bleed page content — otherwise the
|
||||
frame is technically rounded but visually invisible except on sparse
|
||||
pages. Follows the border-radius, defining the card shape. */
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
|
||||
margin: 16px var(--aurora-gutter-r) 24px 24px;
|
||||
padding: 32px;
|
||||
min-height: calc(100vh - var(--aurora-toolbar-h) - 40px);
|
||||
@@ -323,7 +327,10 @@ html.aurora-ready .theme-install-overlay .wp-full-overlay-main {
|
||||
background: var(--aurora-frame-bg);
|
||||
border: 1px solid var(--aurora-frame-border);
|
||||
border-radius: var(--aurora-radius-xl);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
/* Match #wpbody-content's shadow so the dashboard's rounded card edge
|
||||
reads the same way — the dashboard is dense and full-bleed, so the old
|
||||
0.06 shadow left it looking un-rounded. */
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
|
||||
margin: 16px var(--aurora-gutter-r) 24px 24px;
|
||||
box-sizing: border-box;
|
||||
/* Same unconditional height cap as #wpbody-content's base rule — this
|
||||
@@ -335,8 +342,15 @@ html.aurora-ready .theme-install-overlay .wp-full-overlay-main {
|
||||
no document
|
||||
scroll left to fall back on either. */
|
||||
max-height: calc(100vh - var(--aurora-toolbar-h) - 40px);
|
||||
overflow-y: auto;
|
||||
overflow-anchor: none;
|
||||
/* The frame itself clips (rounded corners) and lays out as a column; the
|
||||
actual scrolling happens on the inner app-root (see the shared scroll
|
||||
rule below), so the native scrollbar lives INSIDE the rounded card
|
||||
rather than squaring off its right corners. The 12px padding is the
|
||||
gutter that keeps the scrollbar clear of the corner radius. */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Custom admin notices (Notices::render_for_dashboard(), reparented in
|
||||
@@ -366,6 +380,75 @@ html.aurora-ready body.toplevel_page_aurora-admin #wpbody-content {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* --- Scroll INSIDE the rounded frame -------------------------------------
|
||||
A single element can't both scroll and keep its own rounded corners: the
|
||||
native scrollbar sits in the top/bottom-right corners and squares them
|
||||
off. So the frame clips (overflow:hidden + border-radius) and its Aurora
|
||||
app-root child becomes the real scroll container — the scrollbar then
|
||||
lives inside the rounded card, inset by the frame's 12px padding so it
|
||||
clears the corner radius.
|
||||
|
||||
`:has()` limits the #wpbody-content half to Aurora's own hijacked pages
|
||||
(their mount div is a direct #aurora-admin-*-root child); native WP pages
|
||||
keep their normal single-layer 32px-padded scroll, untouched. Content
|
||||
padding is split 12px (frame) + 20px (scroller) so it still totals the
|
||||
original 32px — pages that assume that (e.g. AppListShell's -32px bleed)
|
||||
keep working. */
|
||||
/* `body:not(.toplevel_page_aurora-admin)` excludes the Settings page — it
|
||||
deliberately drops the frame and manages its own -32px/scroll-as-a-whole
|
||||
layout (see Settings.vue), so the two-layer treatment must not touch it. */
|
||||
html.aurora-ready body:not(.toplevel_page_aurora-admin) #wpbody-content:has(> [id^="aurora-admin-"][id$="-root"]) {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
}
|
||||
.aurora-dashboard-frame > [id^="aurora-admin-"][id$="-root"],
|
||||
html.aurora-ready body:not(.toplevel_page_aurora-admin) #wpbody-content > [id^="aurora-admin-"][id$="-root"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
/* The hijacked/list pages need their content padding on the scroller (the
|
||||
dashboard's own content already pads itself). */
|
||||
html.aurora-ready body:not(.toplevel_page_aurora-admin) #wpbody-content > [id^="aurora-admin-"][id$="-root"] {
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* --- Themed scrollbars -----------------------------------------------------
|
||||
Skin every scrollbar inside the Aurora shell in a translucent shade of the
|
||||
active theme's accent (derived from --aurora-accent, so it tracks whatever
|
||||
preset is selected) instead of the OS default. The plain accent-soft token
|
||||
is a fallback for engines without color-mix; the 3px transparent border +
|
||||
background-clip:padding-box keeps the thumb slim with a little breathing
|
||||
room around it. */
|
||||
html.aurora-ready {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--aurora-accent-soft) transparent;
|
||||
scrollbar-color: color-mix(in srgb, var(--aurora-accent) 55%, transparent) transparent;
|
||||
}
|
||||
html.aurora-ready ::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
html.aurora-ready ::-webkit-scrollbar-track,
|
||||
html.aurora-ready ::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
html.aurora-ready ::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
background-color: var(--aurora-accent-soft);
|
||||
background-color: color-mix(in srgb, var(--aurora-accent) 45%, transparent);
|
||||
}
|
||||
html.aurora-ready ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--aurora-accent);
|
||||
background-color: color-mix(in srgb, var(--aurora-accent) 72%, transparent);
|
||||
}
|
||||
|
||||
/*
|
||||
* Native WordPress pages are fully dark-themed by a dedicated stylesheet
|
||||
* (admin/assets/native-dark.css), enqueued only on native pages and gated
|
||||
|
||||
@@ -36,6 +36,7 @@ export default defineConfig({
|
||||
settings: fileURLToPath(new URL('./src/entries/settings.js', import.meta.url)),
|
||||
'setup-wizard': fileURLToPath(new URL('./src/entries/setup-wizard.js', import.meta.url)),
|
||||
'menu-creator': fileURLToPath(new URL('./src/entries/menu-creator.js', import.meta.url)),
|
||||
modules: fileURLToPath(new URL('./src/entries/modules.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)),
|
||||
@@ -51,6 +52,9 @@ export default defineConfig({
|
||||
'nav-menus': fileURLToPath(new URL('./src/entries/nav-menus.js', import.meta.url)),
|
||||
'themes': fileURLToPath(new URL('./src/entries/themes.js', import.meta.url)),
|
||||
'widgets': fileURLToPath(new URL('./src/entries/widgets.js', import.meta.url)),
|
||||
'tools': fileURLToPath(new URL('./src/entries/tools.js', import.meta.url)),
|
||||
'export': fileURLToPath(new URL('./src/entries/export.js', import.meta.url)),
|
||||
'import': fileURLToPath(new URL('./src/entries/import.js', import.meta.url)),
|
||||
'profile': fileURLToPath(new URL('./src/entries/profile.js', import.meta.url)),
|
||||
'bug-report': fileURLToPath(new URL('./src/entries/bug-report.js', import.meta.url)),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user