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:
NixReaper
2026-07-26 13:37:35 -05:00
co-authored by Claude Sonnet 5
parent faf9f35992
commit 24be114f9a
39 changed files with 3462 additions and 1653 deletions
+4 -1
View File
@@ -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
+252 -81
View File
@@ -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>
+222
View File
@@ -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>
+178
View File
@@ -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>
+94 -40
View File
@@ -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;
+144
View File
@@ -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>
+39
View File
@@ -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',
},
];
+9 -1
View File
@@ -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();
}
};
-17
View File
@@ -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 &amp; 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; }
}
+59 -46
View File
@@ -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',
},
],
},
],
},
];
/**
+3 -2
View File
@@ -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>
+76
View File
@@ -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>