v1.0.0 — Initial public release of Aurora Admin

This commit is contained in:
NixReaper
2026-07-26 16:39:17 -05:00
commit f50159d45a
151 changed files with 24139 additions and 0 deletions
+1363
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "aurora-admin-app",
"version": "3.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"vue": "^3.5.26"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7"
}
}
+60
View File
@@ -0,0 +1,60 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
// 0-100. When null, renders an indeterminate/pulsing bar instead of a
// determinate fill — used for phases with no cheap way to compute a
// percentage (e.g. "building the file list" before zipping starts).
percent: { type: Number, default: null },
label: { type: String, default: '' },
});
const clamped = computed(() => (props.percent === null ? null : Math.max(0, Math.min(100, props.percent))));
</script>
<template>
<div class="progress-bar">
<div v-if="label" class="progress-bar__label">{{ label }}</div>
<div class="progress-bar__track" :class="{ 'progress-bar__track--indeterminate': clamped === null }">
<div
v-if="clamped !== null"
class="progress-bar__fill"
:style="{ width: clamped + '%' }"
/>
</div>
</div>
</template>
<style scoped>
.progress-bar__label {
font-size: 0.8125rem;
color: var(--aurora-text-muted);
margin-bottom: 6px;
}
.progress-bar__track {
height: 8px;
border-radius: 999px;
background: var(--aurora-bg-subtle);
overflow: hidden;
position: relative;
}
.progress-bar__fill {
height: 100%;
border-radius: 999px;
background: var(--aurora-accent);
transition: width 0.3s ease;
}
.progress-bar__track--indeterminate::before {
content: '';
position: absolute;
top: 0; bottom: 0;
width: 40%;
border-radius: 999px;
background: var(--aurora-accent);
animation: progress-bar-indeterminate 1.2s ease-in-out infinite;
}
@keyframes progress-bar-indeterminate {
0% { left: -40%; }
100% { left: 100%; }
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<script setup>
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
label: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
});
defineEmits(['update:modelValue']);
</script>
<template>
<label class="toggle" :class="{ 'toggle--disabled': disabled }">
<span v-if="label" class="toggle__label">{{ label }}</span>
<button
type="button"
class="toggle__switch"
:class="{ 'toggle__switch--on': modelValue }"
role="switch"
:aria-checked="modelValue"
:disabled="disabled"
@click="!disabled && $emit('update:modelValue', !modelValue)"
>
<span class="toggle__thumb" />
</button>
</label>
</template>
<style scoped>
.toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
cursor: pointer;
}
.toggle__label {
font-size: 0.875rem;
}
.toggle__switch {
position: relative;
width: 2.5rem;
height: 1.4rem;
border-radius: 999px;
border: none;
background: var(--aurora-border);
cursor: pointer;
padding: 0;
transition: background-color 0.15s ease;
flex-shrink: 0;
}
.toggle__switch--on {
background: var(--aurora-accent);
}
.toggle--disabled { cursor: default; opacity: 0.55; }
.toggle--disabled .toggle__switch { cursor: default; }
.toggle__thumb {
position: absolute;
top: 0.15rem;
left: 0.15rem;
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: #ffffff;
transition: transform 0.15s ease;
}
.toggle__switch--on .toggle__thumb {
transform: translateX(1.1rem);
}
</style>
+98
View File
@@ -0,0 +1,98 @@
import { ref, onUnmounted } from 'vue';
// Phase weights for a rough overall-progress percentage. Backup and
// restore jobs use different phase sets (see BackupEngine::effective_phases
// on the PHP side) — only the phases relevant to whichever job is active
// contribute, everything else is ignored.
const PHASE_WEIGHTS = {
db_dump: 40,
file_zip: 40,
upload_remote: 20,
download_remote: 20,
db_restore: 50,
file_restore: 50,
};
const PHASE_LABELS = {
pending: 'Starting…',
db_dump: 'Backing up the database…',
file_zip: 'Archiving files…',
upload_remote: 'Uploading to remote storage…',
download_remote: 'Downloading from remote storage…',
db_restore: 'Restoring the database…',
file_restore: 'Restoring files…',
done: 'Done',
failed: 'Failed',
cancelled: 'Cancelled',
};
/**
* Drives a backup/restore job to completion by repeatedly calling the
* REST "step" route — a setTimeout-recursion loop (not setInterval), so a
* slow step can never overlap the next poll for the same job. The
* server-side wp_cron safety net keeps the job moving even if this stops
* polling (e.g. the tab is closed mid-backup) — this composable is purely
* the foreground "watch it happen live" experience, not the only thing
* keeping a job alive.
*/
export function useJobPolling(api) {
const job = ref(null);
const isPolling = ref(false);
const error = ref('');
let timer = null;
let stopped = false;
const percent = () => {
if (!job.value) return null;
const weight = PHASE_WEIGHTS[job.value.phase];
if (!weight) return job.value.status === 'done' ? 100 : null;
// No fine-grained progress within a phase is cheap to compute
// client-side (server tracks byte/row offsets, not percentages) — an
// indeterminate bar per phase, weighted by how much of the whole job
// that phase represents, is a reasonable approximation without a
// second round trip just to compute a number.
return null;
};
const label = () => {
if (!job.value) return '';
if (job.value.status === 'failed') return job.value.error_message || 'Failed';
return PHASE_LABELS[job.value.phase] || PHASE_LABELS[job.value.status] || '';
};
const poll = async (jobId) => {
if (stopped) return;
try {
const res = await api(`/backup/jobs/${jobId}/step`, { method: 'POST' });
job.value = res.job;
} catch (e) {
error.value = e.message || 'Request failed';
isPolling.value = false;
return;
}
if (!job.value || ['done', 'failed', 'cancelled'].includes(job.value.status)) {
isPolling.value = false;
return;
}
timer = setTimeout(() => poll(jobId), 1500);
};
const start = (jobId) => {
stopped = false;
isPolling.value = true;
error.value = '';
poll(jobId);
};
const stop = () => {
stopped = true;
isPolling.value = false;
if (timer) clearTimeout(timer);
};
onUnmounted(stop);
return { job, isPolling, error, start, stop, percent, label };
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import ActivityLog from '@/pages/activity-log/ActivityLog.vue';
const mountEl = document.getElementById('aurora-admin-activity-log-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(ActivityLog, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import AddUser from '@/pages/users/AddUser.vue';
const mountEl = document.getElementById('aurora-admin-add-user-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(AddUser, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import AdminNotices from '@/pages/admin-notices/AdminNotices.vue';
const mountEl = document.getElementById('aurora-admin-admin-notices-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(AdminNotices, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import BugReport from '@/pages/bug-report/BugReport.vue';
const mountEl = document.getElementById('aurora-admin-bug-report-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(BugReport, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Comments from '@/pages/comments/Comments.vue';
const mountEl = document.getElementById('aurora-admin-comments-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Comments, { serverData: data }).mount(mountEl);
}
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue';
// Pulls in the shared --aurora-* theme variables. Harmless if the site-wide
// shell already loaded them (identical values); keeps this page self-sufficient.
import '@/shell/shell-frame.css';
import DashboardPage from '@/pages/dashboard/Dashboard.vue';
const mountEl = document.getElementById('aurora-admin-dashboard-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(DashboardPage, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import 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);
}
+10
View File
@@ -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);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import MediaUpload from '@/pages/media-upload/MediaUpload.vue';
const mountEl = document.getElementById('aurora-admin-media-upload-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(MediaUpload, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Media from '@/pages/media/Media.vue';
const mountEl = document.getElementById('aurora-admin-media-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Media, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import MenuCreator from '@/pages/menu-creator/MenuCreator.vue';
const mountEl = document.getElementById('aurora-admin-menu-creator-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(MenuCreator, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import 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);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import NativeSettings from '@/pages/native-settings/NativeSettings.vue';
const mountEl = document.getElementById('aurora-admin-native-settings-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(NativeSettings, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import NavMenus from '@/pages/nav-menus/NavMenus.vue';
const mountEl = document.getElementById('aurora-admin-nav-menus-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(NavMenus, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Pages from '@/pages/pages/Pages.vue';
const mountEl = document.getElementById('aurora-admin-pages-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Pages, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Plugins from '@/pages/plugins/Plugins.vue';
const mountEl = document.getElementById('aurora-admin-plugins-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Plugins, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Posts from '@/pages/posts/Posts.vue';
const mountEl = document.getElementById('aurora-admin-posts-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Posts, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Profile from '@/pages/profile/Profile.vue';
const mountEl = document.getElementById('aurora-admin-profile-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Profile, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import RoleEditor from '@/pages/role-editor/RoleEditor.vue';
const mountEl = document.getElementById('aurora-admin-role-editor-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(RoleEditor, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import SettingsPage from '@/pages/settings/Settings.vue';
const mountEl = document.getElementById('aurora-admin-settings-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(SettingsPage, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import SetupWizard from '@/pages/setup-wizard/SetupWizard.vue';
const mountEl = document.getElementById('aurora-admin-setup-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(SetupWizard, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import SiteShell from '@/shell/SiteShell.vue';
const mountEl = document.getElementById('aurora-admin-shell-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(SiteShell, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Themes from '@/pages/themes/Themes.vue';
const mountEl = document.getElementById('aurora-admin-themes-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Themes, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -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);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Users from '@/pages/users/Users.vue';
const mountEl = document.getElementById('aurora-admin-users-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Users, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Widgets from '@/pages/widgets/Widgets.vue';
const mountEl = document.getElementById('aurora-admin-widgets-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Widgets, { serverData: data }).mount(mountEl);
}
+86
View File
@@ -0,0 +1,86 @@
<script setup>
defineProps({
icon: { type: String, default: '' },
title: { type: String, required: true },
subtitle: { type: String, default: '' },
active: { type: Boolean, default: false },
});
defineEmits(['click']);
</script>
<template>
<button
type="button"
class="app-list-item"
:class="{ 'app-list-item--active': active }"
@click="$emit('click')"
>
<span v-if="icon" class="app-list-item__icon">
<span class="dashicons" :class="icon" />
</span>
<span class="app-list-item__text">
<span class="app-list-item__title">{{ title }}</span>
<span v-if="subtitle" class="app-list-item__subtitle">{{ subtitle }}</span>
</span>
<slot name="trailing" />
</button>
</template>
<style scoped>
.app-list-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px;
border: none;
background: none;
cursor: pointer;
border-radius: var(--aurora-radius-md);
text-align: left;
}
.app-list-item:hover {
background: var(--aurora-bg-subtle);
}
.app-list-item--active {
background: var(--aurora-accent-soft);
}
.app-list-item__icon {
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: var(--aurora-radius-md);
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
display: flex;
align-items: center;
justify-content: center;
}
.app-list-item--active .app-list-item__icon {
color: var(--aurora-accent);
}
.app-list-item__text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.app-list-item__title {
font-size: 0.875rem;
font-weight: 600;
color: var(--aurora-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-list-item--active .app-list-item__title {
color: var(--aurora-accent);
}
.app-list-item__subtitle {
font-size: 0.75rem;
color: var(--aurora-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+204
View File
@@ -0,0 +1,204 @@
<script setup>
defineProps({
title: { type: String, required: true },
searchPlaceholder: { type: String, default: 'Search…' },
countLabel: { type: String, default: '' },
page: { type: Number, default: 1 },
hasPrev: { type: Boolean, default: false },
hasNext: { type: Boolean, default: false },
// Right-panel placeholder, shown until something is selected.
detailIcon: { type: String, default: 'dashicons-admin-generic' },
detailTitle: { type: String, default: '' },
detailText: { type: String, default: '' },
hasSelection: { type: Boolean, default: false },
});
defineEmits(['prev-page', 'next-page']);
const search = defineModel('search', { type: String, default: '' });
</script>
<template>
<div class="app-list-shell">
<!-- Left: list panel -->
<aside class="app-list-shell__nav">
<div class="app-list-shell__nav-head">
<h1 class="app-list-shell__title">{{ title }}</h1>
<div class="app-list-shell__actions">
<slot name="header-actions" />
</div>
</div>
<div class="app-list-shell__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="text" :placeholder="searchPlaceholder" />
</div>
<div v-if="$slots.filters" class="app-list-shell__filters">
<slot name="filters" />
</div>
<div v-if="countLabel || hasPrev || hasNext" class="app-list-shell__meta">
<span>{{ countLabel }}</span>
<span v-if="hasPrev || hasNext" class="app-list-shell__pager">
Page {{ page }}
<button type="button" :disabled="!hasPrev" @click="$emit('prev-page')">‹</button>
<button type="button" :disabled="!hasNext" @click="$emit('next-page')">›</button>
</span>
</div>
<div class="app-list-shell__list">
<slot name="list" />
</div>
</aside>
<!-- Right: detail panel -->
<section class="app-list-shell__content">
<slot v-if="hasSelection" name="detail" />
<div v-else class="app-list-shell__empty">
<span class="app-list-shell__empty-icon">
<span class="dashicons" :class="detailIcon" />
</span>
<h2 class="app-list-shell__empty-title">{{ detailTitle }}</h2>
<p class="app-list-shell__empty-text">{{ detailText }}</p>
<slot name="detail-empty-actions" />
</div>
</section>
</div>
</template>
<style scoped>
.app-list-shell {
display: flex;
align-items: flex-start;
gap: 20px;
/* A fixed height (not just min-height) so the two panes below can each
scroll independently within it — previously this only set a minimum
and let the whole shell grow with content, meaning both panes shared
one page-level scrollbar. Selecting an item near the bottom of a long
list left the detail panel's newly-loaded content rendered above the
current scroll position, looking like nothing happened until you
scrolled all the way back up. */
height: calc(100vh - var(--aurora-toolbar-h) - 60px);
/* -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
a redundant duplicate layer on top of it. */
}
/* Left — no background/border of its own; the page gradient shows
straight through so it reads as one surface instead of a boxed panel. */
.app-list-shell__nav {
width: 340px;
flex-shrink: 0;
height: 100%;
overflow-y: auto;
border-radius: var(--aurora-radius-lg);
padding: 24px 20px;
box-sizing: border-box;
}
.app-list-shell__nav-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 18px;
}
.app-list-shell__title { font-size: 1.35rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
.app-list-shell__actions { display: flex; gap: 6px; align-items: center; }
.app-list-shell__actions :deep(button) {
display: inline-flex; align-items: center; justify-content: center;
height: 30px; padding: 0 10px; border: none; border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
font-size: 0.75rem; font-weight: 500; cursor: pointer; gap: 6px;
}
.app-list-shell__actions :deep(button.is-primary) {
background: var(--aurora-accent); color: #fff;
}
.app-list-shell__actions :deep(button):hover { opacity: 0.85; }
.app-list-shell__search {
display: flex; align-items: center; gap: 8px;
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md); padding: 8px 12px; margin-bottom: 14px;
color: var(--aurora-text-muted);
}
.app-list-shell__search input {
border: none; background: none; outline: none; color: var(--aurora-text);
font-size: 0.8125rem; width: 100%;
}
.app-list-shell__filters {
display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap;
}
.app-list-shell__filters :deep(button) {
border: none; border-radius: 999px; padding: 5px 12px;
font-size: 0.75rem; font-weight: 500; cursor: pointer;
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
}
.app-list-shell__filters :deep(button.is-active) {
/* --aurora-app-bg can be a gradient (invalid for `color`), so this one
inverted-contrast badge uses the flat fallback token instead. */
background: var(--aurora-text); color: var(--aurora-app-bg-solid);
}
.app-list-shell__meta {
display: flex; align-items: center; justify-content: space-between;
font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.03em;
text-transform: uppercase; color: var(--aurora-text-muted);
margin-bottom: 8px; padding: 0 2px;
}
.app-list-shell__pager { display: flex; align-items: center; gap: 4px; text-transform: none; }
.app-list-shell__pager button {
border: none; background: none; color: var(--aurora-text-muted);
cursor: pointer; font-size: 0.9rem; padding: 0 2px;
}
.app-list-shell__pager button:disabled { opacity: 0.3; cursor: default; }
.app-list-shell__list { display: flex; flex-direction: column; gap: 2px; }
/* Right — no background/border of its own, same reasoning. */
.app-list-shell__content {
flex: 1; min-width: 0;
height: 100%;
overflow-y: auto;
border-radius: var(--aurora-radius-lg);
padding: 28px 32px;
min-height: 480px;
box-sizing: border-box;
}
.app-list-shell__empty {
height: 100%;
min-height: 420px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; gap: 6px;
}
.app-list-shell__empty-icon {
width: 56px; height: 56px; border-radius: var(--aurora-radius-md);
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
display: flex; align-items: center; justify-content: center;
font-size: 22px; margin-bottom: 10px;
}
.app-list-shell__empty-title { font-size: 1.05rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
.app-list-shell__empty-text { font-size: 0.8125rem; color: var(--aurora-text-muted); max-width: 320px; margin: 0; }
@media (max-width: 1000px) {
/* Stacked instead of side-by-side, so the fixed-height/independent-
scroll treatment above (built for two side-by-side panes) would just
squash both panes into half-height boxes — switch to natural height
with each pane capped and independently scrollable instead. */
.app-list-shell { flex-direction: column; height: auto; }
.app-list-shell__nav {
width: auto;
height: auto;
max-height: 40vh;
}
.app-list-shell__content {
height: auto;
max-height: 60vh;
}
}
</style>
+324
View File
@@ -0,0 +1,324 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
headers: { 'X-WP-Nonce': props.serverData.restNonce },
}).then((r) => {
if (!r.ok) throw new Error('Request failed');
return r.json();
});
// 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'],
['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'],
['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);
const page = ref(1);
const totalPages = ref(1);
const actionFilter = ref('');
const objectFilter = ref('');
const search = ref('');
const load = async () => {
loading.value = true;
try {
const params = new URLSearchParams({ scope: scope.value });
if (actionFilter.value) params.set('action_type', actionFilter.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()}`);
items.value = data.items;
total.value = data.total;
totalPages.value = data.totalPages;
} finally {
loading.value = false;
}
};
onMounted(load);
watch([actionFilter, objectFilter, search], () => {
page.value = 1;
load();
});
watch(page, load);
const 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>
<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>
</header>
<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>
<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>
<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-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-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,217 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import AppListShell from '../_shared/AppListShell.vue';
import AppListItem from '../_shared/AppListItem.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path, opts = {}) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(opts.headers || {}),
},
}).then((r) => {
if (!r.ok) throw new Error('Request failed');
return r.json();
});
const notices = ref([]);
const loading = ref(true);
const search = ref('');
const selectedId = ref(null);
const isCreating = ref(false);
const roleOptions = ref([]);
const load = async () => {
loading.value = true;
try {
notices.value = await api('/notices');
} finally {
loading.value = false;
}
};
onMounted(() => {
load();
roleOptions.value = props.serverData.roles || [];
});
const filtered = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return notices.value;
return notices.value.filter((n) => n.title.toLowerCase().includes(q));
});
const selected = computed(() => notices.value.find((n) => n.id === selectedId.value) || null);
const draft = reactive({
title: '', message: '', type: 'info', roles: [], pages: '', dismissible: true, active: true,
});
const selectNotice = (n) => {
selectedId.value = n.id;
isCreating.value = false;
Object.assign(draft, {
title: n.title, message: n.message, type: n.type, roles: [...n.roles],
pages: n.pages.join(', '), dismissible: n.dismissible, active: n.active,
});
};
const newNotice = () => {
selectedId.value = null;
isCreating.value = true;
Object.assign(draft, { title: 'New notice', message: '', type: 'info', roles: [], pages: '', dismissible: true, active: true });
};
const toggleRole = (slug) => {
const idx = draft.roles.indexOf(slug);
if (idx === -1) draft.roles.push(slug);
else draft.roles.splice(idx, 1);
};
const saving = ref(false);
const save = async () => {
saving.value = true;
try {
const payload = {
title: draft.title,
message: draft.message,
type: draft.type,
roles: draft.roles,
pages: draft.pages.split(',').map((p) => p.trim()).filter(Boolean),
dismissible: draft.dismissible,
active: draft.active,
};
if (isCreating.value) {
const created = await api('/notices', { method: 'POST', body: JSON.stringify(payload) });
await load();
selectNotice(created);
} else if (selectedId.value) {
await api(`/notices/${selectedId.value}`, { method: 'PUT', body: JSON.stringify(payload) });
await load();
}
} finally {
saving.value = false;
}
};
const remove = async () => {
if (!selectedId.value) return;
await api(`/notices/${selectedId.value}`, { method: 'DELETE' });
selectedId.value = null;
await load();
};
</script>
<template>
<AppListShell
v-model:search="search"
title="Admin Notices"
search-placeholder="Search notices…"
detail-icon="dashicons-megaphone"
detail-title="Notice Editor"
detail-text="Select a notice from the list to edit it, or create a new one to get started."
:has-selection="!!selected || isCreating"
>
<template #header-actions>
<button type="button" @click="newNotice">+</button>
</template>
<template #detail-empty-actions>
<button type="button" class="is-primary" style="margin-top: 14px;" @click="newNotice">
Create Your First Notice
</button>
</template>
<template #list>
<AppListItem
v-for="n in filtered"
:key="n.id"
icon="dashicons-megaphone"
:title="n.title"
:subtitle="n.active ? n.type : `${n.type} (inactive)`"
:active="n.id === selectedId"
@click="selectNotice(n)"
/>
</template>
<template #detail>
<div class="notice-editor">
<div class="notice-editor__row">
<input v-model="draft.title" type="text" placeholder="Notice title" class="notice-editor__title" />
<select v-model="draft.type">
<option value="info">Info</option>
<option value="success">Success</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
</div>
<textarea v-model="draft.message" placeholder="Message" rows="4" />
<div class="notice-editor__section">
<h3>Target roles (empty = all)</h3>
<div class="notice-editor__roles">
<label v-for="r in roleOptions" :key="r.value" class="notice-editor__role">
<input type="checkbox" :checked="draft.roles.includes(r.value)" @change="toggleRole(r.value)" />
{{ r.label }}
</label>
</div>
</div>
<div class="notice-editor__section">
<h3>Target pages (comma-separated, e.g. index.php, edit.php — empty = all)</h3>
<input v-model="draft.pages" type="text" placeholder="index.php, edit.php" />
</div>
<div class="notice-editor__toggles">
<label><input type="checkbox" v-model="draft.dismissible" /> Dismissible</label>
<label><input type="checkbox" v-model="draft.active" /> Active</label>
</div>
<div class="notice-editor__actions">
<button type="button" class="is-danger" :disabled="!selectedId" @click="remove">Delete</button>
<button type="button" class="is-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</template>
</AppListShell>
</template>
<style scoped>
.notice-editor { display: flex; flex-direction: column; gap: 18px; }
.notice-editor__row { display: flex; gap: 10px; }
.notice-editor__title {
flex: 1; font-size: 1.05rem; font-weight: 700; padding: 8px 10px;
border: 1px solid var(--aurora-border); border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.notice-editor select, .notice-editor input[type='text'], .notice-editor textarea {
padding: 8px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.8125rem;
font-family: inherit; width: 100%; box-sizing: border-box;
}
.notice-editor__section h3 {
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em;
color: var(--aurora-text-muted); margin: 0 0 8px;
}
.notice-editor__roles { display: flex; flex-wrap: wrap; gap: 12px; }
.notice-editor__role { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; color: var(--aurora-text); }
.notice-editor__toggles { display: flex; gap: 20px; font-size: 0.8125rem; color: var(--aurora-text); }
.notice-editor__toggles label { display: flex; align-items: center; gap: 6px; }
.notice-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
.notice-editor__actions button {
border: none; border-radius: var(--aurora-radius-sm); padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.notice-editor__actions .is-primary { background: var(--aurora-accent); color: #fff; }
.notice-editor__actions .is-danger { color: #ef4444; }
.notice-editor__actions button:disabled { opacity: 0.5; cursor: default; }
</style>
+128
View File
@@ -0,0 +1,128 @@
<script setup>
import { reactive, ref } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const description = ref('');
const sending = ref(false);
const notice = ref('');
const error = ref('');
const diagnostics = reactive({
pluginVersion: props.serverData.pluginVersion || '',
wpVersion: props.serverData.wpVersion || '',
phpVersion: props.serverData.phpVersion || '',
theme: props.serverData.theme || '',
});
const send = async () => {
if (!description.value.trim()) return;
sending.value = true;
error.value = '';
notice.value = '';
try {
const res = await fetch(`${props.serverData.restUrl}aurora-admin/v1/bug-report`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
},
body: JSON.stringify({ description: description.value }),
});
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data?.message || 'Could not send your report.');
notice.value = 'Thanks — your report was sent.';
description.value = '';
} catch (e) {
error.value = e.message;
} finally {
sending.value = false;
}
};
</script>
<template>
<main class="bug-report-page">
<header class="bug-report-page__header">
<p class="bug-report-page__eyebrow">Support</p>
<h1>Report a Bug</h1>
<p class="bug-report-page__sub">
Tell us what went wrong. We'll get your diagnostics automatically —
just describe the issue below.
</p>
</header>
<div v-if="notice" class="bug-report-notice bug-report-notice--success">{{ notice }}</div>
<div v-if="error" class="bug-report-notice bug-report-notice--error">{{ error }}</div>
<form class="bug-report-form" @submit.prevent="send">
<label class="bug-report-field">
<span>What happened?</span>
<textarea
v-model="description"
rows="8"
placeholder="Describe the bug, what you expected, and the steps to reproduce it…"
required
/>
</label>
<div class="bug-report-diagnostics">
<h2>Diagnostics (sent automatically)</h2>
<dl>
<div><dt>Plugin version</dt><dd>{{ diagnostics.pluginVersion || '—' }}</dd></div>
<div><dt>WordPress version</dt><dd>{{ diagnostics.wpVersion || '—' }}</dd></div>
<div><dt>PHP version</dt><dd>{{ diagnostics.phpVersion || '—' }}</dd></div>
<div><dt>Active theme</dt><dd>{{ diagnostics.theme || '—' }}</dd></div>
</dl>
</div>
<div class="bug-report-form__actions">
<button type="submit" class="button-primary" :disabled="sending || !description.trim()">
{{ sending ? 'Sending…' : 'Send Report' }}
</button>
</div>
</form>
</main>
</template>
<style scoped>
.bug-report-page { max-width: 640px; margin: 0 auto; }
.bug-report-page__header { margin-bottom: 16px; }
.bug-report-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.bug-report-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.bug-report-page__sub { margin: 8px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
.bug-report-notice { padding: 10px 14px; border-radius: var(--aurora-radius-sm); margin-bottom: 16px; font-size: 0.875rem; }
.bug-report-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.bug-report-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.bug-report-form { display: flex; flex-direction: column; gap: 20px; }
.bug-report-field { display: flex; flex-direction: column; gap: 6px; }
.bug-report-field span { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-text); }
.bug-report-field textarea {
padding: 10px 12px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.875rem;
font-family: inherit; resize: vertical;
}
.bug-report-diagnostics {
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md); padding: 14px 16px;
}
.bug-report-diagnostics h2 {
margin: 0 0 10px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.03em; color: var(--aurora-text-muted); font-weight: 600;
}
.bug-report-diagnostics dl { margin: 0; display: flex; flex-direction: column; gap: 6px; }
.bug-report-diagnostics dl > div { display: flex; justify-content: space-between; font-size: 0.8125rem; }
.bug-report-diagnostics dt { color: var(--aurora-text-muted); }
.bug-report-diagnostics dd { margin: 0; color: var(--aurora-text); font-weight: 500; }
.bug-report-form__actions { display: flex; justify-content: flex-end; }
</style>
+701
View File
@@ -0,0 +1,701 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const comments = ref([]);
const stats = ref({ all: 0, approved: 0, pending: 0, spam: 0, trash: 0 });
const pagination = ref({ page: 1, perPage: 50, total: 0, totalPages: 1 });
const loading = ref(true);
const busyId = ref(0);
const error = ref('');
const notice = ref('');
const search = ref('');
const status = ref('all');
const compact = ref(false);
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
const request = async (path, options = {}) => {
const response = await fetch(`${apiBase.value}${path}`, {
credentials: 'same-origin',
...options,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(options.headers || {}),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.message || data.error || 'Request failed');
}
return data;
};
const load = async () => {
loading.value = true;
error.value = '';
try {
const params = new URLSearchParams({
status: status.value,
search: search.value.trim(),
page: String(pagination.value.page),
perPage: String(pagination.value.perPage),
});
const data = await request(`/comments?${params.toString()}`);
comments.value = data.items || [];
stats.value = data.stats || stats.value;
pagination.value = data.pagination || pagination.value;
} catch (err) {
error.value = err.message || 'Could not load comments';
} finally {
loading.value = false;
}
};
onMounted(load);
let searchTimer = 0;
watch(search, () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
pagination.value.page = 1;
load();
}, 250);
});
const setStatus = (next) => {
status.value = next;
pagination.value.page = 1;
load();
};
const filters = computed(() => [
{ key: 'all', label: 'All', count: stats.value.all },
{ key: 'approved', label: 'Approved', count: stats.value.approved },
{ key: 'pending', label: 'Pending', count: stats.value.pending },
{ key: 'spam', label: 'Spam', count: stats.value.spam },
{ key: 'trash', label: 'Trash', count: stats.value.trash },
]);
const runStatus = async (comment, nextStatus) => {
if (!comment || busyId.value) return;
busyId.value = comment.id;
error.value = '';
notice.value = '';
try {
const data = await request('/comments/status', {
method: 'POST',
body: JSON.stringify({ id: comment.id, status: nextStatus }),
});
notice.value = data.message || 'Comment updated.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const deleteComment = async (comment) => {
if (!comment || busyId.value) return;
busyId.value = comment.id;
error.value = '';
notice.value = '';
try {
const data = await request(`/comments/${comment.id}`, { method: 'DELETE' });
notice.value = data.message || 'Comment deleted.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const goPage = (page) => {
const next = Math.min(Math.max(1, page), pagination.value.totalPages || 1);
if (next === pagination.value.page) return;
pagination.value.page = next;
load();
};
const initials = (name) =>
(name || '?')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('');
const rowRange = computed(() => {
if (!pagination.value.total) return '0 to 0';
const start = (pagination.value.page - 1) * pagination.value.perPage + 1;
const end = Math.min(pagination.value.total, pagination.value.page * pagination.value.perPage);
return `${start} to ${end}`;
});
</script>
<template>
<main class="comments-page">
<header class="comments-page__header">
<div>
<p class="comments-page__eyebrow">Discussion</p>
<h1>Comments</h1>
</div>
<div class="comments-page__actions">
<button
type="button"
class="icon-button"
:class="{ 'is-active': compact }"
title="Toggle compact view"
@click="compact = !compact"
>
<span class="dashicons dashicons-menu-alt3" />
</button>
<button type="button" class="icon-button" title="Refresh comments" :disabled="loading" @click="load">
<span class="dashicons dashicons-update" />
</button>
</div>
</header>
<section class="comments-page__toolbar" aria-label="Comment filters and search">
<div class="comments-page__filters">
<button
v-for="filter in filters"
:key="filter.key"
type="button"
class="filter-pill"
:class="{ 'is-active': status === filter.key }"
@click="setStatus(filter.key)"
>
<span>{{ filter.label }}</span>
<strong>{{ filter.count }}</strong>
</button>
</div>
<label class="comments-page__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Search comments..." />
</label>
</section>
<div v-if="notice" class="comments-notice comments-notice--success">{{ notice }}</div>
<div v-if="error" class="comments-notice comments-notice--error">{{ error }}</div>
<section class="comments-table" :class="{ 'is-compact': compact }" aria-label="Comments">
<div class="comments-table__head">
<div>Author <span class="dashicons dashicons-filter" /></div>
<div>Comment <span class="dashicons dashicons-filter" /></div>
<div>Post <span class="dashicons dashicons-filter" /></div>
<div>Date <span class="dashicons dashicons-filter" /></div>
<div>Status <span class="dashicons dashicons-filter" /></div>
<div>Actions</div>
</div>
<div v-if="loading" class="comments-table__empty">Loading comments...</div>
<div v-else-if="!comments.length" class="comments-table__empty">No comments match your filters.</div>
<article v-for="comment in comments" v-else :key="comment.id" class="comment-row">
<div class="comment-row__author">
<img v-if="comment.avatar" :src="comment.avatar" alt="" class="comment-row__avatar" />
<div v-else class="comment-row__initials">{{ initials(comment.author) }}</div>
<div class="comment-row__person">
<strong>{{ comment.author }}</strong>
<span>{{ comment.email || 'No email' }}</span>
</div>
</div>
<div class="comment-row__comment" data-label="Comment">
{{ comment.content || 'No comment text.' }}
</div>
<div class="comment-row__post" data-label="Post">
<a v-if="comment.postEditUrl" :href="comment.postEditUrl">{{ comment.postTitle }}</a>
<span v-else>{{ comment.postTitle }}</span>
</div>
<div class="comment-row__cell" data-label="Date">
<strong>{{ comment.relativeDate }}</strong>
<span>{{ comment.date }}</span>
</div>
<div class="comment-row__cell" data-label="Status">
<span class="status-badge" :class="`status-badge--${comment.status}`">
<i />
{{ comment.statusLabel }}
</span>
</div>
<div class="comment-row__actions">
<button
v-if="comment.status !== 'approved'"
type="button"
class="icon-button icon-button--inline"
title="Approve"
:disabled="busyId === comment.id"
@click="runStatus(comment, 'approve')"
>
<span class="dashicons dashicons-yes" />
</button>
<button
v-if="comment.status === 'approved'"
type="button"
class="icon-button icon-button--inline"
title="Mark pending"
:disabled="busyId === comment.id"
@click="runStatus(comment, 'hold')"
>
<span class="dashicons dashicons-hidden" />
</button>
<a class="icon-button icon-button--inline" title="Edit comment" :href="comment.editUrl">
<span class="dashicons dashicons-edit" />
</a>
<button
v-if="comment.status !== 'spam'"
type="button"
class="icon-button icon-button--inline"
title="Mark spam"
:disabled="busyId === comment.id"
@click="runStatus(comment, 'spam')"
>
<span class="dashicons dashicons-warning" />
</button>
<button
v-if="comment.status !== 'trash'"
type="button"
class="icon-button icon-button--inline"
title="Move to trash"
:disabled="busyId === comment.id"
@click="runStatus(comment, 'trash')"
>
<span class="dashicons dashicons-trash" />
</button>
<button
v-else
type="button"
class="icon-button icon-button--inline"
title="Delete permanently"
:disabled="busyId === comment.id"
@click="deleteComment(comment)"
>
<span class="dashicons dashicons-no-alt" />
</button>
</div>
</article>
<footer v-if="!loading && comments.length" class="comments-table__footer">
<div>Page Size: {{ pagination.perPage }}</div>
<div>{{ rowRange }} of {{ pagination.total }}</div>
<div class="comments-table__pager">
<button type="button" :disabled="pagination.page <= 1" @click="goPage(1)">
<span class="dashicons dashicons-controls-skipback" />
</button>
<button type="button" :disabled="pagination.page <= 1" @click="goPage(pagination.page - 1)">
<span class="dashicons dashicons-arrow-left-alt2" />
</button>
<span>Page {{ pagination.page }} of {{ pagination.totalPages }}</span>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.page + 1)"
>
<span class="dashicons dashicons-arrow-right-alt2" />
</button>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.totalPages)"
>
<span class="dashicons dashicons-controls-skipforward" />
</button>
</div>
</footer>
</section>
</main>
</template>
<style scoped>
.comments-page {
min-height: calc(100vh - 112px);
padding: 0;
color: var(--aurora-text);
background: transparent;
}
.comments-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 24px;
}
.comments-page__eyebrow {
margin: 0 0 7px;
color: var(--aurora-text-muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.comments-page h1 {
margin: 0;
color: var(--aurora-text);
font-size: 1.45rem;
line-height: 1.15;
}
.comments-page__actions,
.comments-page__toolbar,
.comments-page__filters,
.comment-row__actions {
display: flex;
align-items: center;
}
.comments-page__actions {
gap: 10px;
}
.comments-page__toolbar {
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.comments-page__filters {
flex-wrap: wrap;
gap: 8px;
}
.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 12px;
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
}
.filter-pill strong {
min-width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--aurora-frame-bg);
color: inherit;
font-size: 0.72rem;
}
.filter-pill.is-active {
border-color: var(--aurora-text-muted);
background: var(--aurora-frame-bg);
color: var(--aurora-text);
}
.comments-page__search {
width: min(320px, 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);
}
.comments-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;
}
.comments-page__search input::placeholder {
color: var(--aurora-text-muted);
}
.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;
text-decoration: none;
}
.icon-button.is-active,
.icon-button:hover:not(:disabled) {
border-color: var(--aurora-text-muted);
color: var(--aurora-text);
}
.icon-button--inline {
width: 30px;
min-height: 30px;
border: 0;
background: transparent;
color: var(--aurora-text-muted);
}
.icon-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.comments-notice {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
font-size: 0.78rem;
font-weight: 750;
}
.comments-table {
overflow: hidden;
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-sm);
background: var(--aurora-frame-bg);
}
.comments-table__head,
.comment-row {
display: grid;
grid-template-columns: minmax(190px, 0.9fr) minmax(320px, 1.6fr) minmax(190px, 1fr) 110px 150px 142px;
align-items: center;
}
.comments-table__head {
min-height: 48px;
border-bottom: 1px solid var(--aurora-border);
color: var(--aurora-text);
font-size: 0.79rem;
font-weight: 850;
}
.comments-table__head > div {
min-height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.comment-row {
min-height: 76px;
border-bottom: 1px solid var(--aurora-border);
}
.comment-row:hover {
background: var(--aurora-bg-subtle);
}
.comment-row__author {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
gap: 12px;
align-items: center;
min-width: 0;
padding: 14px 16px;
}
.comment-row__avatar,
.comment-row__initials {
width: 36px;
height: 36px;
border-radius: 50%;
}
.comment-row__avatar {
display: block;
}
.comment-row__initials {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
font-weight: 900;
}
.comment-row__person {
min-width: 0;
}
.comment-row__person strong {
display: block;
overflow: hidden;
color: var(--aurora-text);
font-size: 0.86rem;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-row__person span,
.comment-row__cell span {
display: block;
overflow: hidden;
color: var(--aurora-text-muted);
font-size: 0.74rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-row__comment,
.comment-row__post,
.comment-row__cell {
min-width: 0;
padding: 14px 16px;
color: var(--aurora-text-muted);
font-size: 0.8rem;
line-height: 1.45;
}
.comment-row__comment {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-row__post a,
.comment-row__post span {
display: block;
overflow: hidden;
color: var(--aurora-text);
font-weight: 800;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-row__cell strong {
display: block;
color: var(--aurora-text-muted);
font-size: 0.8rem;
}
.status-badge {
width: fit-content;
border-radius: 999px;
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
font-size: 0.74rem;
font-weight: 850;
}
.status-badge i {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.8;
}
.status-badge--approved {
background: var(--aurora-frame-bg);
color: var(--aurora-text);
box-shadow: inset 0 0 0 1px var(--aurora-border);
}
.comment-row__actions {
gap: 8px;
padding: 14px 16px;
}
.comments-table.is-compact .comment-row {
min-height: 58px;
}
.comments-table.is-compact .comment-row__author,
.comments-table.is-compact .comment-row__comment,
.comments-table.is-compact .comment-row__post,
.comments-table.is-compact .comment-row__cell,
.comments-table.is-compact .comment-row__actions {
padding-top: 9px;
padding-bottom: 9px;
}
.comments-table.is-compact .comment-row__avatar,
.comments-table.is-compact .comment-row__initials {
width: 30px;
height: 30px;
}
.comments-table__empty {
padding: 38px 18px;
color: var(--aurora-text-muted);
font-size: 0.85rem;
text-align: center;
}
.comments-table__footer {
min-height: 54px;
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;
}
.comments-table__pager {
display: flex;
align-items: center;
gap: 8px;
}
.comments-table__pager button {
width: 28px;
height: 28px;
border: 0;
border-radius: var(--aurora-radius-sm);
background: transparent;
color: var(--aurora-text-muted);
cursor: pointer;
}
.comments-table__pager button:not(:disabled):hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.comments-table__pager button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 1280px) {
.comments-table__head,
.comment-row {
grid-template-columns: minmax(170px, 0.9fr) minmax(260px, 1.4fr) minmax(160px, 0.9fr) 100px 130px 132px;
}
}
@media (max-width: 1080px) {
.comments-table__head {
display: none;
}
.comment-row {
grid-template-columns: 1fr;
align-items: stretch;
padding: 8px 0;
}
.comment-row__comment,
.comment-row__post,
.comment-row__cell,
.comment-row__actions {
padding: 8px 16px;
}
.comment-row__cell,
.comment-row__post {
display: flex;
justify-content: space-between;
gap: 16px;
}
.comment-row__cell::before,
.comment-row__post::before {
content: attr(data-label);
color: var(--aurora-text-muted);
font-weight: 800;
}
.comments-table__footer,
.comments-page__header,
.comments-page__toolbar {
align-items: stretch;
flex-direction: column;
}
.comments-table__footer {
padding: 14px 16px;
gap: 10px;
}
.comments-page__search {
width: 100%;
}
}
</style>
+259
View File
@@ -0,0 +1,259 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useDashboardData } from './useDashboardData.js';
import { useAnalyticsData } from './useAnalyticsData.js';
import RecentContent from './cards/RecentContent.vue';
import ScheduledContent from './cards/ScheduledContent.vue';
import RecentComments from './cards/RecentComments.vue';
import UserAnalytics from './cards/UserAnalytics.vue';
import MediaAnalytics from './cards/MediaAnalytics.vue';
import ServerHealth from './cards/ServerHealth.vue';
import PageViews from './cards/PageViews.vue';
import ActiveUsers from './cards/ActiveUsers.vue';
import DeviceUsage from './cards/DeviceUsage.vue';
import Engagement from './cards/Engagement.vue';
import TopList from './cards/TopList.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const { data, loading, load } = useDashboardData(props.serverData);
const { data: analytics, error: analyticsError, load: loadAnalytics } = useAnalyticsData(props.serverData);
// Date range — default last 7 days, with simple presets.
const today = new Date();
const range = ref([new Date(new Date().setDate(today.getDate() - 7)), new Date()]);
const presetsOpen = ref(false);
const fmtLabel = (d) =>
d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
const reload = (from, to) => {
load(from, to);
loadAnalytics(from, to);
};
const applyPreset = (days) => {
const to = new Date();
const from = new Date();
from.setDate(to.getDate() - days);
range.value = [from, to];
presetsOpen.value = false;
reload(from, to);
};
const activeTab = ref('overview');
onMounted(() => reload(range.value[0], range.value[1]));
</script>
<template>
<div class="aurora-dashboard">
<header class="dash__head">
<div>
<h1 class="dash__title">Dashboard</h1>
<p class="dash__subtitle">Overview of your site activity</p>
</div>
<div class="dash__range">
<button type="button" class="dash__range-btn" @click="presetsOpen = !presetsOpen">
<span class="dash__range-icon">▦</span>
{{ fmtLabel(range[0]) }} — {{ fmtLabel(range[1]) }}
</button>
<div v-if="presetsOpen" class="dash__presets">
<button type="button" @click="applyPreset(7)">Last 7 days</button>
<button type="button" @click="applyPreset(30)">Last 30 days</button>
<button type="button" @click="applyPreset(90)">Last 90 days</button>
</div>
</div>
</header>
<div class="dash__tabs">
<button
type="button"
:class="['dash__tab', { 'dash__tab--active': activeTab === 'overview' }]"
@click="activeTab = 'overview'"
>
Overview
</button>
<button
type="button"
:class="['dash__tab', { 'dash__tab--active': activeTab === 'analytics' }]"
@click="activeTab = 'analytics'"
>
Analytics
</button>
</div>
<div v-if="activeTab === 'overview'" class="dash__grid">
<div class="dash__cell" style="grid-column: span 4">
<RecentContent :data="data ? data.recentContent : {}" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<ScheduledContent :data="data ? data.scheduledContent : {}" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<RecentComments :data="data ? data.recentComments : {}" />
</div>
<div class="dash__cell" style="grid-column: span 6">
<UserAnalytics :data="data ? data.userAnalytics : {}" />
</div>
<div class="dash__cell" style="grid-column: span 6">
<MediaAnalytics :data="data ? data.mediaAnalytics : {}" />
</div>
<div class="dash__cell" style="grid-column: span 6">
<ServerHealth :data="data ? data.serverHealth : {}" />
</div>
</div>
<div v-else-if="analyticsError" class="dash__analytics-error">
Could not load analytics. Try refreshing the page.
</div>
<div v-else class="dash__grid">
<div class="dash__cell" style="grid-column: span 8">
<PageViews :data="analytics ? analytics.pageViews : {}" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<ActiveUsers :count="analytics ? analytics.activeUsers : 0" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<DeviceUsage :items="analytics ? analytics.deviceUsage : []" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<Engagement :bounce="analytics ? analytics.bounce : {}" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<TopList
title="Top Countries"
subtitle="Where visitors are from"
:items="analytics ? analytics.topCountries : []"
empty-text="No location data yet."
/>
</div>
<div class="dash__cell" style="grid-column: span 6">
<TopList
title="Top Pages"
subtitle="Most viewed pages"
:items="analytics ? analytics.topPages : []"
/>
</div>
<div class="dash__cell" style="grid-column: span 6">
<TopList
title="Top Referrers"
subtitle="Where traffic comes from"
:items="analytics ? analytics.topReferrers : []"
/>
</div>
</div>
</div>
</template>
<style scoped>
.aurora-dashboard {
padding: 24px var(--aurora-gutter-r, 44px) 40px 32px;
box-sizing: border-box;
}
.dash__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.dash__title {
margin: 0;
font-size: 1.6rem;
font-weight: 700;
color: var(--aurora-text);
letter-spacing: -0.02em;
}
.dash__subtitle { margin: 4px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
.dash__range { position: relative; }
.dash__range-btn {
display: flex; align-items: center; gap: 8px;
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-card-border);
color: var(--aurora-text);
border-radius: var(--aurora-radius-md);
padding: 8px 12px;
font-size: 0.8125rem;
cursor: pointer;
}
.dash__range-icon { color: var(--aurora-text-muted); }
.dash__presets {
position: absolute; right: 0; top: calc(100% + 6px);
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-card-border);
border-radius: var(--aurora-radius-md);
padding: 6px;
z-index: 10;
min-width: 160px;
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
}
.dash__presets button {
display: block; width: 100%; text-align: left;
background: none; border: none; color: var(--aurora-text);
padding: 8px 10px; border-radius: var(--aurora-radius-sm); cursor: pointer; font-size: 0.8125rem;
}
.dash__presets button:hover { background: var(--aurora-bg-subtle); }
.dash__tabs {
display: inline-flex;
gap: 2px;
background: var(--aurora-bg-subtle);
border-radius: var(--aurora-radius-md);
padding: 3px;
margin-bottom: 20px;
}
.dash__analytics-error {
padding: 48px 24px;
text-align: center;
color: var(--aurora-text-muted);
font-size: 0.875rem;
}
.dash__tab {
border: none; background: none; cursor: pointer;
padding: 6px 16px; border-radius: var(--aurora-radius-sm);
font-size: 0.8125rem; font-weight: 500;
color: var(--aurora-text-muted);
}
.dash__tab--active { background: var(--aurora-card-bg); color: var(--aurora-text); }
.dash__grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 18px;
}
.dash__cell { min-width: 0; display: flex; }
.dash__cell > * { width: 100%; }
.dash__analytics-empty {
color: var(--aurora-text-muted);
font-size: 0.875rem;
padding: 40px 0;
text-align: center;
}
@media (max-width: 1100px) {
.dash__cell { grid-column: span 12 !important; }
}
</style>
<style>
/* Shared bits used inside the card slots (head-right badges, footer links).
Scoped under .aurora-dashboard so they can't collide with WordPress or
leak elsewhere, while still reaching slotted content in child cards. */
.aurora-dashboard .dot {
display: inline-block;
width: 7px; height: 7px;
border-radius: 50%;
margin-right: 4px;
vertical-align: middle;
}
.aurora-dashboard .dot--accent { background: var(--aurora-accent); }
.aurora-dashboard .dot--green { background: #34d399; }
.aurora-dashboard .viewall {
font-size: 0.75rem;
color: var(--aurora-text-muted);
text-decoration: none;
}
.aurora-dashboard .viewall:hover { color: var(--aurora-accent); }
</style>
@@ -0,0 +1,35 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
defineProps({ count: { type: Number, default: 0 } });
</script>
<template>
<AuroraCard title="Active Users" subtitle="Visitors in the last 5 minutes">
<div class="au">
<span class="au__pulse" />
<div class="au__num">{{ count }}</div>
<div class="au__label">active now</div>
</div>
</AuroraCard>
</template>
<style scoped>
.au {
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 24px 0; height: 100%;
}
.au__pulse {
width: 10px; height: 10px; border-radius: 50%;
background: #34d399; margin-bottom: 14px;
box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.6);
animation: au-pulse 1.8s infinite;
}
@keyframes au-pulse {
0% { box-shadow: 0 0 0 0 rgba(52,211,153,0.5); }
70% { box-shadow: 0 0 0 12px rgba(52,211,153,0); }
100% { box-shadow: 0 0 0 0 rgba(52,211,153,0); }
}
.au__num { font-size: 2.5rem; font-weight: 700; color: var(--aurora-text); line-height: 1; }
.au__label { margin-top: 6px; font-size: 0.8125rem; color: var(--aurora-text-muted); }
</style>
@@ -0,0 +1,18 @@
<script setup>
import { computed } from 'vue';
import AuroraCard from '../components/AuroraCard.vue';
import BarList from '../components/BarList.vue';
const props = defineProps({ items: { type: Array, default: () => [] } });
const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
const list = computed(() =>
props.items.map((d) => ({ label: `${cap(d.device)} · ${d.pct}%`, count: d.count }))
);
</script>
<template>
<AuroraCard title="Device Usage" subtitle="How visitors browse">
<BarList :items="list" empty-text="No visits in this range yet." />
</AuroraCard>
</template>
@@ -0,0 +1,18 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import StatRow from '../components/StatRow.vue';
defineProps({ bounce: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="Engagement" subtitle="Bounce rate & time on site">
<StatRow
:stats="[
{ value: (bounce.rate || 0) + '%', label: 'Bounce Rate' },
{ value: bounce.avgTime || '0s', label: 'Avg. Time' },
{ value: bounce.sessions || 0, label: 'Sessions' },
]"
/>
</AuroraCard>
</template>
@@ -0,0 +1,61 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
defineProps({ data: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="Media Analytics" subtitle="Media library overview">
<div class="ma__top">
<div class="ma__big">
<div class="ma__big-num">{{ data.totalFiles || 0 }}</div>
<div class="ma__big-label">Total Files</div>
</div>
<div class="ma__big">
<div class="ma__big-num">{{ data.totalSize || '0 B' }}</div>
<div class="ma__big-label">Total Size</div>
</div>
</div>
<div class="ma__grid">
<div class="ma__box">
<div class="ma__box-num">{{ data.recent30d || 0 }}</div>
<div class="ma__box-label">Recent (30d)</div>
</div>
<div class="ma__box">
<div class="ma__box-num">{{ data.unused || 0 }}</div>
<div class="ma__box-label">Unused</div>
</div>
<div class="ma__box">
<div class="ma__box-num">{{ data.largeFiles || 0 }}</div>
<div class="ma__box-label">Large Files</div>
</div>
<div class="ma__box">
<div class="ma__box-num">{{ data.diskUsage || '0 B' }}</div>
<div class="ma__box-label">Disk Usage</div>
</div>
</div>
<template #footer>
<a :href="data.manageUrl" class="viewall">Manage Media ›</a>
</template>
</AuroraCard>
</template>
<style scoped>
.ma__top {
display: grid; grid-template-columns: 1fr 1fr; text-align: center;
margin-bottom: 18px;
}
.ma__big-num { font-size: 1.75rem; font-weight: 700; color: var(--aurora-text); }
.ma__big-label { margin-top: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); }
.ma__grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.ma__box {
background: var(--aurora-bg-subtle);
border-radius: var(--aurora-radius-md);
padding: 14px;
text-align: center;
}
.ma__box-num { font-size: 1.25rem; font-weight: 700; color: var(--aurora-text); }
.ma__box-label { margin-top: 2px; font-size: 0.6875rem; color: var(--aurora-text-muted); }
</style>
@@ -0,0 +1,26 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import StatRow from '../components/StatRow.vue';
import AreaChart from '../components/AreaChart.vue';
defineProps({ data: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="Page Views" subtitle="Traffic over the selected range">
<StatRow
:boxed="false"
:stats="[
{ value: data.total || 0, label: 'Total Views' },
{ value: data.unique || 0, label: 'Unique Visitors' },
]"
/>
<div class="pv__chart">
<AreaChart :series="data.series || []" :height="150" />
</div>
</AuroraCard>
</template>
<style scoped>
.pv__chart { margin-top: 20px; }
</style>
@@ -0,0 +1,43 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import StatRow from '../components/StatRow.vue';
defineProps({ data: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="Recent Comments" subtitle="Latest comments and discussions">
<template #head-right>
<span class="dot dot--accent" /> {{ data.total || 0 }} total
</template>
<StatRow
:stats="[
{ value: data.approved || 0, label: 'Approved' },
{ value: data.pending || 0, label: 'Pending' },
{ value: data.spam || 0, label: 'Spam' },
]"
/>
<ul class="cm__list">
<li v-for="c in data.latest" :key="c.id" class="cm__item">
<p class="cm__excerpt">{{ c.excerpt }}</p>
<div class="cm__meta">on <span class="cm__post">{{ c.postTitle }}</span> · {{ c.date }}</div>
</li>
<li v-if="!data.latest || !data.latest.length" class="cm__empty">No comments yet.</li>
</ul>
<template #footer>
<a :href="data.listUrl" class="viewall">View all ›</a>
</template>
</AuroraCard>
</template>
<style scoped>
.cm__list { list-style: none; margin: 16px 0 0; padding: 0; }
.cm__item { padding: 8px 0; }
.cm__excerpt { margin: 0; font-size: 0.8125rem; color: var(--aurora-text); }
.cm__meta { margin-top: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); }
.cm__post { color: var(--aurora-accent); }
.cm__empty { font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 12px 0; }
</style>
@@ -0,0 +1,72 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import StatRow from '../components/StatRow.vue';
const props = defineProps({ data: { type: Object, default: () => ({}) } });
const statusLabel = (s) =>
({ publish: 'Published', draft: 'Draft', future: 'Scheduled' }[s] || s);
</script>
<template>
<AuroraCard title="Recent content" subtitle="Latest content activity">
<template #head-right>
<span class="dot dot--accent" /> {{ data.total || 0 }} total
</template>
<StatRow
:stats="[
{ value: data.published || 0, label: 'Published' },
{ value: data.draft || 0, label: 'Draft' },
{ value: data.scheduled || 0, label: 'Scheduled' },
]"
/>
<ul class="rc__list">
<li v-for="p in data.latest" :key="p.id" class="rc__item">
<div class="rc__main">
<a :href="p.editUrl" class="rc__title">{{ p.title || '(no title)' }}</a>
<div class="rc__meta">{{ p.author }} · {{ p.date }}</div>
</div>
<span class="rc__badge" :class="`rc__badge--${p.status}`">
{{ statusLabel(p.status) }}
</span>
</li>
<li v-if="!data.latest || !data.latest.length" class="rc__empty">No content yet.</li>
</ul>
<template #footer>
<a :href="data.listUrl" class="viewall">View all ›</a>
</template>
</AuroraCard>
</template>
<style scoped>
.rc__list { list-style: none; margin: 16px 0 0; padding: 0; }
.rc__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.rc__title {
color: var(--aurora-text);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
}
.rc__title:hover { color: var(--aurora-accent); }
.rc__meta { font-size: 0.75rem; color: var(--aurora-text-muted); margin-top: 2px; }
.rc__badge {
font-size: 0.6875rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
flex-shrink: 0;
}
.rc__badge--publish { color: #34d399; }
.rc__badge--future { color: var(--aurora-accent); }
.rc__badge--draft { color: var(--aurora-text-muted); }
.rc__empty { font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 12px 0; }
</style>
@@ -0,0 +1,169 @@
<script setup>
import { ref, computed } from 'vue';
import AuroraCard from '../components/AuroraCard.vue';
const props = defineProps({ data: { type: Object, default: () => ({}) } });
const view = ref(new Date());
const monthLabel = computed(() =>
view.value.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
);
const weekdays = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
// Build a calendar grid (Mon-first) for the viewed month.
const grid = computed(() => {
const year = view.value.getFullYear();
const month = view.value.getMonth();
const first = new Date(year, month, 1);
const startOffset = (first.getDay() + 6) % 7; // Mon = 0
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells = [];
for (let i = 0; i < startOffset; i++) cells.push(null);
const today = new Date();
for (let d = 1; d <= daysInMonth; d++) {
const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const scheduled = props.data.days && props.data.days[key] ? props.data.days[key].length : 0;
const isToday =
d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
cells.push({ d, key, scheduled, isToday });
}
return cells;
});
const move = (delta) => {
const next = new Date(view.value);
next.setMonth(next.getMonth() + delta);
view.value = next;
};
// Clicking a day: with scheduled content, open the posts list filtered to
// that day; otherwise show a transient popup on the dashboard.
const toast = ref('');
let toastTimer;
const onDayClick = (cell) => {
if (!cell) return;
if (cell.scheduled > 0) {
// Relative to /wp-admin/ (the dashboard is at index.php).
window.location.href = `edit.php?post_status=future&aurora_day=${cell.key}`;
} else {
toast.value = 'No scheduled content for this day.';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast.value = ''), 3000);
}
};
</script>
<template>
<AuroraCard title="Scheduled Content" :subtitle="monthLabel">
<template #head-right>
<div class="sc__nav">
<button type="button" @click="move(-1)" aria-label="Previous month">‹</button>
<button type="button" @click="move(1)" aria-label="Next month">›</button>
</div>
</template>
<div class="sc__count">
<span class="sc__count-num">{{ data.count || 0 }}</span> Scheduled Content
</div>
<div class="sc__weekdays">
<span v-for="(w, i) in weekdays" :key="i">{{ w }}</span>
</div>
<div class="sc__grid">
<div v-for="(cell, i) in grid" :key="i" class="sc__cell">
<button
v-if="cell"
type="button"
class="sc__day"
:class="{ 'sc__day--has': cell.scheduled > 0, 'sc__day--today': cell.isToday }"
:title="cell.scheduled > 0 ? `${cell.scheduled} scheduled` : 'No scheduled content'"
@click="onDayClick(cell)"
>
<span class="sc__date">{{ cell.d }}</span>
<span v-if="cell.scheduled > 0" class="sc__count">{{ cell.scheduled }}</span>
</button>
</div>
</div>
<Teleport to="body">
<Transition name="sc-toast">
<div v-if="toast" class="sc__toast">{{ toast }}</div>
</Transition>
</Teleport>
</AuroraCard>
</template>
<style scoped>
.sc__nav { display: flex; gap: 4px; }
.sc__nav button {
width: 26px; height: 26px;
border: 1px solid var(--aurora-card-border);
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
border-radius: var(--aurora-radius-sm);
cursor: pointer;
line-height: 1;
}
.sc__nav button:hover { color: var(--aurora-text); }
.sc__count { font-size: 0.875rem; color: var(--aurora-text-muted); margin-bottom: 16px; }
.sc__count-num { font-size: 1.5rem; font-weight: 700; color: var(--aurora-text); margin-right: 6px; }
.sc__weekdays {
display: grid; grid-template-columns: repeat(7, 1fr);
font-size: 0.6875rem; color: var(--aurora-text-muted); margin-bottom: 8px;
}
.sc__weekdays span { text-align: center; }
.sc__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; }
.sc__cell { aspect-ratio: 1; }
.sc__day {
position: relative;
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
border-radius: var(--aurora-radius-sm);
border: 1px solid var(--aurora-card-border);
background: transparent;
padding: 0;
cursor: pointer;
font-family: inherit;
transition: border-color 0.12s ease, background-color 0.12s ease;
}
.sc__day:hover { border-color: var(--aurora-accent); }
.sc__day--has:hover { filter: brightness(1.08); }
.sc__date {
position: absolute; top: 5px; left: 6px;
font-size: 0.7rem; line-height: 1; color: var(--aurora-text-muted);
}
.sc__count {
font-size: 1.05rem; font-weight: 700; color: var(--aurora-text);
}
.sc__day--has { background: var(--aurora-accent); border-color: var(--aurora-accent); }
.sc__day--has .sc__date { color: rgba(255, 255, 255, 0.85); }
.sc__day--has .sc__count { color: #fff; }
.sc__day--today { border-color: var(--aurora-accent); }
.sc__day--today .sc__date { color: var(--aurora-accent); }
</style>
<style>
/* Toast is teleported to <body>, so these rules are global (not scoped).
They rely on the global --aurora-* theme variables. */
.sc__toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 100001;
background: var(--aurora-card-bg, #1f1f23);
color: var(--aurora-text, #f4f4f5);
border: 1px solid var(--aurora-border, #2e2e33);
border-radius: var(--aurora-radius-md);
padding: 12px 18px;
font-size: 0.875rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
}
.sc-toast-enter-active,
.sc-toast-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.sc-toast-enter-from,
.sc-toast-leave-to { opacity: 0; transform: translate(-50%, 10px); }
</style>
@@ -0,0 +1,71 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import StatRow from '../components/StatRow.vue';
import ProgressBar from '../components/ProgressBar.vue';
defineProps({ data: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="Server Health" subtitle="System performance and updates">
<template #head-right>
<span class="dot dot--green" /> {{ data.updates || 0 }} updates
</template>
<div class="sh__versions">
<div class="sh__ver">
<div class="sh__ver-label">‹› WordPress</div>
<div class="sh__ver-num">{{ data.wp || '—' }}</div>
</div>
<div class="sh__ver">
<div class="sh__ver-label">⚙ PHP</div>
<div class="sh__ver-num">{{ data.php || '—' }}</div>
</div>
</div>
<div class="sh__bars">
<ProgressBar
label="Memory Usage"
icon="◍"
:pct="data.memory ? data.memory.pct : 0"
:detail="data.memory ? `${data.memory.used} / ${data.memory.total}` : ''"
/>
<ProgressBar
label="Disk Space"
icon="▤"
gradient
:pct="data.disk ? data.disk.pct : 0"
:detail="data.disk ? `${data.disk.used} / ${data.disk.total}` : ''"
/>
</div>
<StatRow
:stats="[
{ value: data.plugins || 0, label: 'Plugins' },
{ value: data.themes || 0, label: 'Themes' },
{ value: data.core || 0, label: 'Core' },
]"
/>
<div class="sh__foot">
<span>{{ data.ssl ? '🔒 SSL enabled' : '⚠ SSL disabled' }}</span>
<span>🕓 Timezone: {{ data.timezone || 'UTC' }}</span>
</div>
</AuroraCard>
</template>
<style scoped>
.sh__versions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
.sh__ver {
background: var(--aurora-bg-subtle);
border-radius: var(--aurora-radius-md);
padding: 12px 14px;
}
.sh__ver-label { font-size: 0.6875rem; color: var(--aurora-text-muted); }
.sh__ver-num { font-size: 1rem; font-weight: 600; color: var(--aurora-text); margin-top: 2px; }
.sh__bars { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; }
.sh__foot {
display: flex; justify-content: space-between;
margin-top: 16px; font-size: 0.6875rem; color: var(--aurora-text-muted);
}
</style>
+17
View File
@@ -0,0 +1,17 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import BarList from '../components/BarList.vue';
defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: '' },
items: { type: Array, default: () => [] },
emptyText: { type: String, default: 'No data in this range yet.' },
});
</script>
<template>
<AuroraCard :title="title" :subtitle="subtitle">
<BarList :items="items" :empty-text="emptyText" />
</AuroraCard>
</template>
@@ -0,0 +1,27 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import StatRow from '../components/StatRow.vue';
import AreaChart from '../components/AreaChart.vue';
defineProps({ data: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="User Analytics" subtitle="User registration trends">
<StatRow
:boxed="false"
:stats="[
{ value: data.total || 0, label: 'Total Users' },
{ value: data.inRange || 0, label: 'In Range' },
{ value: data.recent7d || 0, label: 'Recent (7d)' },
]"
/>
<div class="ua__chart">
<AreaChart :series="data.series || []" :height="150" />
</div>
</AuroraCard>
</template>
<style scoped>
.ua__chart { margin-top: 20px; }
</style>
@@ -0,0 +1,71 @@
<script setup>
import { computed } from 'vue';
// series: [{ date, count }]
const props = defineProps({
series: { type: Array, default: () => [] },
height: { type: Number, default: 140 },
});
const W = 600;
const PAD = 10;
const view = computed(() => {
const s = props.series;
if (!s.length) return null;
const max = Math.max(1, ...s.map((d) => d.count));
const H = props.height;
const innerW = W - PAD * 2;
const innerH = H - PAD * 2;
const stepX = s.length > 1 ? innerW / (s.length - 1) : 0;
const points = s.map((d, i) => {
const x = PAD + i * stepX;
const y = PAD + innerH - (d.count / max) * innerH;
return [x, y];
});
const line = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
const area = `${line} L${points[points.length - 1][0].toFixed(1)},${(H - PAD).toFixed(1)} L${points[0][0].toFixed(1)},${(H - PAD).toFixed(1)} Z`;
return { points, line, area, H };
});
</script>
<template>
<div class="areachart" v-if="view">
<svg :viewBox="`0 0 ${W} ${view.H}`" preserveAspectRatio="none" class="areachart__svg">
<defs>
<linearGradient id="aurora-area-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--aurora-accent)" stop-opacity="0.35" />
<stop offset="100%" stop-color="var(--aurora-accent)" stop-opacity="0" />
</linearGradient>
</defs>
<path :d="view.area" fill="url(#aurora-area-grad)" />
<path :d="view.line" fill="none" stroke="var(--aurora-accent)" stroke-width="2"
vector-effect="non-scaling-stroke" />
<circle v-for="(p, i) in view.points" :key="i" :cx="p[0]" :cy="p[1]" r="3"
fill="var(--aurora-accent)" vector-effect="non-scaling-stroke" />
</svg>
<div class="areachart__labels">
<span v-for="(d, i) in series" :key="i">{{ d.date }}</span>
</div>
</div>
</template>
<style scoped>
.areachart__svg {
width: 100%;
display: block;
}
.areachart__labels {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 0.6875rem;
color: var(--aurora-text-muted);
}
.areachart__labels span {
flex: 1;
text-align: center;
}
.areachart__labels span:first-child { text-align: left; }
.areachart__labels span:last-child { text-align: right; }
</style>
@@ -0,0 +1,75 @@
<script setup>
defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: '' },
});
</script>
<template>
<section class="acard">
<header class="acard__head">
<div class="acard__titles">
<h2 class="acard__title">{{ title }}</h2>
<p v-if="subtitle" class="acard__subtitle">{{ subtitle }}</p>
</div>
<div class="acard__head-right">
<slot name="head-right" />
</div>
</header>
<div class="acard__body">
<slot />
</div>
<footer v-if="$slots.footer" class="acard__foot">
<slot name="footer" />
</footer>
</section>
</template>
<style scoped>
.acard {
display: flex;
flex-direction: column;
height: 100%;
/* Deeper than the generic --aurora-card-bg — the Dashboard is now
wrapped in its own frame (dashboardFrame.js's .aurora-dashboard-frame,
using --aurora-frame-bg), and that's close in tone to --aurora-card-bg
for most presets, so cards would nearly disappear into it otherwise. */
background: var(--aurora-card-bg-deep, var(--aurora-card-bg));
border: 1px solid var(--aurora-card-border);
border-radius: var(--aurora-radius-lg);
padding: 20px;
box-sizing: border-box;
}
.acard__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.acard__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 600;
color: var(--aurora-text);
}
.acard__subtitle {
margin: 2px 0 0;
font-size: 0.8125rem;
color: var(--aurora-text-muted);
}
.acard__head-right {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--aurora-text-muted);
}
.acard__body {
flex: 1;
}
.acard__foot {
margin-top: 16px;
padding-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,44 @@
<script setup>
import { computed } from 'vue';
// items: [{ label, count }]
const props = defineProps({
items: { type: Array, default: () => [] },
emptyText: { type: String, default: 'No data yet.' },
});
const max = computed(() => Math.max(1, ...props.items.map((i) => i.count)));
</script>
<template>
<div class="barlist">
<div v-for="(it, i) in items" :key="i" class="barlist__row">
<div class="barlist__head">
<span class="barlist__label" :title="it.label">{{ it.label }}</span>
<span class="barlist__count">{{ it.count }}</span>
</div>
<div class="barlist__track">
<div class="barlist__fill" :style="{ width: (it.count / max) * 100 + '%' }" />
</div>
</div>
<p v-if="!items.length" class="barlist__empty">{{ emptyText }}</p>
</div>
</template>
<style scoped>
.barlist__row { margin-bottom: 12px; }
.barlist__head {
display: flex; justify-content: space-between; gap: 12px; margin-bottom: 5px;
}
.barlist__label {
font-size: 0.8125rem; color: var(--aurora-text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.barlist__count { font-size: 0.8125rem; color: var(--aurora-text-muted); flex-shrink: 0; }
.barlist__track {
height: 6px; border-radius: 999px;
background: rgba(127,127,127,0.18); overflow: hidden;
}
.barlist__fill { height: 100%; border-radius: 999px; background: var(--aurora-accent); }
.barlist__empty { font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 8px 0; }
</style>
@@ -0,0 +1,75 @@
<script setup>
defineProps({
label: { type: String, required: true },
pct: { type: Number, default: 0 },
detail: { type: String, default: '' },
gradient: { type: Boolean, default: false },
icon: { type: String, default: '' },
});
</script>
<template>
<div class="pbar">
<div class="pbar__top">
<span class="pbar__label">
<span v-if="icon" class="pbar__icon">{{ icon }}</span>{{ label }}
</span>
<span class="pbar__pct">{{ pct }}%</span>
</div>
<div class="pbar__track">
<div
class="pbar__fill"
:class="{ 'pbar__fill--gradient': gradient }"
:style="{ width: Math.min(100, pct) + '%' }"
/>
</div>
<div v-if="detail" class="pbar__detail">{{ detail }}</div>
</div>
</template>
<style scoped>
.pbar {
background: var(--aurora-bg-subtle);
border-radius: var(--aurora-radius-md);
padding: 12px 14px;
}
.pbar__top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.pbar__label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
font-weight: 500;
color: var(--aurora-text);
}
.pbar__icon { color: var(--aurora-text-muted); }
.pbar__pct {
font-size: 0.75rem;
font-weight: 600;
color: #34d399;
}
.pbar__track {
height: 6px;
border-radius: 999px;
background: rgba(127, 127, 127, 0.18);
overflow: hidden;
}
.pbar__fill {
height: 100%;
border-radius: 999px;
background: var(--aurora-accent);
}
.pbar__fill--gradient {
background: linear-gradient(90deg, #4f7bf5 0%, #a855f7 55%, #ef4444 100%);
}
.pbar__detail {
margin-top: 6px;
font-size: 0.6875rem;
color: var(--aurora-text-muted);
}
</style>
@@ -0,0 +1,41 @@
<script setup>
// stats: [{ value, label }]
defineProps({
stats: { type: Array, required: true },
boxed: { type: Boolean, default: true },
});
</script>
<template>
<div class="statrow" :class="{ 'statrow--boxed': boxed }">
<div v-for="(s, i) in stats" :key="i" class="statrow__item">
<div class="statrow__value">{{ s.value }}</div>
<div class="statrow__label">{{ s.label }}</div>
</div>
</div>
</template>
<style scoped>
.statrow {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
text-align: center;
}
.statrow--boxed {
background: var(--aurora-bg-subtle);
border-radius: var(--aurora-radius-md);
padding: 16px 8px;
}
.statrow__value {
font-size: 1.5rem;
font-weight: 700;
color: var(--aurora-text);
line-height: 1.1;
}
.statrow__label {
margin-top: 4px;
font-size: 0.75rem;
color: var(--aurora-text-muted);
}
</style>
@@ -0,0 +1,34 @@
import { ref } from 'vue';
/** Fetches aggregated analytics from aurora-admin/v1/analytics for a range. */
export function useAnalyticsData(serverData) {
const data = ref(null);
const loading = ref(false);
const error = ref(false);
const fmt = (d) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
const load = async (from, to) => {
loading.value = true;
error.value = false;
try {
const url = new URL(`${serverData.restUrl}aurora-admin/v1/analytics`);
url.searchParams.set('from', fmt(from));
url.searchParams.set('to', fmt(to));
const res = await fetch(url, { headers: { 'X-WP-Nonce': serverData.restNonce } });
if (!res.ok) throw new Error('Request failed');
data.value = await res.json();
} catch {
error.value = true;
} finally {
loading.value = false;
}
};
return { data, loading, error, load };
}
@@ -0,0 +1,39 @@
import { ref } from 'vue';
/**
* Fetches all dashboard card data from aurora-admin/v1/dashboard for a
* given date range. Re-callable when the range changes.
*/
export function useDashboardData(serverData) {
const data = ref(null);
const loading = ref(false);
const error = ref(false);
const fmt = (d) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
const load = async (from, to) => {
loading.value = true;
error.value = false;
try {
const url = new URL(`${serverData.restUrl}aurora-admin/v1/dashboard`);
url.searchParams.set('from', fmt(from));
url.searchParams.set('to', fmt(to));
const res = await fetch(url, {
headers: { 'X-WP-Nonce': serverData.restNonce },
});
if (!res.ok) throw new Error('Request failed');
data.value = await res.json();
} catch {
error.value = true;
} finally {
loading.value = false;
}
};
return { data, loading, error, load };
}
+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>
+148
View File
@@ -0,0 +1,148 @@
<script setup>
import { ref } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const files = ref([]);
const dragging = ref(false);
const fileInput = ref(null);
const addFiles = (fileList) => {
files.value.push(
...Array.from(fileList).map((file) => ({
file,
name: file.name,
status: 'pending', // pending | uploading | done | error
message: '',
url: '',
}))
);
};
const onDrop = (e) => {
dragging.value = false;
addFiles(e.dataTransfer.files);
};
const onPick = (e) => {
addFiles(e.target.files);
e.target.value = '';
};
const uploadOne = async (entry) => {
entry.status = 'uploading';
try {
const res = await fetch(`${props.serverData.restUrl}wp/v2/media`, {
method: 'POST',
headers: {
'X-WP-Nonce': props.serverData.restNonce,
'Content-Disposition': `attachment; filename="${entry.file.name}"`,
'Content-Type': entry.file.type || 'application/octet-stream',
},
body: entry.file,
});
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Upload failed.');
entry.status = 'done';
entry.url = data.source_url || '';
} catch (e) {
entry.status = 'error';
entry.message = e.message || 'Upload failed.';
}
};
const uploadAll = () => {
files.value.filter((f) => f.status === 'pending').forEach(uploadOne);
};
const clearDone = () => {
files.value = files.value.filter((f) => f.status !== 'done');
};
</script>
<template>
<main class="media-upload-page">
<header class="media-upload-page__header">
<div>
<p class="media-upload-page__eyebrow">Media</p>
<h1>Add Media File</h1>
</div>
<a class="button" :href="serverData.libraryUrl">Back to Media Library</a>
</header>
<div
class="media-upload-drop"
:class="{ 'media-upload-drop--active': dragging }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
@click="fileInput.click()"
>
<span class="dashicons dashicons-upload" />
<p>Drop files here, or click to select files</p>
<input ref="fileInput" type="file" multiple hidden @change="onPick" />
</div>
<section v-if="files.length" class="media-upload-list">
<div class="media-upload-list__actions">
<button type="button" class="button-primary" @click="uploadAll">Upload All</button>
<button type="button" class="button" @click="clearDone">Clear Completed</button>
</div>
<div v-for="entry in files" :key="entry.name + entry.file.lastModified" class="media-upload-item">
<span class="media-upload-item__name">{{ entry.name }}</span>
<span class="media-upload-item__status" :class="`media-upload-item__status--${entry.status}`">
<template v-if="entry.status === 'pending'">Ready</template>
<template v-else-if="entry.status === 'uploading'">Uploading…</template>
<template v-else-if="entry.status === 'done'">
Uploaded — <a :href="entry.url" target="_blank" rel="noopener">View</a>
</template>
<template v-else>{{ entry.message }}</template>
</span>
</div>
</section>
</main>
</template>
<style scoped>
.media-upload-page { max-width: 720px; margin: 0 auto; }
.media-upload-page__header {
display: flex; align-items: flex-start; justify-content: space-between;
margin-bottom: 20px;
}
.media-upload-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.media-upload-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.media-upload-drop {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px; padding: 48px 24px; text-align: center; cursor: pointer;
border: 2px dashed var(--aurora-border);
border-radius: var(--aurora-radius-md);
color: var(--aurora-text-muted);
transition: border-color 0.15s, color 0.15s;
}
.media-upload-drop .dashicons { font-size: 32px; width: 32px; height: 32px; }
.media-upload-drop--active,
.media-upload-drop:hover { border-color: var(--aurora-accent); color: var(--aurora-text); }
.media-upload-list { margin-top: 20px; }
.media-upload-list__actions { display: flex; gap: 8px; margin-bottom: 12px; }
.media-upload-item {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
margin-bottom: 6px; font-size: 0.875rem;
}
.media-upload-item__name { color: var(--aurora-text); }
.media-upload-item__status { color: var(--aurora-text-muted); }
.media-upload-item__status--done { color: #22c55e; }
.media-upload-item__status--error { color: #ef4444; }
.media-upload-item__status a { color: inherit; text-decoration: underline; }
</style>
+838
View File
@@ -0,0 +1,838 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue';
import MediaFolderTree from './MediaFolderTree.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
// Media Folders is a core feature — always on, no gating.
const foldersAvailable = true;
const items = ref([]);
const stats = ref({ all: 0, image: 0, audio: 0, video: 0, application: 0, unattached: 0 });
const pagination = ref({ page: 1, perPage: 24, total: 0, totalPages: 1 });
const loading = ref(true);
const busyId = ref(0);
const error = ref('');
const notice = ref('');
const search = ref('');
const type = ref('all');
// Media Folders (paid) state — folderId 0 = All Media, -1 = Uncategorized,
// matching MediaFolders::ALL_MEDIA / ::UNCATEGORIZED on the PHP side.
const folderId = ref(0);
const folderTree = ref([]);
const allMediaCount = ref(0);
const uncategorizedCount = ref(0);
const foldersLoading = ref(false);
const expanded = reactive({});
const moveMenuFor = ref(0);
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
const request = async (path, options = {}) => {
const response = await fetch(`${apiBase.value}${path}`, {
credentials: 'same-origin',
...options,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(options.headers || {}),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.message || data.error || 'Request failed');
}
return data;
};
const load = async () => {
loading.value = true;
error.value = '';
try {
const params = new URLSearchParams({
type: type.value,
search: search.value.trim(),
page: String(pagination.value.page),
perPage: String(pagination.value.perPage),
});
params.set('folderId', String(folderId.value));
const data = await request(`/media?${params.toString()}`);
items.value = data.items || [];
stats.value = data.stats || stats.value;
pagination.value = data.pagination || pagination.value;
} catch (err) {
error.value = err.message || 'Could not load media';
} finally {
loading.value = false;
}
};
const loadFolders = async () => {
foldersLoading.value = true;
try {
const data = await request('/media-folders');
folderTree.value = data.tree || [];
allMediaCount.value = data.allMediaCount || 0;
uncategorizedCount.value = data.uncategorizedCount || 0;
} catch (err) {
error.value = err.message || 'Could not load folders';
} finally {
foldersLoading.value = false;
}
};
const flattenFolders = (nodes, depth = 0, out = []) => {
for (const node of nodes) {
out.push({ id: node.id, name: node.name, depth });
if (node.children && node.children.length) flattenFolders(node.children, depth + 1, out);
}
return out;
};
const flatFolders = computed(() => flattenFolders(folderTree.value));
const selectFolder = (id) => {
folderId.value = id;
pagination.value.page = 1;
load();
};
const toggleFolder = (id) => {
expanded[id] = !expanded[id];
};
const createFolder = async (parent) => {
const name = window.prompt('New folder name:');
if (!name) return;
try {
await request('/media-folders', { method: 'POST', body: JSON.stringify({ name, parent }) });
if (parent) expanded[parent] = true;
await loadFolders();
} catch (err) {
error.value = err.message || 'Could not create folder';
}
};
const renameFolder = async (node) => {
const name = window.prompt('Rename folder:', node.name);
if (!name || name === node.name) return;
try {
await request(`/media-folders/${node.id}`, { method: 'POST', body: JSON.stringify({ name }) });
await loadFolders();
} catch (err) {
error.value = err.message || 'Could not rename folder';
}
};
const deleteFolder = async (node) => {
if (!window.confirm(`Delete "${node.name}"? Its media will become uncategorized.`)) return;
try {
await request(`/media-folders/${node.id}`, { method: 'DELETE' });
if (folderId.value === node.id) selectFolder(0);
else await loadFolders();
} catch (err) {
error.value = err.message || 'Could not delete folder';
}
};
const reparentFolder = async ({ id, parent }) => {
try {
await request(`/media-folders/${id}`, { method: 'POST', body: JSON.stringify({ parent }) });
await loadFolders();
} catch (err) {
error.value = err.message || 'Could not move folder';
}
};
const assignToFolder = async (folderIdTarget, attachmentIds) => {
try {
await request('/media-folders/assign', {
method: 'POST',
body: JSON.stringify({ folderId: folderIdTarget, attachmentIds }),
});
moveMenuFor.value = 0;
await Promise.all([load(), loadFolders()]);
} catch (err) {
error.value = err.message || 'Could not move media';
}
};
onMounted(() => {
load();
loadFolders();
});
let searchTimer = 0;
watch(search, () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
pagination.value.page = 1;
load();
}, 250);
});
const setType = (next) => {
type.value = next;
pagination.value.page = 1;
load();
};
const filters = computed(() => [
{ key: 'all', label: 'All', count: stats.value.all },
{ key: 'image', label: 'Images', count: stats.value.image },
{ key: 'audio', label: 'Audio', count: stats.value.audio },
{ key: 'video', label: 'Video', count: stats.value.video },
{ key: 'application', label: 'Documents', count: stats.value.application },
{ key: 'unattached', label: 'Unattached', count: stats.value.unattached },
]);
const deleteItem = async (item) => {
if (!item || busyId.value) return;
if (!window.confirm(`Permanently delete "${item.title}"? This cannot be undone.`)) return;
busyId.value = item.id;
error.value = '';
notice.value = '';
try {
const data = await request(`/media/${item.id}`, { method: 'DELETE' });
notice.value = data.message || 'Media item deleted.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const goPage = (page) => {
const next = Math.min(Math.max(1, page), pagination.value.totalPages || 1);
if (next === pagination.value.page) return;
pagination.value.page = next;
load();
};
const rowRange = computed(() => {
if (!pagination.value.total) return '0 to 0';
const start = (pagination.value.page - 1) * pagination.value.perPage + 1;
const end = Math.min(pagination.value.total, pagination.value.page * pagination.value.perPage);
return `${start} to ${end}`;
});
const typeIcon = (mimeType) => {
const group = (mimeType || '').split('/')[0];
if (group === 'audio') return 'dashicons-format-audio';
if (group === 'video') return 'dashicons-format-video';
if (mimeType === 'application/pdf') return 'dashicons-pdf';
return 'dashicons-media-document';
};
</script>
<template>
<main class="media-page">
<header class="media-page__header">
<div>
<p class="media-page__eyebrow">Content</p>
<h1>Media Library</h1>
</div>
<div class="media-page__actions">
<a class="button-primary" :href="serverData.newMediaUrl">Add Media File</a>
<button type="button" class="icon-button" title="Refresh media" :disabled="loading" @click="load">
<span class="dashicons dashicons-update" />
</button>
</div>
</header>
<div class="media-page__body">
<aside class="media-page__sidebar">
<div class="mf-pinned">
<button
type="button"
class="mf-pinned__row"
:class="{ 'mf-pinned__row--active': folderId === 0 }"
@dragover.prevent
@drop.prevent="
(e) => {
const p = e.dataTransfer.getData('text/plain');
if (p.startsWith('aurora-attachments:')) assignToFolder(0, p.split(':')[1].split(',').map(Number));
}
"
@click="selectFolder(0)"
>
<span class="dashicons dashicons-admin-media" />
<span>All Media</span>
<span class="mf-pinned__count">{{ allMediaCount }}</span>
</button>
<button
type="button"
class="mf-pinned__row"
:class="{ 'mf-pinned__row--active': folderId === -1 }"
@dragover.prevent
@drop.prevent="
(e) => {
const p = e.dataTransfer.getData('text/plain');
if (p.startsWith('aurora-attachments:')) assignToFolder(-1, p.split(':')[1].split(',').map(Number));
}
"
@click="selectFolder(-1)"
>
<span class="dashicons dashicons-editor-help" />
<span>Uncategorized</span>
<span class="mf-pinned__count">{{ uncategorizedCount }}</span>
</button>
</div>
<div class="mf-sidebar__header">
<span>Folders</span>
<button type="button" class="icon-button icon-button--tiny" title="New folder" @click="createFolder(0)">
<span class="dashicons dashicons-plus-alt2" />
</button>
</div>
<p v-if="foldersLoading" class="mf-sidebar__loading">Loading folders…</p>
<p v-else-if="!folderTree.length" class="mf-sidebar__empty">No folders yet.</p>
<MediaFolderTree
v-else
:nodes="folderTree"
:active-folder-id="folderId"
:expanded="expanded"
@select="selectFolder"
@toggle="toggleFolder"
@create-child="createFolder"
@rename="renameFolder"
@delete="deleteFolder"
@reparent="reparentFolder"
@assign="({ folderId: fid, attachmentIds }) => assignToFolder(fid, attachmentIds)"
/>
</aside>
<div class="media-page__main">
<section class="media-page__toolbar" aria-label="Media filters and search">
<div class="media-page__filters">
<button
v-for="filter in filters"
:key="filter.key"
type="button"
class="filter-pill"
:class="{ 'is-active': type === filter.key }"
@click="setType(filter.key)"
>
<span>{{ filter.label }}</span>
<strong>{{ filter.count }}</strong>
</button>
</div>
<label class="media-page__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Search media..." />
</label>
</section>
<div v-if="notice" class="media-notice media-notice--success">{{ notice }}</div>
<div v-if="error" class="media-notice media-notice--error">{{ error }}</div>
<div v-if="loading" class="media-grid__empty">Loading media...</div>
<div v-else-if="!items.length" class="media-grid__empty">No media items match your filters.</div>
<section v-else class="media-grid" aria-label="Media items">
<article
v-for="item in items"
:key="item.id"
class="media-card"
:draggable="foldersAvailable"
@dragstart="foldersAvailable && $event.dataTransfer.setData('text/plain', `aurora-attachments:${item.id}`)"
>
<div class="media-card__preview">
<img v-if="item.isImage && item.thumbnailUrl" :src="item.thumbnailUrl" :alt="item.title" />
<span v-else class="dashicons media-card__icon" :class="typeIcon(item.mimeType)" />
<div class="media-card__overlay">
<a class="icon-button icon-button--inline" title="Edit" :href="item.editUrl">
<span class="dashicons dashicons-edit" />
</a>
<a
class="icon-button icon-button--inline"
title="View file"
:href="item.fileUrl"
target="_blank"
rel="noopener"
>
<span class="dashicons dashicons-visibility" />
</a>
<button
v-if="foldersAvailable"
type="button"
class="icon-button icon-button--inline"
title="Move to folder"
@click.stop="moveMenuFor = moveMenuFor === item.id ? 0 : item.id"
>
<span class="dashicons dashicons-portfolio" />
</button>
<button
v-if="item.canDelete"
type="button"
class="icon-button icon-button--inline"
title="Delete permanently"
:disabled="busyId === item.id"
@click="deleteItem(item)"
>
<span class="dashicons dashicons-trash" />
</button>
</div>
<div v-if="moveMenuFor === item.id" class="media-card__move-menu" @click.stop>
<button type="button" @click="assignToFolder(0, [item.id])">Uncategorized</button>
<button
v-for="f in flatFolders"
:key="f.id"
type="button"
:style="{ paddingLeft: 10 + f.depth * 14 + 'px' }"
@click="assignToFolder(f.id, [item.id])"
>
{{ f.name }}
</button>
</div>
</div>
<div class="media-card__meta">
<strong :title="item.title">{{ item.title }}</strong>
<span>{{ item.typeLabel }}<template v-if="item.dimensions"> · {{ item.dimensions }}</template></span>
<span v-if="item.attachedToTitle">
Attached to
<a v-if="item.attachedToEditUrl" :href="item.attachedToEditUrl">{{ item.attachedToTitle }}</a>
<template v-else>{{ item.attachedToTitle }}</template>
</span>
<span v-else class="media-card__unattached">Unattached</span>
<span>{{ item.date }}<template v-if="item.fileSize"> · {{ item.fileSize }}</template></span>
</div>
</article>
</section>
<footer v-if="!loading && items.length" class="media-page__footer">
<div>Page Size: {{ pagination.perPage }}</div>
<div>{{ rowRange }} of {{ pagination.total }}</div>
<div class="media-page__pager">
<button type="button" :disabled="pagination.page <= 1" @click="goPage(1)">
<span class="dashicons dashicons-controls-skipback" />
</button>
<button type="button" :disabled="pagination.page <= 1" @click="goPage(pagination.page - 1)">
<span class="dashicons dashicons-arrow-left-alt2" />
</button>
<span>Page {{ pagination.page }} of {{ pagination.totalPages }}</span>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.page + 1)"
>
<span class="dashicons dashicons-arrow-right-alt2" />
</button>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.totalPages)"
>
<span class="dashicons dashicons-controls-skipforward" />
</button>
</div>
</footer>
</div>
</div>
</main>
</template>
<style scoped>
.media-page {
min-height: calc(100vh - 112px);
padding: 0;
color: var(--aurora-text);
background: transparent;
}
.media-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 24px;
}
.media-page__eyebrow {
margin: 0 0 7px;
color: var(--aurora-text-muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.media-page h1 {
margin: 0;
color: var(--aurora-text);
font-size: 1.45rem;
line-height: 1.15;
}
.media-page__actions,
.media-page__toolbar,
.media-page__filters,
.media-page__pager {
display: flex;
align-items: center;
}
.media-page__actions {
gap: 10px;
}
.button-primary {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 16px;
border-radius: var(--aurora-radius-sm);
background: var(--aurora-accent);
color: var(--aurora-accent-text);
font-size: 0.84rem;
font-weight: 700;
text-decoration: none;
}
.media-page__toolbar {
justify-content: space-between;
gap: 18px;
margin-bottom: 16px;
}
.media-page__filters {
flex-wrap: wrap;
gap: 8px;
}
.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 12px;
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
}
.filter-pill strong {
min-width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--aurora-frame-bg);
color: inherit;
font-size: 0.72rem;
}
.filter-pill.is-active {
border-color: var(--aurora-text-muted);
background: var(--aurora-frame-bg);
color: var(--aurora-text);
}
.media-page__search {
width: min(320px, 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);
}
.media-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;
}
.media-page__search input::placeholder {
color: var(--aurora-text-muted);
}
.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;
text-decoration: none;
}
.icon-button:hover:not(:disabled) {
border-color: var(--aurora-text-muted);
color: var(--aurora-text);
}
.icon-button--inline {
width: 34px;
min-height: 34px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.45);
color: #fff;
}
.icon-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.media-notice {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
font-size: 0.78rem;
font-weight: 750;
}
.media-grid__empty {
padding: 60px 18px;
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-sm);
background: var(--aurora-frame-bg);
color: var(--aurora-text-muted);
font-size: 0.85rem;
text-align: center;
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.media-card {
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-md);
background: var(--aurora-frame-bg);
overflow: hidden;
}
.media-card__preview {
position: relative;
aspect-ratio: 1;
background: var(--aurora-bg-subtle);
display: flex;
align-items: center;
justify-content: center;
}
.media-card__preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.media-card__icon {
font-size: 46px;
width: 46px;
height: 46px;
color: var(--aurora-text-muted);
}
.media-card__overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.15s ease;
}
.media-card:hover .media-card__overlay {
opacity: 1;
}
.media-card__meta {
padding: 10px 12px 12px;
display: flex;
flex-direction: column;
gap: 3px;
}
.media-card__meta strong {
overflow: hidden;
color: var(--aurora-text);
font-size: 0.8rem;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-card__meta span {
overflow: hidden;
color: var(--aurora-text-muted);
font-size: 0.72rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-card__meta a {
color: var(--aurora-accent);
text-decoration: none;
}
.media-card__unattached {
font-style: italic;
}
.media-page__footer {
min-height: 54px;
margin-top: 18px;
border-top: 1px solid var(--aurora-border);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 26px;
padding: 14px 4px 0;
color: var(--aurora-text-muted);
font-size: 0.8rem;
}
.media-page__pager {
gap: 8px;
}
.media-page__pager button {
width: 28px;
height: 28px;
border: 0;
border-radius: var(--aurora-radius-sm);
background: transparent;
color: var(--aurora-text-muted);
cursor: pointer;
}
.media-page__pager button:not(:disabled):hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.media-page__pager button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 1080px) {
.media-page__header,
.media-page__toolbar,
.media-page__footer {
align-items: stretch;
flex-direction: column;
}
.media-page__search {
width: 100%;
}
.media-page__footer {
gap: 10px;
}
.media-page__body {
flex-direction: column;
}
.media-page__sidebar {
width: 100%;
}
}
.media-page__body {
display: flex;
align-items: flex-start;
gap: 20px;
}
.media-page__sidebar {
flex-shrink: 0;
width: 220px;
padding: 12px;
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-md);
background: var(--aurora-frame-bg);
}
.media-page__main {
flex: 1;
min-width: 0;
}
.mf-pinned {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--aurora-border);
}
.mf-pinned__row {
display: flex;
align-items: center;
gap: 8px;
border: none;
border-radius: var(--aurora-radius-sm);
background: none;
padding: 6px 6px;
color: var(--aurora-text);
font-size: 0.8125rem;
cursor: pointer;
text-align: left;
}
.mf-pinned__row:hover {
background: var(--aurora-bg-subtle);
}
.mf-pinned__row--active {
background: var(--aurora-accent-soft);
}
.mf-pinned__row .dashicons {
font-size: 15px;
width: 15px;
height: 15px;
color: var(--aurora-text-muted);
}
.mf-pinned__row span:nth-child(2) {
flex: 1;
}
.mf-pinned__count {
font-size: 0.7rem;
color: var(--aurora-text-muted);
}
.mf-sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
color: var(--aurora-text-muted);
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
}
.icon-button--tiny {
width: 22px;
min-height: 22px;
border: none;
background: none;
color: var(--aurora-text-muted);
}
.icon-button--tiny:hover {
color: var(--aurora-text);
}
.mf-sidebar__loading,
.mf-sidebar__empty {
margin: 0;
color: var(--aurora-text-muted);
font-size: 0.75rem;
}
.media-card__move-menu {
position: absolute;
z-index: 10;
right: 8px;
bottom: 8px;
max-height: 160px;
min-width: 150px;
overflow-y: auto;
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-sm);
background: var(--aurora-frame-bg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
padding: 4px;
}
.media-card__move-menu button {
display: block;
width: 100%;
padding: 6px 10px;
border: none;
background: none;
border-radius: var(--aurora-radius-sm);
color: var(--aurora-text);
font-size: 0.78rem;
text-align: left;
cursor: pointer;
}
.media-card__move-menu button:hover {
background: var(--aurora-bg-subtle);
}
</style>
+193
View File
@@ -0,0 +1,193 @@
<script setup>
import { ref } from 'vue';
const props = defineProps({
nodes: { type: Array, required: true },
activeFolderId: { type: Number, required: true },
expanded: { type: Object, required: true },
});
const emit = defineEmits(['select', 'toggle', 'create-child', 'rename', 'delete', 'reparent', 'assign']);
// Drag-and-drop reparenting (folder onto folder) and media-into-folder both
// go through the same 'text/plain' payload, prefixed so drop handlers can
// tell them apart — custom MIME types are unreliable to read during
// dragover in some browsers, plain text is not.
const dragOverId = ref(0);
const onDragStart = (event, node) => {
event.dataTransfer.setData('text/plain', `aurora-folder:${node.id}`);
event.dataTransfer.effectAllowed = 'move';
};
const onDragOver = (event, node) => {
event.preventDefault();
dragOverId.value = node.id;
};
const onDragLeave = (node) => {
if (dragOverId.value === node.id) dragOverId.value = 0;
};
const onDrop = (event, node) => {
event.preventDefault();
dragOverId.value = 0;
const payload = event.dataTransfer.getData('text/plain');
if (!payload) return;
if (payload.startsWith('aurora-folder:')) {
const draggedId = Number(payload.split(':')[1]);
if (draggedId && draggedId !== node.id) {
emit('reparent', { id: draggedId, parent: node.id });
}
} else if (payload.startsWith('aurora-attachments:')) {
const ids = payload
.split(':')[1]
.split(',')
.map(Number)
.filter(Boolean);
if (ids.length) {
emit('assign', { folderId: node.id, attachmentIds: ids });
}
}
};
const contextMenu = ref({ open: false, x: 0, y: 0, node: null });
const openMenu = (event, node) => {
contextMenu.value = { open: true, x: event.clientX, y: event.clientY, node };
setTimeout(() => document.addEventListener('click', closeMenu), 0);
};
const closeMenu = () => {
contextMenu.value.open = false;
document.removeEventListener('click', closeMenu);
};
const runFromMenu = (fn) => {
closeMenu();
fn();
};
</script>
<template>
<ul class="mf-tree">
<li v-for="node in nodes" :key="node.id">
<div
class="mf-tree__row"
:class="{ 'mf-tree__row--active': node.id === activeFolderId, 'mf-tree__row--dragover': dragOverId === node.id }"
draggable="true"
@dragstart="onDragStart($event, node)"
@dragover="onDragOver($event, node)"
@dragleave="onDragLeave(node)"
@drop="onDrop($event, node)"
@contextmenu.prevent="openMenu($event, node)"
>
<button
v-if="node.children && node.children.length"
type="button"
class="mf-tree__toggle"
@click="emit('toggle', node.id)"
>
<span class="dashicons dashicons-arrow-right-alt2" :class="{ 'mf-tree__toggle-icon--open': expanded[node.id] }" />
</button>
<span v-else class="mf-tree__toggle-spacer" />
<button type="button" class="mf-tree__label" @click="emit('select', node.id)">
<span class="dashicons dashicons-portfolio" :style="node.color ? { color: node.color } : {}" />
<span class="mf-tree__name">{{ node.name }}</span>
<span class="mf-tree__count">{{ node.count }}</span>
</button>
</div>
<MediaFolderTree
v-if="expanded[node.id] && node.children && node.children.length"
:nodes="node.children"
:active-folder-id="activeFolderId"
:expanded="expanded"
@select="emit('select', $event)"
@toggle="emit('toggle', $event)"
@create-child="emit('create-child', $event)"
@rename="emit('rename', $event)"
@delete="emit('delete', $event)"
@reparent="emit('reparent', $event)"
@assign="emit('assign', $event)"
/>
</li>
</ul>
<Teleport to="body">
<ul v-if="contextMenu.open" class="mf-context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }" @contextmenu.prevent>
<li><button type="button" @click="runFromMenu(() => emit('create-child', contextMenu.node.id))"><span class="dashicons dashicons-plus" /> New Subfolder</button></li>
<li><button type="button" @click="runFromMenu(() => emit('rename', contextMenu.node))"><span class="dashicons dashicons-edit" /> Rename</button></li>
<li class="mf-context-menu__sep" />
<li><button type="button" class="mf-context-menu__danger" @click="runFromMenu(() => emit('delete', contextMenu.node))"><span class="dashicons dashicons-trash" /> Delete</button></li>
</ul>
</Teleport>
</template>
<style scoped>
.mf-tree { list-style: none; margin: 0; padding-left: 14px; }
.mf-tree:first-of-type { padding-left: 0; }
.mf-tree__row {
display: flex; align-items: center; gap: 2px; border-radius: var(--aurora-radius-sm);
}
.mf-tree__row:hover { background: var(--aurora-bg-subtle); }
.mf-tree__row--active { background: var(--aurora-accent-soft); }
.mf-tree__row--dragover { outline: 2px dashed var(--aurora-accent); outline-offset: -2px; }
.mf-tree__toggle,
.mf-tree__toggle-spacer {
width: 18px; height: 24px; flex-shrink: 0;
}
.mf-tree__toggle {
border: none; background: none; cursor: pointer;
color: var(--aurora-text-muted); display: inline-flex; align-items: center; justify-content: center;
}
.mf-tree__toggle .dashicons { font-size: 14px; width: 14px; height: 14px; transition: transform 0.15s; }
.mf-tree__toggle-icon--open { transform: rotate(90deg); }
.mf-tree__label {
flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px;
border: none; background: none; cursor: pointer; text-align: left;
padding: 3px 4px; color: var(--aurora-text); font-size: 0.8125rem;
}
.mf-tree__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.mf-tree__label .dashicons:first-child { color: var(--aurora-text-muted); flex-shrink: 0; font-size: 15px; width: 15px; height: 15px; }
.mf-tree__count { font-size: 0.7rem; color: var(--aurora-text-muted); flex-shrink: 0; }
</style>
<style>
.mf-context-menu {
position: fixed;
z-index: 100000;
min-width: 170px;
margin: 0;
padding: 4px;
list-style: none;
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-sm);
background: var(--aurora-frame-bg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.mf-context-menu button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
background: none;
border-radius: var(--aurora-radius-sm);
color: var(--aurora-text);
font-size: 0.8rem;
text-align: left;
cursor: pointer;
}
.mf-context-menu button:hover { background: var(--aurora-bg-subtle); }
.mf-context-menu button .dashicons { font-size: 16px; width: 16px; height: 16px; color: var(--aurora-text-muted); }
.mf-context-menu__danger { color: #ef4444 !important; }
.mf-context-menu__danger .dashicons { color: #ef4444 !important; }
.mf-context-menu__sep {
height: 1px;
margin: 4px 6px;
background: var(--aurora-border);
}
</style>
+313
View File
@@ -0,0 +1,313 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import AppListShell from '../_shared/AppListShell.vue';
import AppListItem from '../_shared/AppListItem.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path, opts = {}) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(opts.headers || {}),
},
}).then((r) => {
if (!r.ok) throw new Error('Request failed');
return r.json();
});
const menus = ref([]);
const nativeItems = ref([]);
const loading = ref(true);
const search = ref('');
const statusFilter = ref('all');
const selectedId = ref(null);
const roleOptions = ref([]);
const load = async () => {
loading.value = true;
try {
menus.value = await api('/menus');
} finally {
loading.value = false;
}
};
onMounted(() => {
load();
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(() => {
let list = menus.value;
if (statusFilter.value !== 'all') {
list = list.filter((m) => m.status === statusFilter.value);
}
const q = search.value.trim().toLowerCase();
if (q) list = list.filter((m) => m.name.toLowerCase().includes(q));
return list;
});
const selected = computed(() => menus.value.find((m) => m.id === selectedId.value) || null);
const draft = reactive({ name: '', status: 'draft', roles: [] });
// 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] });
buildOrdered(menu.items);
};
const isCreating = ref(false);
const newMenu = () => {
selectedId.value = null;
isCreating.value = true;
Object.assign(draft, { name: 'New menu', status: 'draft', roles: [] });
buildOrdered([]);
};
// 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 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,
// 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();
selectMenu(created);
isCreating.value = false;
} else if (selectedId.value) {
await api(`/menus/${selectedId.value}`, { method: 'PUT', body: JSON.stringify(payload) });
await load();
}
} finally {
saving.value = false;
}
};
const remove = async () => {
if (!selectedId.value) return;
await api(`/menus/${selectedId.value}`, { method: 'DELETE' });
selectedId.value = null;
await load();
};
const toggleRole = (slug) => {
const idx = draft.roles.indexOf(slug);
if (idx === -1) draft.roles.push(slug);
else draft.roles.splice(idx, 1);
};
</script>
<template>
<AppListShell
v-model:search="search"
title="Menu Creator"
search-placeholder="Search menus…"
:count-label="`${filteredMenus.length} MENUS`"
detail-icon="dashicons-menu-alt"
detail-title="Menu Editor"
detail-text="Select a menu from the list to edit its structure and settings."
:has-selection="!!selected || isCreating"
>
<template #header-actions>
<button type="button" @click="newMenu">+</button>
</template>
<template #filters>
<button
v-for="f in ['all', 'published', 'draft']"
:key="f"
type="button"
:class="{ 'is-active': statusFilter === f }"
@click="statusFilter = f"
>
{{ f === 'all' ? 'All' : f[0].toUpperCase() + f.slice(1) }}
</button>
</template>
<template #list>
<p v-if="!loading && !filteredMenus.length" style="font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 20px 4px; text-align: center;">
No menus yet
</p>
<AppListItem
v-for="m in filteredMenus"
:key="m.id"
icon="dashicons-menu-alt"
:title="m.name"
:subtitle="m.status"
:active="m.id === selectedId"
@click="selectMenu(m)"
/>
</template>
<template #detail>
<div class="menu-editor">
<div class="menu-editor__row">
<input v-model="draft.name" type="text" class="menu-editor__name" placeholder="Menu name" />
<select v-model="draft.status" class="menu-editor__status">
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</div>
<div class="menu-editor__section">
<h3>Target roles</h3>
<div class="menu-editor__roles">
<label v-for="r in roleOptions" :key="r.value" class="menu-editor__role">
<input type="checkbox" :checked="draft.roles.includes(r.value)" @change="toggleRole(r.value)" />
{{ r.label }}
</label>
</div>
</div>
<div class="menu-editor__section">
<h3>Menu items — drag to reorder</h3>
<div class="menu-editor__items">
<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" 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>
<div class="menu-editor__actions">
<button type="button" class="is-danger" :disabled="!selectedId" @click="remove">Delete</button>
<button type="button" class="is-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</template>
</AppListShell>
</template>
<style scoped>
.menu-editor { display: flex; flex-direction: column; gap: 22px; }
.menu-editor__row { display: flex; gap: 10px; }
.menu-editor__name {
flex: 1; font-size: 1.1rem; font-weight: 700; padding: 8px 10px;
border: 1px solid var(--aurora-border); border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.menu-editor__status {
padding: 8px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.menu-editor__section h3 {
font-size: 0.8125rem; text-transform: uppercase; letter-spacing: 0.03em;
color: var(--aurora-text-muted); margin: 0 0 10px;
}
.menu-editor__roles { display: flex; flex-wrap: wrap; gap: 12px; }
.menu-editor__role { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; color: var(--aurora-text); }
.menu-editor__items { display: flex; flex-direction: column; gap: 6px; max-height: 360px; overflow-y: auto; }
.menu-editor__item {
display: flex; align-items: center; gap: 10px; padding: 6px 4px;
border-bottom: 1px solid var(--aurora-frame-border);
}
.menu-editor__item.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); 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;
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.menu-editor__actions .is-primary { background: var(--aurora-accent); color: #fff; }
.menu-editor__actions .is-danger { color: #ef4444; }
.menu-editor__actions button:disabled { opacity: 0.5; cursor: default; }
</style>
+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',
},
];
@@ -0,0 +1,215 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import SettingsField from '@/pages/settings/components/SettingsField.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
// Permalinks (rewrite rules) and Discussion (core's REST settings schema
// only exposes 2 of its ~15 real options — see DiscussionData.php) both
// have their own aurora-admin/v1 routes instead of going through
// wp/v2/settings like every other native-settings screen.
const isPermalinks = props.serverData.screen === 'permalinks';
const isDiscussion = props.serverData.screen === 'discussion';
const values = reactive({ ...(props.serverData.values || {}) });
// Mirrors options-permalink.php's own preset list — the actual reason
// people visit this screen; the "Custom Structure" text field alone
// (this screen's only original field) skipped the part of the native
// page virtually everyone actually uses instead of hand-writing tags.
const permalinkPresets = [
{ key: 'plain', label: 'Plain', structure: '', example: '?p=123' },
{
key: 'day',
label: 'Day and name',
structure: '/%year%/%monthnum%/%day%/%postname%/',
example: '/2026/01/28/sample-post/',
},
{
key: 'month',
label: 'Month and name',
structure: '/%year%/%monthnum%/%postname%/',
example: '/2026/01/sample-post/',
},
{
key: 'numeric',
label: 'Numeric',
structure: '/archives/%post_id%',
example: '/archives/123',
},
{
key: 'postname',
label: 'Post name',
structure: '/%postname%/',
example: '/sample-post/',
},
{ key: 'custom', label: 'Custom Structure', structure: null, example: '' },
];
// Fields can declare showIf: { key, equals } to only appear once another
// field's current value matches — e.g. the front-page/posts-page pickers
// only make sense once "Homepage Displays" is set to "A static page",
// matching the native Reading screen's own conditional reveal.
const visibleFields = computed(() =>
props.serverData.fields.filter((f) => !f.showIf || values[f.showIf.key] === f.showIf.equals)
);
const selectedPreset = computed(() => {
const match = permalinkPresets.find(
(p) => p.structure !== null && p.structure === (values.permalink_structure || '')
);
return match ? match.key : 'custom';
});
const choosePreset = (preset) => {
if (preset.key === 'custom') {
// Leave whatever custom value is already there (or empty) — this
// option just reveals the text field instead of assigning a value.
if (selectedPreset.value !== 'custom') values.permalink_structure = '';
return;
}
values.permalink_structure = preset.structure;
};
const saving = ref(false);
const savedMsg = ref('');
const save = async () => {
saving.value = true;
savedMsg.value = '';
try {
const url = isPermalinks
? `${props.serverData.restUrl}aurora-admin/v1/permalinks`
: isDiscussion
? `${props.serverData.restUrl}aurora-admin/v1/discussion-settings`
: `${props.serverData.restUrl}wp/v2/settings`;
const body = isPermalinks
? { structure: values.permalink_structure || '' }
: isDiscussion
? {
values: Object.fromEntries(
props.serverData.fields.filter((f) => !f.readonly).map((f) => [f.key, values[f.key]])
),
}
: Object.fromEntries(
props.serverData.fields
.filter((f) => !f.readonly)
.map((f) => [f.key, values[f.key]])
);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error();
savedMsg.value = 'Saved';
setTimeout(() => (savedMsg.value = ''), 2500);
} catch {
savedMsg.value = 'Could not save';
} finally {
saving.value = false;
}
};
</script>
<template>
<div class="native-settings">
<header class="native-settings__head">
<div>
<h2 class="native-settings__title">{{ serverData.title }}</h2>
<p class="native-settings__sub">{{ serverData.subtitle }}</p>
</div>
<div class="native-settings__save-wrap">
<span v-if="savedMsg" class="native-settings__saved">{{ savedMsg }}</span>
<button type="button" class="native-settings__save" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</header>
<div v-if="isPermalinks" class="native-settings__presets">
<label v-for="preset in permalinkPresets" :key="preset.key" class="native-settings__preset">
<input
type="radio"
name="permalink-preset"
:checked="selectedPreset === preset.key"
@change="choosePreset(preset)"
/>
<span class="native-settings__preset-label">{{ preset.label }}</span>
<code v-if="preset.example" class="native-settings__preset-example">{{ preset.example }}</code>
</label>
</div>
<div class="native-settings__fields">
<SettingsField
v-for="f in visibleFields"
:key="f.key"
:field="f"
v-model="values[f.key]"
/>
</div>
</div>
</template>
<style scoped>
.native-settings {
max-width: 720px;
margin: 0 auto;
}
.native-settings__head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 24px;
padding-bottom: 22px;
margin-bottom: 4px;
border-bottom: 1px solid var(--aurora-frame-border);
}
.native-settings__title { font-size: 1.6rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
.native-settings__sub { margin: 6px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
.native-settings__save-wrap { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.native-settings__saved { font-size: 0.8125rem; color: var(--aurora-text-muted); }
.native-settings__save {
background: var(--aurora-accent); color: #fff; border: none;
border-radius: var(--aurora-radius-md); padding: 9px 20px; font-size: 0.875rem; font-weight: 500; cursor: pointer;
}
.native-settings__save:disabled { opacity: 0.6; cursor: default; }
.native-settings__fields > :last-child { border-bottom: none; }
.native-settings__presets {
display: flex;
flex-direction: column;
gap: 4px;
padding: 18px 0;
border-bottom: 1px solid var(--aurora-frame-border);
}
.native-settings__preset {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
cursor: pointer;
font-size: 0.875rem;
color: var(--aurora-text);
}
.native-settings__preset input[type='radio'] {
accent-color: var(--aurora-accent);
width: 15px;
height: 15px;
flex-shrink: 0;
}
.native-settings__preset-label {
min-width: 150px;
flex-shrink: 0;
font-weight: 500;
}
.native-settings__preset-example {
color: var(--aurora-text-muted);
font-size: 0.8125rem;
background: var(--aurora-bg-subtle);
padding: 2px 8px;
border-radius: var(--aurora-radius-sm);
}
</style>
+437
View File
@@ -0,0 +1,437 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path) => `${props.serverData.restUrl}aurora-admin/v1${path}`;
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
const menus = ref([]);
const activeMenuId = ref(null);
const items = ref([]);
const locations = ref([]);
const pickers = ref({ pages: [], posts: [], categories: [] });
const loading = ref(false);
const error = ref('');
const notice = ref('');
const activeMenu = computed(() => menus.value.find((m) => m.id === activeMenuId.value));
// Flat list, ordered so parents appear before children and children are
// visually indented — simpler than a tree render, same end result.
const orderedItems = computed(() => {
const byParent = {};
items.value.forEach((i) => {
(byParent[i.parent] ||= []).push(i);
});
Object.values(byParent).forEach((list) => list.sort((a, b) => a.order - b.order));
const walk = (parentId, depth) => {
const out = [];
(byParent[parentId] || []).forEach((item) => {
out.push({ ...item, depth });
out.push(...walk(item.id, depth + 1));
});
return out;
};
return walk(0, 0);
});
const loadMenus = async () => {
try {
const res = await fetch(api('/nav-menus'), { headers: headers() });
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not load menus.');
menus.value = data;
if (!activeMenuId.value && menus.value.length) {
activeMenuId.value = menus.value[0].id;
}
} catch (e) {
error.value = e.message || 'Could not load menus.';
}
};
const loadItems = async () => {
if (!activeMenuId.value) {
items.value = [];
return;
}
loading.value = true;
try {
const res = await fetch(api(`/nav-menus/${activeMenuId.value}/items`), { headers: headers() });
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not load menu items.');
items.value = data;
} catch (e) {
error.value = e.message || 'Could not load menu items.';
} finally {
loading.value = false;
}
};
const loadLocations = async () => {
try {
const res = await fetch(api('/nav-menus/locations'), { headers: headers() });
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not load locations.');
locations.value = data;
} catch (e) {
error.value = e.message || 'Could not load locations.';
}
};
const loadPickers = async () => {
try {
const res = await fetch(api('/nav-menus/pickers'), { headers: headers() });
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not load pickers.');
pickers.value = data;
} catch (e) {
error.value = e.message || 'Could not load pickers.';
}
};
onMounted(async () => {
await Promise.all([loadMenus(), loadLocations(), loadPickers()]);
await loadItems();
});
const selectMenu = async (id) => {
activeMenuId.value = id;
await loadItems();
};
// Create menu -----------------------------------------------------------
const newMenuName = ref('');
const creatingMenu = ref(false);
const createMenu = async () => {
if (!newMenuName.value.trim()) return;
creatingMenu.value = true;
try {
const res = await fetch(api('/nav-menus'), {
method: 'POST',
headers: headers(),
body: JSON.stringify({ name: newMenuName.value.trim() }),
});
const menu = await res.json();
if (!res.ok) throw new Error(menu?.message || 'Could not create menu.');
menus.value.push(menu);
newMenuName.value = '';
await selectMenu(menu.id);
} catch (e) {
error.value = e.message;
} finally {
creatingMenu.value = false;
}
};
const deleteMenu = async () => {
if (!activeMenu.value) return;
if (!confirm(`Delete menu "${activeMenu.value.name}"? This can't be undone.`)) return;
await fetch(api(`/nav-menus/${activeMenuId.value}`), { method: 'DELETE', headers: headers() });
menus.value = menus.value.filter((m) => m.id !== activeMenuId.value);
activeMenuId.value = menus.value[0]?.id || null;
await loadItems();
};
// Add items ---------------------------------------------------------------
const addTab = ref('pages');
const customLink = ref({ title: '', url: 'https://' });
const selectedIds = ref([]);
const toggleSelected = (id) => {
const i = selectedIds.value.indexOf(id);
if (i === -1) selectedIds.value.push(id);
else selectedIds.value.splice(i, 1);
};
const addSelectedItems = async () => {
if (!activeMenuId.value) return;
const list = pickers.value[addTab.value] || [];
const toAdd = list.filter((p) => selectedIds.value.includes(p.id));
for (const obj of toAdd) {
const type = addTab.value === 'pages' ? 'page' : addTab.value === 'posts' ? 'post' : 'category';
await fetch(api(`/nav-menus/${activeMenuId.value}/items`), {
method: 'POST',
headers: headers(),
body: JSON.stringify({ type, objectId: obj.id, title: obj.title, parent: 0, order: items.value.length + 1 }),
});
}
selectedIds.value = [];
await loadItems();
await loadMenus();
};
const addCustomLink = async () => {
if (!activeMenuId.value || !customLink.value.url) return;
await fetch(api(`/nav-menus/${activeMenuId.value}/items`), {
method: 'POST',
headers: headers(),
body: JSON.stringify({
type: 'custom',
url: customLink.value.url,
title: customLink.value.title || customLink.value.url,
parent: 0,
order: items.value.length + 1,
}),
});
customLink.value = { title: '', url: 'https://' };
await loadItems();
await loadMenus();
};
// Structure editing -------------------------------------------------------
const removeItem = async (id) => {
await fetch(api(`/nav-menus/items/${id}`), { method: 'DELETE', headers: headers() });
await loadItems();
await loadMenus();
};
const persistOrder = async () => {
const payload = orderedItems.value.map((it, idx) => ({ id: it.id, parent: it.parent, order: idx + 1 }));
await fetch(api(`/nav-menus/${activeMenuId.value}/reorder`), {
method: 'POST',
headers: headers(),
body: JSON.stringify({ items: payload }),
});
};
const moveUp = async (item) => {
const idx = items.value.findIndex((i) => i.id === item.id);
const siblings = items.value.filter((i) => i.parent === item.parent).sort((a, b) => a.order - b.order);
const pos = siblings.findIndex((i) => i.id === item.id);
if (pos <= 0) return;
[siblings[pos - 1].order, siblings[pos].order] = [siblings[pos].order, siblings[pos - 1].order];
await persistOrder();
await loadItems();
};
const moveDown = async (item) => {
const siblings = items.value.filter((i) => i.parent === item.parent).sort((a, b) => a.order - b.order);
const pos = siblings.findIndex((i) => i.id === item.id);
if (pos === -1 || pos >= siblings.length - 1) return;
[siblings[pos + 1].order, siblings[pos].order] = [siblings[pos].order, siblings[pos + 1].order];
await persistOrder();
await loadItems();
};
const indent = async (item) => {
const siblings = items.value.filter((i) => i.parent === item.parent).sort((a, b) => a.order - b.order);
const pos = siblings.findIndex((i) => i.id === item.id);
if (pos <= 0) return;
item.parent = siblings[pos - 1].id;
await persistOrder();
await loadItems();
};
const outdent = async (item) => {
const parentItem = items.value.find((i) => i.id === item.parent);
if (!parentItem) return;
item.parent = parentItem.parent;
await persistOrder();
await loadItems();
};
// Locations ---------------------------------------------------------------
const assignLocation = async (slug, menuId) => {
await fetch(api('/nav-menus/locations'), {
method: 'POST',
headers: headers(),
body: JSON.stringify({ location: slug, menuId: menuId || 0 }),
});
notice.value = 'Location updated.';
setTimeout(() => (notice.value = ''), 2000);
await loadLocations();
};
</script>
<template>
<main class="nav-menus-page">
<header class="nav-menus-page__header">
<div>
<p class="nav-menus-page__eyebrow">Appearance</p>
<h1>Menus</h1>
</div>
</header>
<div v-if="notice" class="nav-menus-notice nav-menus-notice--success">{{ notice }}</div>
<div v-if="error" class="nav-menus-notice nav-menus-notice--error">{{ error }}</div>
<div class="nav-menus-layout">
<!-- Left: menu list + locations -->
<aside class="nav-menus-side">
<h2>Your Menus</h2>
<ul class="nav-menus-list">
<li v-for="menu in menus" :key="menu.id">
<button
type="button"
class="nav-menus-list__item"
:class="{ 'is-active': menu.id === activeMenuId }"
@click="selectMenu(menu.id)"
>
{{ menu.name }} <span>({{ menu.itemCount }})</span>
</button>
</li>
</ul>
<div class="nav-menus-create">
<input v-model="newMenuName" type="text" placeholder="New menu name" @keyup.enter="createMenu" />
<button type="button" class="button" :disabled="creatingMenu" @click="createMenu">Create</button>
</div>
<h2>Manage Locations</h2>
<div v-for="loc in locations" :key="loc.slug" class="nav-menus-location">
<label>{{ loc.description }}</label>
<select :value="loc.menuId || ''" @change="assignLocation(loc.slug, $event.target.value ? Number($event.target.value) : 0)">
<option value="">— Not assigned —</option>
<option v-for="menu in menus" :key="menu.id" :value="menu.id">{{ menu.name }}</option>
</select>
</div>
<p v-if="!locations.length" class="nav-menus-empty">This theme registered no menu locations.</p>
</aside>
<!-- Middle: add items -->
<section class="nav-menus-add">
<h2>Add Menu Items</h2>
<div class="nav-menus-add__tabs">
<button type="button" :class="{ 'is-active': addTab === 'pages' }" @click="addTab = 'pages'">Pages</button>
<button type="button" :class="{ 'is-active': addTab === 'posts' }" @click="addTab = 'posts'">Posts</button>
<button type="button" :class="{ 'is-active': addTab === 'categories' }" @click="addTab = 'categories'">Categories</button>
<button type="button" :class="{ 'is-active': addTab === 'custom' }" @click="addTab = 'custom'">Custom Link</button>
</div>
<div v-if="addTab !== 'custom'" class="nav-menus-add__list">
<label v-for="opt in pickers[addTab]" :key="opt.id" class="nav-menus-add__row">
<input type="checkbox" :checked="selectedIds.includes(opt.id)" @change="toggleSelected(opt.id)" />
<span>{{ opt.title }}</span>
</label>
<p v-if="!pickers[addTab]?.length" class="nav-menus-empty">Nothing to show.</p>
<button v-if="pickers[addTab]?.length" type="button" class="button-primary" :disabled="!activeMenuId" @click="addSelectedItems">
Add to Menu
</button>
</div>
<div v-else class="nav-menus-add__custom">
<input v-model="customLink.title" type="text" placeholder="Link Text" />
<input v-model="customLink.url" type="text" placeholder="https://example.com" />
<button type="button" class="button-primary" :disabled="!activeMenuId" @click="addCustomLink">Add to Menu</button>
</div>
</section>
<!-- Right: current structure -->
<section class="nav-menus-structure">
<div class="nav-menus-structure__head">
<h2>{{ activeMenu?.name || 'Select a menu' }}</h2>
<button v-if="activeMenu" type="button" class="icon-button" title="Delete menu" @click="deleteMenu">
<span class="dashicons dashicons-trash" />
</button>
</div>
<p v-if="loading" class="nav-menus-empty">Loading…</p>
<p v-else-if="!orderedItems.length" class="nav-menus-empty">No items in this menu yet — add some from the left.</p>
<div
v-for="item in orderedItems"
:key="item.id"
class="nav-menus-item"
:style="{ marginLeft: `${item.depth * 24}px` }"
>
<span class="nav-menus-item__title">{{ item.title }}</span>
<span class="nav-menus-item__actions">
<button type="button" title="Move up" @click="moveUp(item)"><span class="dashicons dashicons-arrow-up-alt2" /></button>
<button type="button" title="Move down" @click="moveDown(item)"><span class="dashicons dashicons-arrow-down-alt2" /></button>
<button type="button" title="Indent (make sub-item)" @click="indent(item)"><span class="dashicons dashicons-arrow-right-alt2" /></button>
<button type="button" title="Outdent" :disabled="!item.parent" @click="outdent(item)"><span class="dashicons dashicons-arrow-left-alt2" /></button>
<button type="button" title="Remove" @click="removeItem(item.id)"><span class="dashicons dashicons-no-alt" /></button>
</span>
</div>
</section>
</div>
</main>
</template>
<style scoped>
.nav-menus-page { max-width: 1200px; margin: 0 auto; }
.nav-menus-page__header { margin-bottom: 16px; }
.nav-menus-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.nav-menus-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.nav-menus-notice { padding: 10px 14px; border-radius: var(--aurora-radius-sm); margin-bottom: 16px; font-size: 0.875rem; }
.nav-menus-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.nav-menus-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.nav-menus-layout { display: grid; grid-template-columns: 260px 300px 1fr; gap: 20px; align-items: start; }
.nav-menus-side, .nav-menus-add, .nav-menus-structure {
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md); padding: 18px;
}
.nav-menus-side h2, .nav-menus-add h2, .nav-menus-structure h2 {
font-size: 0.9375rem; margin: 0 0 10px; color: var(--aurora-text);
}
.nav-menus-list { list-style: none; margin: 0 0 14px; padding: 0; }
.nav-menus-list__item {
width: 100%; text-align: left; padding: 8px 10px; border-radius: var(--aurora-radius-sm);
border: none; background: none; color: var(--aurora-text-muted); cursor: pointer;
font-size: 0.875rem;
}
.nav-menus-list__item span { color: var(--aurora-text-muted); }
.nav-menus-list__item:hover { background: var(--aurora-bg); }
.nav-menus-list__item.is-active { background: var(--aurora-accent-soft); color: var(--aurora-accent); }
.nav-menus-create { display: flex; gap: 6px; margin-bottom: 20px; }
.nav-menus-create input {
flex: 1; padding: 7px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.8125rem;
}
.nav-menus-location { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.nav-menus-location label { font-size: 0.8125rem; color: var(--aurora-text-muted); }
.nav-menus-location select,
.nav-menus-add__custom input {
padding: 7px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.8125rem;
}
.nav-menus-add__tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.nav-menus-add__tabs button {
padding: 6px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg); color: var(--aurora-text-muted); font-size: 0.75rem; cursor: pointer;
}
.nav-menus-add__tabs button.is-active { background: var(--aurora-accent-soft); color: var(--aurora-accent); border-color: transparent; }
.nav-menus-add__list { max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
.nav-menus-add__row {
display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: var(--aurora-radius-sm);
font-size: 0.8125rem; color: var(--aurora-text);
}
.nav-menus-add__row:hover { background: var(--aurora-bg); }
.nav-menus-add__list .button-primary { margin-top: 10px; width: 100%; }
.nav-menus-add__custom { display: flex; flex-direction: column; gap: 8px; }
.nav-menus-structure__head { display: flex; align-items: center; justify-content: space-between; }
.nav-menus-item {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; border-radius: var(--aurora-radius-sm); margin-bottom: 4px;
background: var(--aurora-bg); border: 1px solid var(--aurora-border);
font-size: 0.8125rem; color: var(--aurora-text);
}
.nav-menus-item__actions { display: flex; gap: 2px; }
.nav-menus-item__actions button {
border: none; background: none; color: var(--aurora-text-muted); cursor: pointer;
width: 26px; height: 26px; border-radius: var(--aurora-radius-sm); display: inline-flex; align-items: center; justify-content: center;
}
.nav-menus-item__actions button:hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
.nav-menus-item__actions button:disabled { opacity: 0.35; cursor: default; }
.nav-menus-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
@media (max-width: 1100px) {
.nav-menus-layout { grid-template-columns: 1fr; }
}
</style>
+622
View File
@@ -0,0 +1,622 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const pages = ref([]);
const stats = ref({ all: 0, publish: 0, draft: 0, pending: 0, trash: 0 });
const pagination = ref({ page: 1, perPage: 20, total: 0, totalPages: 1 });
const loading = ref(true);
const busyId = ref(0);
const error = ref('');
const notice = ref('');
const search = ref('');
const status = ref('all');
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
const request = async (path, options = {}) => {
const response = await fetch(`${apiBase.value}${path}`, {
credentials: 'same-origin',
...options,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(options.headers || {}),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.message || data.error || 'Request failed');
}
return data;
};
const load = async () => {
loading.value = true;
error.value = '';
try {
const params = new URLSearchParams({
status: status.value,
search: search.value.trim(),
page: String(pagination.value.page),
perPage: String(pagination.value.perPage),
});
const data = await request(`/pages?${params.toString()}`);
pages.value = data.items || [];
stats.value = data.stats || stats.value;
pagination.value = data.pagination || pagination.value;
} catch (err) {
error.value = err.message || 'Could not load pages';
} finally {
loading.value = false;
}
};
onMounted(load);
let searchTimer = 0;
watch(search, () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
pagination.value.page = 1;
load();
}, 250);
});
const setStatus = (next) => {
status.value = next;
pagination.value.page = 1;
load();
};
const filters = computed(() => [
{ key: 'all', label: 'All', count: stats.value.all },
{ key: 'publish', label: 'Published', count: stats.value.publish },
{ key: 'draft', label: 'Draft', count: stats.value.draft },
{ key: 'pending', label: 'Pending', count: stats.value.pending },
{ key: 'trash', label: 'Trash', count: stats.value.trash },
]);
const trashPage = async (item) => {
if (!item || busyId.value) return;
busyId.value = item.id;
error.value = '';
notice.value = '';
try {
const data = await request('/pages/trash', {
method: 'POST',
body: JSON.stringify({ id: item.id }),
});
notice.value = data.message || 'Page moved to trash.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const restorePage = async (item) => {
if (!item || busyId.value) return;
busyId.value = item.id;
error.value = '';
notice.value = '';
try {
const data = await request('/pages/restore', {
method: 'POST',
body: JSON.stringify({ id: item.id }),
});
notice.value = data.message || 'Page restored.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const deletePage = async (item) => {
if (!item || busyId.value) return;
if (!window.confirm(`Permanently delete "${item.title}"? This cannot be undone.`)) return;
busyId.value = item.id;
error.value = '';
notice.value = '';
try {
const data = await request(`/pages/${item.id}`, { method: 'DELETE' });
notice.value = data.message || 'Page deleted.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const goPage = (page) => {
const next = Math.min(Math.max(1, page), pagination.value.totalPages || 1);
if (next === pagination.value.page) return;
pagination.value.page = next;
load();
};
const rowRange = computed(() => {
if (!pagination.value.total) return '0 to 0';
const start = (pagination.value.page - 1) * pagination.value.perPage + 1;
const end = Math.min(pagination.value.total, pagination.value.page * pagination.value.perPage);
return `${start} to ${end}`;
});
</script>
<template>
<main class="pages-page">
<header class="pages-page__header">
<div>
<p class="pages-page__eyebrow">Content</p>
<h1>Pages</h1>
</div>
<div class="pages-page__actions">
<a class="button-primary" :href="serverData.newPageUrl">Add Page</a>
<button type="button" class="icon-button" title="Refresh pages" :disabled="loading" @click="load">
<span class="dashicons dashicons-update" />
</button>
</div>
</header>
<section class="pages-page__toolbar" aria-label="Page filters and search">
<div class="pages-page__filters">
<button
v-for="filter in filters"
:key="filter.key"
type="button"
class="filter-pill"
:class="{ 'is-active': status === filter.key }"
@click="setStatus(filter.key)"
>
<span>{{ filter.label }}</span>
<strong>{{ filter.count }}</strong>
</button>
</div>
<label class="pages-page__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Search pages..." />
</label>
</section>
<div v-if="notice" class="pages-notice pages-notice--success">{{ notice }}</div>
<div v-if="error" class="pages-notice pages-notice--error">{{ error }}</div>
<section class="pages-table" aria-label="Pages">
<div class="pages-table__head">
<div>Title</div>
<div>Author</div>
<div>Parent</div>
<div>Comments</div>
<div>Date</div>
<div>Actions</div>
</div>
<div v-if="loading" class="pages-table__empty">Loading pages...</div>
<div v-else-if="!pages.length" class="pages-table__empty">No pages match your filters.</div>
<article v-for="item in pages" v-else :key="item.id" class="page-row">
<div class="page-row__title" data-label="Title">
<a :href="item.editUrl">{{ item.title }}</a>
<span class="status-badge" :class="`status-badge--${item.status}`">{{ item.statusLabel }}</span>
</div>
<div class="page-row__cell" data-label="Author">{{ item.author }}</div>
<div class="page-row__cell" data-label="Parent">{{ item.parentTitle || '—' }}</div>
<div class="page-row__cell" data-label="Comments">{{ item.commentCount }}</div>
<div class="page-row__cell" data-label="Date">
<strong>{{ item.dateLabel }}</strong>
<span>{{ item.date }}</span>
</div>
<div class="page-row__actions">
<a class="icon-button icon-button--inline" title="Edit page" :href="item.editUrl">
<span class="dashicons dashicons-edit" />
</a>
<a
v-if="item.viewUrl"
class="icon-button icon-button--inline"
title="View page"
:href="item.viewUrl"
target="_blank"
rel="noopener"
>
<span class="dashicons dashicons-visibility" />
</a>
<button
v-if="item.status !== 'trash'"
type="button"
class="icon-button icon-button--inline"
title="Move to trash"
:disabled="busyId === item.id"
@click="trashPage(item)"
>
<span class="dashicons dashicons-trash" />
</button>
<template v-else>
<button
type="button"
class="icon-button icon-button--inline"
title="Restore"
:disabled="busyId === item.id"
@click="restorePage(item)"
>
<span class="dashicons dashicons-undo" />
</button>
<button
v-if="item.canDelete"
type="button"
class="icon-button icon-button--inline"
title="Delete permanently"
:disabled="busyId === item.id"
@click="deletePage(item)"
>
<span class="dashicons dashicons-no-alt" />
</button>
</template>
</div>
</article>
<footer v-if="!loading && pages.length" class="pages-table__footer">
<div>Page Size: {{ pagination.perPage }}</div>
<div>{{ rowRange }} of {{ pagination.total }}</div>
<div class="pages-table__pager">
<button type="button" :disabled="pagination.page <= 1" @click="goPage(1)">
<span class="dashicons dashicons-controls-skipback" />
</button>
<button type="button" :disabled="pagination.page <= 1" @click="goPage(pagination.page - 1)">
<span class="dashicons dashicons-arrow-left-alt2" />
</button>
<span>Page {{ pagination.page }} of {{ pagination.totalPages }}</span>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.page + 1)"
>
<span class="dashicons dashicons-arrow-right-alt2" />
</button>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.totalPages)"
>
<span class="dashicons dashicons-controls-skipforward" />
</button>
</div>
</footer>
</section>
</main>
</template>
<style scoped>
.pages-page {
min-height: calc(100vh - 112px);
padding: 0;
color: var(--aurora-text);
background: transparent;
}
.pages-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 24px;
}
.pages-page__eyebrow {
margin: 0 0 7px;
color: var(--aurora-text-muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.pages-page h1 {
margin: 0;
color: var(--aurora-text);
font-size: 1.45rem;
line-height: 1.15;
}
.pages-page__actions,
.pages-page__toolbar,
.pages-page__filters,
.page-row__actions {
display: flex;
align-items: center;
}
.pages-page__actions {
gap: 10px;
}
.button-primary {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 16px;
border-radius: var(--aurora-radius-sm);
background: var(--aurora-accent);
color: var(--aurora-accent-text);
font-size: 0.84rem;
font-weight: 700;
text-decoration: none;
}
.pages-page__toolbar {
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.pages-page__filters {
flex-wrap: wrap;
gap: 8px;
}
.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 12px;
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
}
.filter-pill strong {
min-width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--aurora-frame-bg);
color: inherit;
font-size: 0.72rem;
}
.filter-pill.is-active {
border-color: var(--aurora-text-muted);
background: var(--aurora-frame-bg);
color: var(--aurora-text);
}
.pages-page__search {
width: min(320px, 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);
}
.pages-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;
}
.pages-page__search input::placeholder {
color: var(--aurora-text-muted);
}
.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;
text-decoration: none;
}
.icon-button:hover:not(:disabled) {
border-color: var(--aurora-text-muted);
color: var(--aurora-text);
}
.icon-button--inline {
width: 30px;
min-height: 30px;
border: 0;
background: transparent;
color: var(--aurora-text-muted);
}
.icon-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.pages-notice {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
font-size: 0.78rem;
font-weight: 750;
}
.pages-table {
overflow: hidden;
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-sm);
background: var(--aurora-frame-bg);
}
.pages-table__head,
.page-row {
display: grid;
grid-template-columns: minmax(220px, 1.6fr) minmax(120px, 0.8fr) minmax(160px, 1fr) 110px 130px 132px;
align-items: center;
}
.pages-table__head {
min-height: 48px;
border-bottom: 1px solid var(--aurora-border);
color: var(--aurora-text);
font-size: 0.79rem;
font-weight: 850;
}
.pages-table__head > div {
min-height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
}
.page-row {
min-height: 66px;
border-bottom: 1px solid var(--aurora-border);
}
.page-row:hover {
background: var(--aurora-bg-subtle);
}
.page-row__title {
min-width: 0;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.page-row__title a {
overflow: hidden;
color: var(--aurora-text);
font-weight: 800;
font-size: 0.86rem;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-row__title a:hover {
text-decoration: underline;
}
.page-row__cell {
min-width: 0;
padding: 14px 16px;
color: var(--aurora-text-muted);
font-size: 0.8rem;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-row__cell span {
display: block;
color: var(--aurora-text-muted);
font-size: 0.74rem;
}
.page-row__cell strong {
display: block;
color: var(--aurora-text-muted);
font-size: 0.8rem;
}
.status-badge {
width: fit-content;
border-radius: 999px;
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
padding: 2px 9px;
font-size: 0.68rem;
font-weight: 850;
box-shadow: inset 0 0 0 1px var(--aurora-border);
}
.page-row__actions {
gap: 8px;
padding: 14px 16px;
}
.pages-table__empty {
padding: 38px 18px;
color: var(--aurora-text-muted);
font-size: 0.85rem;
text-align: center;
}
.pages-table__footer {
min-height: 54px;
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;
}
.pages-table__pager {
display: flex;
align-items: center;
gap: 8px;
}
.pages-table__pager button {
width: 28px;
height: 28px;
border: 0;
border-radius: var(--aurora-radius-sm);
background: transparent;
color: var(--aurora-text-muted);
cursor: pointer;
}
.pages-table__pager button:not(:disabled):hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.pages-table__pager button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 1280px) {
.pages-table__head,
.page-row {
grid-template-columns: minmax(200px, 1.6fr) minmax(100px, 0.8fr) minmax(140px, 1fr) 100px 120px 122px;
}
}
@media (max-width: 1080px) {
.pages-table__head {
display: none;
}
.page-row {
grid-template-columns: 1fr;
align-items: stretch;
padding: 8px 0;
}
.page-row__title,
.page-row__cell,
.page-row__actions {
padding: 8px 16px;
}
.page-row__cell {
display: flex;
justify-content: space-between;
gap: 16px;
white-space: normal;
}
.page-row__cell::before {
content: attr(data-label);
color: var(--aurora-text-muted);
font-weight: 800;
}
.pages-table__footer,
.pages-page__header,
.pages-page__toolbar {
align-items: stretch;
flex-direction: column;
}
.pages-table__footer {
padding: 14px 16px;
gap: 10px;
}
.pages-page__search {
width: 100%;
}
}
</style>
+698
View File
@@ -0,0 +1,698 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const plugins = ref([]);
const stats = ref({ total: 0, active: 0, inactive: 0, updates: 0 });
const addUrl = ref('');
const loading = ref(true);
const busyFile = ref('');
const error = ref('');
const notice = ref('');
const search = ref('');
const status = ref('all');
const compact = ref(false);
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
const request = async (path, options = {}) => {
const response = await fetch(`${apiBase.value}${path}`, {
credentials: 'same-origin',
...options,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(options.headers || {}),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.message || data.error || 'Request failed');
}
return data;
};
const load = async () => {
loading.value = true;
error.value = '';
try {
const data = await request('/plugins');
plugins.value = data.items || [];
stats.value = data.stats || stats.value;
addUrl.value = data.addUrl || '';
} catch (err) {
error.value = err.message || 'Could not load plugins';
} finally {
loading.value = false;
}
};
onMounted(load);
const filtered = computed(() => {
const query = search.value.trim().toLowerCase();
return plugins.value.filter((plugin) => {
const matchesStatus =
status.value === 'all' ||
(status.value === 'active' && plugin.active) ||
(status.value === 'inactive' && !plugin.active) ||
(status.value === 'updates' && plugin.updateAvailable);
if (!matchesStatus) return false;
if (!query) return true;
return [plugin.name, plugin.description, plugin.author, plugin.file]
.join(' ')
.toLowerCase()
.includes(query);
});
});
const filters = computed(() => [
{ key: 'all', label: 'All', count: stats.value.total },
{ key: 'active', label: 'Active', count: stats.value.active },
{ key: 'inactive', label: 'Inactive', count: stats.value.inactive },
{ key: 'updates', label: 'Update Available', count: stats.value.updates },
]);
const runAction = async (action, plugin) => {
if (!plugin || busyFile.value) return;
busyFile.value = plugin.file;
error.value = '';
notice.value = '';
let redirecting = false;
try {
const data = await request(`/plugins/${action}`, {
method: action === 'delete' ? 'DELETE' : 'POST',
body: JSON.stringify({ file: plugin.file }),
});
notice.value = data.message || 'Done';
if (data.redirect) {
redirecting = true;
window.location.href = data.redirect;
return;
}
} 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();
}
};
const openAdd = () => {
if (addUrl.value) {
window.location.href = addUrl.value;
}
};
const pluginInitial = (name) => (name || 'P').trim().charAt(0).toUpperCase();
const statusLabel = (plugin) => {
if (plugin.networkActive) return 'Network active';
return plugin.active ? 'Active' : 'Inactive';
};
const versionLine = (plugin) => {
if (plugin.updateAvailable && plugin.newVersion) {
return `${plugin.version || 'Unknown'} -> ${plugin.newVersion}`;
}
return plugin.version || 'Unknown';
};
</script>
<template>
<main class="plugins-page">
<header class="plugins-page__header">
<div>
<p class="plugins-page__eyebrow">Aurora Admin</p>
<h1>Plugins</h1>
</div>
<div class="plugins-page__actions">
<button
type="button"
class="icon-button"
:class="{ 'is-active': compact }"
title="Toggle compact view"
@click="compact = !compact"
>
<span class="dashicons dashicons-menu-alt3" />
</button>
<button type="button" class="icon-button" title="Refresh plugins" :disabled="loading" @click="load">
<span class="dashicons dashicons-update" />
</button>
<button type="button" class="primary-button" @click="openAdd">
<span class="dashicons dashicons-plus-alt2" />
Add New
</button>
</div>
</header>
<section class="plugins-page__toolbar" aria-label="Plugin filters and search">
<div class="plugins-page__filters">
<button
v-for="filter in filters"
:key="filter.key"
type="button"
class="filter-pill"
:class="{ 'is-active': status === filter.key }"
@click="status = filter.key"
>
<span>{{ filter.label }}</span>
<strong>{{ filter.count }}</strong>
</button>
</div>
<label class="plugins-page__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Search plugins..." />
</label>
</section>
<div v-if="notice" class="plugins-notice plugins-notice--success">{{ notice }}</div>
<div v-if="error" class="plugins-notice plugins-notice--error">{{ error }}</div>
<section class="plugins-table" :class="{ 'is-compact': compact }" aria-label="Installed plugins">
<div class="plugins-table__head">
<div>Plugin Info</div>
<div>Version <span class="dashicons dashicons-filter" /></div>
<div>Status <span class="dashicons dashicons-filter" /></div>
<div>Author <span class="dashicons dashicons-filter" /></div>
<div>Actions</div>
</div>
<div v-if="loading" class="plugins-table__empty">Loading plugins...</div>
<div v-else-if="!filtered.length" class="plugins-table__empty">No plugins match your filters.</div>
<article
v-for="plugin in filtered"
v-else
:key="plugin.file"
class="plugin-row"
:class="{ 'is-active': plugin.active }"
>
<div class="plugin-row__info">
<div class="plugin-row__mark">
<span>{{ pluginInitial(plugin.name) }}</span>
</div>
<div class="plugin-row__copy">
<h2>{{ plugin.name }}</h2>
<p>{{ plugin.description || 'No description provided.' }}</p>
<div class="plugin-row__links">
<a v-if="plugin.pluginUri" :href="plugin.pluginUri" target="_blank" rel="noopener noreferrer">
Plugin site
</a>
<a v-if="plugin.authorUri" :href="plugin.authorUri" target="_blank" rel="noopener noreferrer">
Author site
</a>
<span>{{ plugin.file }}</span>
</div>
</div>
</div>
<div class="plugin-row__cell" data-label="Version">
<strong>{{ versionLine(plugin) }}</strong>
<span v-if="plugin.updateAvailable">Update ready</span>
</div>
<div class="plugin-row__cell" data-label="Status">
<span class="status-badge" :class="{ 'status-badge--active': plugin.active }">
<i />
{{ statusLabel(plugin) }}
</span>
</div>
<div class="plugin-row__cell" data-label="Author">
<strong>{{ plugin.author || 'Unknown' }}</strong>
</div>
<div class="plugin-row__actions">
<button
v-if="!plugin.active"
type="button"
class="toggle-button"
title="Activate plugin"
:disabled="busyFile === plugin.file || !plugin.canActivate"
@click="runAction('activate', plugin)"
>
<span />
</button>
<button
v-else
type="button"
class="toggle-button is-on"
title="Deactivate plugin"
:disabled="busyFile === plugin.file"
@click="runAction('deactivate', plugin)"
>
<span />
</button>
<button
v-if="plugin.updateAvailable"
type="button"
class="icon-button icon-button--inline"
title="Update plugin"
:disabled="busyFile === plugin.file || !plugin.canUpdate"
@click="runAction('update', plugin)"
>
<span class="dashicons dashicons-update-alt" />
</button>
<button
type="button"
class="icon-button icon-button--inline icon-button--danger"
title="Delete plugin"
:disabled="busyFile === plugin.file || !plugin.canDelete"
@click="runAction('delete', plugin)"
>
<span class="dashicons dashicons-trash" />
</button>
<a
v-if="plugin.pluginUri"
class="icon-button icon-button--inline"
title="Open plugin site"
:href="plugin.pluginUri"
target="_blank"
rel="noopener noreferrer"
>
<span class="dashicons dashicons-ellipsis" />
</a>
</div>
</article>
</section>
</main>
</template>
<style scoped>
.plugins-page {
min-height: calc(100vh - 112px);
padding: 0;
color: var(--aurora-text);
background: transparent;
}
.plugins-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 24px;
}
.plugins-page__eyebrow {
margin: 0 0 7px;
color: var(--aurora-text-muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.plugins-page h1 {
margin: 0;
color: var(--aurora-text);
font-size: 1.45rem;
line-height: 1.15;
}
.plugins-page__actions,
.plugins-page__toolbar,
.plugins-page__filters,
.plugin-row__actions,
.plugin-row__links {
display: flex;
align-items: center;
}
.plugins-page__actions {
gap: 10px;
}
.plugins-page__toolbar {
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.plugins-page__filters {
flex-wrap: wrap;
gap: 8px;
}
.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 12px;
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
}
.filter-pill strong {
min-width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--aurora-frame-bg);
color: inherit;
font-size: 0.72rem;
}
.filter-pill.is-active {
border-color: var(--aurora-text-muted);
background: var(--aurora-frame-bg);
color: var(--aurora-text);
}
.filter-pill.is-active strong {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.plugins-page__search {
width: min(320px, 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);
}
.plugins-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;
}
.plugins-page__search input::placeholder {
color: var(--aurora-text-muted);
}
.primary-button,
.icon-button {
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-sm);
min-height: 42px;
color: var(--aurora-text);
background: var(--aurora-bg-subtle);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.primary-button {
gap: 8px;
border-color: var(--aurora-text-muted);
background: var(--aurora-frame-bg);
color: var(--aurora-text);
padding: 0 16px;
font-size: 0.84rem;
font-weight: 850;
}
.icon-button {
width: 42px;
padding: 0;
text-decoration: none;
}
.icon-button.is-active {
border-color: var(--aurora-text-muted);
color: var(--aurora-text);
}
.icon-button--inline {
width: 30px;
min-height: 30px;
border: 0;
background: transparent;
color: var(--aurora-text-muted);
}
.icon-button:hover:not(:disabled),
.icon-button--inline:hover:not(:disabled) {
color: var(--aurora-text);
border-color: var(--aurora-text-muted);
}
.icon-button--danger:hover:not(:disabled) {
color: var(--aurora-text);
}
.primary-button:disabled,
.icon-button:disabled,
.toggle-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.plugins-notice {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--aurora-radius-sm);
font-size: 0.78rem;
font-weight: 750;
}
.plugins-notice--success {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.plugins-notice--error {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.plugins-table {
overflow: hidden;
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-sm);
background: var(--aurora-frame-bg);
box-shadow: none;
}
.plugins-table__head,
.plugin-row {
display: grid;
grid-template-columns: minmax(320px, 1fr) 140px 140px 170px 150px;
align-items: center;
}
.plugins-table__head {
min-height: 48px;
border-bottom: 1px solid var(--aurora-border);
color: var(--aurora-text);
font-size: 0.79rem;
font-weight: 850;
}
.plugins-table__head > * {
padding: 0 16px;
}
.plugins-table__head > div {
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
}
.plugin-row {
min-height: 78px;
border-bottom: 1px solid var(--aurora-border);
transition: background 160ms ease, border-color 160ms ease;
}
.plugin-row:last-child {
border-bottom: 0;
}
.plugin-row:hover {
background: var(--aurora-bg-subtle);
}
.plugin-row.is-active {
background: var(--aurora-bg-subtle);
}
.plugin-row__info {
display: grid;
grid-template-columns: 46px minmax(0, 1fr);
gap: 16px;
align-items: center;
min-width: 0;
padding: 16px;
}
.plugin-row__mark {
width: 46px;
height: 46px;
border-radius: var(--aurora-radius-sm);
display: flex;
align-items: center;
justify-content: center;
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
font-size: 1rem;
font-weight: 900;
}
.plugin-row__copy {
min-width: 0;
}
.plugin-row__copy h2 {
margin: 0 0 5px;
color: var(--aurora-text);
font-size: 0.95rem;
line-height: 1.2;
}
.plugin-row__copy p {
margin: 0;
color: var(--aurora-text-muted);
font-size: 0.78rem;
line-height: 1.35;
}
.plugin-row__links {
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
color: var(--aurora-text-muted);
font-size: 0.7rem;
}
.plugin-row__links a {
color: var(--aurora-text);
font-weight: 800;
text-decoration: none;
}
.plugin-row__links span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plugin-row__cell {
min-width: 0;
padding: 16px;
color: var(--aurora-text-muted);
font-size: 0.78rem;
}
.plugin-row__cell strong {
display: block;
overflow: hidden;
color: var(--aurora-text-muted);
font-size: 0.82rem;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
}
.plugin-row__cell span {
display: block;
margin-top: 4px;
color: var(--aurora-text-muted);
font-size: 0.7rem;
font-weight: 800;
}
.status-badge {
width: fit-content;
border-radius: 999px;
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
font-size: 0.75rem;
font-weight: 850;
}
.status-badge i {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.8;
}
.status-badge--active {
background: var(--aurora-frame-bg);
color: var(--aurora-text);
box-shadow: inset 0 0 0 1px var(--aurora-border);
}
.plugin-row__actions {
gap: 8px;
justify-content: flex-start;
padding: 16px;
}
.plugins-table.is-compact .plugin-row {
min-height: 62px;
}
.plugins-table.is-compact .plugin-row__info,
.plugins-table.is-compact .plugin-row__cell,
.plugins-table.is-compact .plugin-row__actions {
padding-top: 10px;
padding-bottom: 10px;
}
.plugins-table.is-compact .plugin-row__mark {
width: 38px;
height: 38px;
}
.plugins-table.is-compact .plugin-row__links {
display: none;
}
.toggle-button {
position: relative;
width: 34px;
height: 18px;
border: 0;
border-radius: 999px;
background: var(--aurora-bg-subtle);
cursor: pointer;
}
.toggle-button span {
position: absolute;
top: 3px;
left: 3px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--aurora-text-muted);
transition: transform 160ms ease, background 160ms ease;
}
.toggle-button.is-on {
background: var(--aurora-text-muted);
}
.toggle-button.is-on span {
transform: translateX(16px);
background: var(--aurora-frame-bg);
}
.plugins-table__empty {
padding: 38px 18px;
color: var(--aurora-text-muted);
font-size: 0.85rem;
text-align: center;
}
@media (max-width: 1120px) {
.plugins-page {
padding: 24px 18px 34px;
}
.plugins-table__head {
display: none;
}
.plugin-row {
grid-template-columns: 1fr;
align-items: stretch;
padding: 8px 0;
}
.plugin-row__cell,
.plugin-row__actions {
padding: 8px 16px;
}
.plugin-row__cell {
display: flex;
justify-content: space-between;
gap: 16px;
}
.plugin-row__cell::before {
content: attr(data-label);
color: var(--aurora-text-muted);
font-weight: 800;
}
}
@media (max-width: 720px) {
.plugins-page__header,
.plugins-page__toolbar {
align-items: stretch;
flex-direction: column;
}
.plugins-page__actions {
justify-content: flex-start;
}
.plugins-page__search {
width: 100%;
}
}
</style>
+624
View File
@@ -0,0 +1,624 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const posts = ref([]);
const stats = ref({ all: 0, publish: 0, draft: 0, pending: 0, trash: 0 });
const pagination = ref({ page: 1, perPage: 20, total: 0, totalPages: 1 });
const loading = ref(true);
const busyId = ref(0);
const error = ref('');
const notice = ref('');
const search = ref('');
const status = ref('all');
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
const request = async (path, options = {}) => {
const response = await fetch(`${apiBase.value}${path}`, {
credentials: 'same-origin',
...options,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(options.headers || {}),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.message || data.error || 'Request failed');
}
return data;
};
const load = async () => {
loading.value = true;
error.value = '';
try {
const params = new URLSearchParams({
status: status.value,
search: search.value.trim(),
page: String(pagination.value.page),
perPage: String(pagination.value.perPage),
});
const data = await request(`/posts?${params.toString()}`);
posts.value = data.items || [];
stats.value = data.stats || stats.value;
pagination.value = data.pagination || pagination.value;
} catch (err) {
error.value = err.message || 'Could not load posts';
} finally {
loading.value = false;
}
};
onMounted(load);
let searchTimer = 0;
watch(search, () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
pagination.value.page = 1;
load();
}, 250);
});
const setStatus = (next) => {
status.value = next;
pagination.value.page = 1;
load();
};
const filters = computed(() => [
{ key: 'all', label: 'All', count: stats.value.all },
{ key: 'publish', label: 'Published', count: stats.value.publish },
{ key: 'draft', label: 'Draft', count: stats.value.draft },
{ key: 'pending', label: 'Pending', count: stats.value.pending },
{ key: 'trash', label: 'Trash', count: stats.value.trash },
]);
const trashPost = async (post) => {
if (!post || busyId.value) return;
busyId.value = post.id;
error.value = '';
notice.value = '';
try {
const data = await request('/posts/trash', {
method: 'POST',
body: JSON.stringify({ id: post.id }),
});
notice.value = data.message || 'Post moved to trash.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const restorePost = async (post) => {
if (!post || busyId.value) return;
busyId.value = post.id;
error.value = '';
notice.value = '';
try {
const data = await request('/posts/restore', {
method: 'POST',
body: JSON.stringify({ id: post.id }),
});
notice.value = data.message || 'Post restored.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const deletePost = async (post) => {
if (!post || busyId.value) return;
if (!window.confirm(`Permanently delete "${post.title}"? This cannot be undone.`)) return;
busyId.value = post.id;
error.value = '';
notice.value = '';
try {
const data = await request(`/posts/${post.id}`, { method: 'DELETE' });
notice.value = data.message || 'Post deleted.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const goPage = (page) => {
const next = Math.min(Math.max(1, page), pagination.value.totalPages || 1);
if (next === pagination.value.page) return;
pagination.value.page = next;
load();
};
const rowRange = computed(() => {
if (!pagination.value.total) return '0 to 0';
const start = (pagination.value.page - 1) * pagination.value.perPage + 1;
const end = Math.min(pagination.value.total, pagination.value.page * pagination.value.perPage);
return `${start} to ${end}`;
});
</script>
<template>
<main class="posts-page">
<header class="posts-page__header">
<div>
<p class="posts-page__eyebrow">Content</p>
<h1>Posts</h1>
</div>
<div class="posts-page__actions">
<a class="button-primary" :href="serverData.newPostUrl">Add Post</a>
<button type="button" class="icon-button" title="Refresh posts" :disabled="loading" @click="load">
<span class="dashicons dashicons-update" />
</button>
</div>
</header>
<section class="posts-page__toolbar" aria-label="Post filters and search">
<div class="posts-page__filters">
<button
v-for="filter in filters"
:key="filter.key"
type="button"
class="filter-pill"
:class="{ 'is-active': status === filter.key }"
@click="setStatus(filter.key)"
>
<span>{{ filter.label }}</span>
<strong>{{ filter.count }}</strong>
</button>
</div>
<label class="posts-page__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Search posts..." />
</label>
</section>
<div v-if="notice" class="posts-notice posts-notice--success">{{ notice }}</div>
<div v-if="error" class="posts-notice posts-notice--error">{{ error }}</div>
<section class="posts-table" aria-label="Posts">
<div class="posts-table__head">
<div>Title</div>
<div>Author</div>
<div>Categories</div>
<div>Comments</div>
<div>Date</div>
<div>Actions</div>
</div>
<div v-if="loading" class="posts-table__empty">Loading posts...</div>
<div v-else-if="!posts.length" class="posts-table__empty">No posts match your filters.</div>
<article v-for="post in posts" v-else :key="post.id" class="post-row">
<div class="post-row__title" data-label="Title">
<a :href="post.editUrl">{{ post.title }}</a>
<span class="status-badge" :class="`status-badge--${post.status}`">{{ post.statusLabel }}</span>
</div>
<div class="post-row__cell" data-label="Author">{{ post.author }}</div>
<div class="post-row__cell" data-label="Categories">
{{ post.categories.length ? post.categories.join(', ') : '—' }}
</div>
<div class="post-row__cell" data-label="Comments">{{ post.commentCount }}</div>
<div class="post-row__cell" data-label="Date">
<strong>{{ post.dateLabel }}</strong>
<span>{{ post.date }}</span>
</div>
<div class="post-row__actions">
<a class="icon-button icon-button--inline" title="Edit post" :href="post.editUrl">
<span class="dashicons dashicons-edit" />
</a>
<a
v-if="post.viewUrl"
class="icon-button icon-button--inline"
title="View post"
:href="post.viewUrl"
target="_blank"
rel="noopener"
>
<span class="dashicons dashicons-visibility" />
</a>
<button
v-if="post.status !== 'trash'"
type="button"
class="icon-button icon-button--inline"
title="Move to trash"
:disabled="busyId === post.id"
@click="trashPost(post)"
>
<span class="dashicons dashicons-trash" />
</button>
<template v-else>
<button
type="button"
class="icon-button icon-button--inline"
title="Restore"
:disabled="busyId === post.id"
@click="restorePost(post)"
>
<span class="dashicons dashicons-undo" />
</button>
<button
v-if="post.canDelete"
type="button"
class="icon-button icon-button--inline"
title="Delete permanently"
:disabled="busyId === post.id"
@click="deletePost(post)"
>
<span class="dashicons dashicons-no-alt" />
</button>
</template>
</div>
</article>
<footer v-if="!loading && posts.length" class="posts-table__footer">
<div>Page Size: {{ pagination.perPage }}</div>
<div>{{ rowRange }} of {{ pagination.total }}</div>
<div class="posts-table__pager">
<button type="button" :disabled="pagination.page <= 1" @click="goPage(1)">
<span class="dashicons dashicons-controls-skipback" />
</button>
<button type="button" :disabled="pagination.page <= 1" @click="goPage(pagination.page - 1)">
<span class="dashicons dashicons-arrow-left-alt2" />
</button>
<span>Page {{ pagination.page }} of {{ pagination.totalPages }}</span>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.page + 1)"
>
<span class="dashicons dashicons-arrow-right-alt2" />
</button>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.totalPages)"
>
<span class="dashicons dashicons-controls-skipforward" />
</button>
</div>
</footer>
</section>
</main>
</template>
<style scoped>
.posts-page {
min-height: calc(100vh - 112px);
padding: 0;
color: var(--aurora-text);
background: transparent;
}
.posts-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 24px;
}
.posts-page__eyebrow {
margin: 0 0 7px;
color: var(--aurora-text-muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.posts-page h1 {
margin: 0;
color: var(--aurora-text);
font-size: 1.45rem;
line-height: 1.15;
}
.posts-page__actions,
.posts-page__toolbar,
.posts-page__filters,
.post-row__actions {
display: flex;
align-items: center;
}
.posts-page__actions {
gap: 10px;
}
.button-primary {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 16px;
border-radius: var(--aurora-radius-sm);
background: var(--aurora-accent);
color: var(--aurora-accent-text);
font-size: 0.84rem;
font-weight: 700;
text-decoration: none;
}
.posts-page__toolbar {
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.posts-page__filters {
flex-wrap: wrap;
gap: 8px;
}
.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 12px;
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
}
.filter-pill strong {
min-width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--aurora-frame-bg);
color: inherit;
font-size: 0.72rem;
}
.filter-pill.is-active {
border-color: var(--aurora-text-muted);
background: var(--aurora-frame-bg);
color: var(--aurora-text);
}
.posts-page__search {
width: min(320px, 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);
}
.posts-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;
}
.posts-page__search input::placeholder {
color: var(--aurora-text-muted);
}
.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;
text-decoration: none;
}
.icon-button:hover:not(:disabled) {
border-color: var(--aurora-text-muted);
color: var(--aurora-text);
}
.icon-button--inline {
width: 30px;
min-height: 30px;
border: 0;
background: transparent;
color: var(--aurora-text-muted);
}
.icon-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.posts-notice {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
font-size: 0.78rem;
font-weight: 750;
}
.posts-table {
overflow: hidden;
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-sm);
background: var(--aurora-frame-bg);
}
.posts-table__head,
.post-row {
display: grid;
grid-template-columns: minmax(220px, 1.6fr) minmax(120px, 0.8fr) minmax(160px, 1fr) 110px 130px 132px;
align-items: center;
}
.posts-table__head {
min-height: 48px;
border-bottom: 1px solid var(--aurora-border);
color: var(--aurora-text);
font-size: 0.79rem;
font-weight: 850;
}
.posts-table__head > div {
min-height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
}
.post-row {
min-height: 66px;
border-bottom: 1px solid var(--aurora-border);
}
.post-row:hover {
background: var(--aurora-bg-subtle);
}
.post-row__title {
min-width: 0;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.post-row__title a {
overflow: hidden;
color: var(--aurora-text);
font-weight: 800;
font-size: 0.86rem;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-row__title a:hover {
text-decoration: underline;
}
.post-row__cell {
min-width: 0;
padding: 14px 16px;
color: var(--aurora-text-muted);
font-size: 0.8rem;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-row__cell span {
display: block;
color: var(--aurora-text-muted);
font-size: 0.74rem;
}
.post-row__cell strong {
display: block;
color: var(--aurora-text-muted);
font-size: 0.8rem;
}
.status-badge {
width: fit-content;
border-radius: 999px;
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
padding: 2px 9px;
font-size: 0.68rem;
font-weight: 850;
box-shadow: inset 0 0 0 1px var(--aurora-border);
}
.post-row__actions {
gap: 8px;
padding: 14px 16px;
}
.posts-table__empty {
padding: 38px 18px;
color: var(--aurora-text-muted);
font-size: 0.85rem;
text-align: center;
}
.posts-table__footer {
min-height: 54px;
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;
}
.posts-table__pager {
display: flex;
align-items: center;
gap: 8px;
}
.posts-table__pager button {
width: 28px;
height: 28px;
border: 0;
border-radius: var(--aurora-radius-sm);
background: transparent;
color: var(--aurora-text-muted);
cursor: pointer;
}
.posts-table__pager button:not(:disabled):hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.posts-table__pager button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 1280px) {
.posts-table__head,
.post-row {
grid-template-columns: minmax(200px, 1.6fr) minmax(100px, 0.8fr) minmax(140px, 1fr) 100px 120px 122px;
}
}
@media (max-width: 1080px) {
.posts-table__head {
display: none;
}
.post-row {
grid-template-columns: 1fr;
align-items: stretch;
padding: 8px 0;
}
.post-row__title,
.post-row__cell,
.post-row__actions {
padding: 8px 16px;
}
.post-row__cell {
display: flex;
justify-content: space-between;
gap: 16px;
white-space: normal;
}
.post-row__cell::before {
content: attr(data-label);
color: var(--aurora-text-muted);
font-weight: 800;
}
.posts-table__footer,
.posts-page__header,
.posts-page__toolbar {
align-items: stretch;
flex-direction: column;
}
.posts-table__footer {
padding: 14px 16px;
gap: 10px;
}
.posts-page__search {
width: 100%;
}
}
</style>
+205
View File
@@ -0,0 +1,205 @@
<script setup>
import { reactive, ref, onMounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
const meUrl = `${props.serverData.restUrl}wp/v2/users/me?context=edit`;
const form = reactive({
first_name: '',
last_name: '',
nickname: '',
email: '',
url: '',
description: '',
});
const loading = ref(true);
const saving = ref(false);
const notice = ref('');
const error = ref('');
const load = async () => {
loading.value = true;
try {
const res = await fetch(meUrl, { headers: headers() });
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not load your profile.');
form.first_name = data.first_name || '';
form.last_name = data.last_name || '';
form.nickname = data.nickname || '';
form.email = data.email || '';
form.url = data.url || '';
form.description = data.description || '';
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
};
onMounted(load);
const save = async () => {
saving.value = true;
error.value = '';
notice.value = '';
try {
const res = await fetch(meUrl, {
method: 'POST',
headers: headers(),
body: JSON.stringify(form),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not save your profile.');
notice.value = 'Profile updated.';
setTimeout(() => (notice.value = ''), 2500);
} catch (e) {
error.value = e.message;
} finally {
saving.value = false;
}
};
// Password ----------------------------------------------------------------
const newPassword = ref('');
const changingPassword = ref(false);
const passwordNotice = ref('');
const passwordError = ref('');
const generatePassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
newPassword.value = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
};
const changePassword = async () => {
passwordError.value = '';
passwordNotice.value = '';
if (!newPassword.value) return;
changingPassword.value = true;
try {
const res = await fetch(`${props.serverData.restUrl}aurora-admin/v1/profile/password`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ password: newPassword.value }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not change your password.');
passwordNotice.value = 'Password updated.';
newPassword.value = '';
setTimeout(() => (passwordNotice.value = ''), 2500);
} catch (e) {
passwordError.value = e.message;
} finally {
changingPassword.value = false;
}
};
</script>
<template>
<main class="profile-page">
<header class="profile-page__header">
<div>
<p class="profile-page__eyebrow">Accounts</p>
<h1>Your Profile</h1>
</div>
</header>
<div v-if="notice" class="profile-notice profile-notice--success">{{ notice }}</div>
<div v-if="error" class="profile-notice profile-notice--error">{{ error }}</div>
<p v-if="loading" class="profile-empty">Loading…</p>
<form v-else class="profile-form" @submit.prevent="save">
<label class="profile-field">
<span>First Name</span>
<input v-model="form.first_name" type="text" />
</label>
<label class="profile-field">
<span>Last Name</span>
<input v-model="form.last_name" type="text" />
</label>
<label class="profile-field">
<span>Nickname</span>
<input v-model="form.nickname" type="text" />
</label>
<label class="profile-field">
<span>Email</span>
<input v-model="form.email" type="email" />
</label>
<label class="profile-field">
<span>Website</span>
<input v-model="form.url" type="text" />
</label>
<label class="profile-field">
<span>Biographical Info</span>
<textarea v-model="form.description" rows="4" />
</label>
<div class="profile-form__actions">
<button type="submit" class="button-primary" :disabled="saving">
{{ saving ? 'Saving…' : 'Update Profile' }}
</button>
</div>
</form>
<section class="profile-password">
<h2>Change Password</h2>
<div v-if="passwordNotice" class="profile-notice profile-notice--success">{{ passwordNotice }}</div>
<div v-if="passwordError" class="profile-notice profile-notice--error">{{ passwordError }}</div>
<div class="profile-password__row">
<input v-model="newPassword" type="text" placeholder="New password" />
<button type="button" class="button" @click="generatePassword">Generate</button>
<button type="button" class="button-primary" :disabled="changingPassword || !newPassword" @click="changePassword">
{{ changingPassword ? 'Updating…' : 'Change Password' }}
</button>
</div>
</section>
</main>
</template>
<style scoped>
.profile-page { max-width: 640px; margin: 0 auto; }
.profile-page__header { margin-bottom: 16px; }
.profile-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.profile-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.profile-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
.profile-notice { padding: 10px 14px; border-radius: var(--aurora-radius-sm); margin-bottom: 16px; font-size: 0.875rem; }
.profile-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.profile-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.profile-notice--info { background: var(--aurora-bg-subtle); color: var(--aurora-text-muted); }
.profile-notice--info a { color: var(--aurora-accent); }
.profile-form, .profile-password {
display: flex; flex-direction: column; gap: 18px;
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md);
padding: 24px;
margin-bottom: 20px;
}
.profile-field { display: flex; flex-direction: column; gap: 6px; }
.profile-field span { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-text); }
.profile-field input,
.profile-field textarea {
padding: 9px 12px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.875rem;
font-family: inherit; resize: vertical;
}
.profile-form__actions { margin-top: 4px; }
.profile-password h2 { margin: 0 0 4px; font-size: 0.9375rem; color: var(--aurora-text); }
.profile-password__row { display: flex; gap: 8px; }
.profile-password__row input {
flex: 1; padding: 9px 12px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.875rem;
}
</style>
+255
View File
@@ -0,0 +1,255 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import AppListShell from '../_shared/AppListShell.vue';
import AppListItem from '../_shared/AppListItem.vue';
import { describeCapability } from './capabilityDescriptions.js';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path, opts = {}) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(opts.headers || {}),
},
}).then((r) => {
if (!r.ok) throw new Error('Request failed');
return r.json();
});
const roles = ref([]);
const allCapabilities = ref([]);
const search = ref('');
const selectedSlug = ref(null);
const isCreating = ref(false);
const errorMsg = ref('');
const load = async () => {
const data = await api('/roles');
roles.value = data.roles;
allCapabilities.value = data.allCapabilities;
};
onMounted(load);
const filtered = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return roles.value;
return roles.value.filter((r) => r.name.toLowerCase().includes(q));
});
const selected = computed(() => roles.value.find((r) => r.slug === selectedSlug.value) || null);
const draft = reactive({ slug: '', name: '', capabilities: [] });
const selectRole = (r) => {
selectedSlug.value = r.slug;
isCreating.value = false;
errorMsg.value = '';
Object.assign(draft, { slug: r.slug, name: r.name, capabilities: [...r.capabilities] });
};
const newRole = () => {
selectedSlug.value = null;
isCreating.value = true;
errorMsg.value = '';
Object.assign(draft, { slug: '', name: '', capabilities: ['read'] });
};
const toggleCap = (cap) => {
const idx = draft.capabilities.indexOf(cap);
if (idx === -1) draft.capabilities.push(cap);
else draft.capabilities.splice(idx, 1);
};
const saving = ref(false);
const save = async () => {
saving.value = true;
errorMsg.value = '';
try {
if (isCreating.value) {
await api('/roles', {
method: 'POST',
body: JSON.stringify({ slug: draft.slug, displayName: draft.name, capabilities: draft.capabilities }),
});
await load();
isCreating.value = false;
selectRole(roles.value.find((r) => r.slug === draft.slug));
} else if (selectedSlug.value) {
await api(`/roles/${selectedSlug.value}`, {
method: 'PUT',
body: JSON.stringify({ displayName: draft.name, capabilities: draft.capabilities }),
});
await load();
}
} catch (e) {
errorMsg.value = 'Could not save — check the role slug is unique.';
} finally {
saving.value = false;
}
};
const remove = async () => {
if (!selectedSlug.value || selected.value?.isDefault) return;
await api(`/roles/${selectedSlug.value}`, { method: 'DELETE' });
selectedSlug.value = null;
await load();
};
</script>
<template>
<AppListShell
v-model:search="search"
title="Role Editor"
search-placeholder="Search roles…"
:count-label="`${filtered.length} ROLES`"
detail-icon="dashicons-groups"
detail-title="Role Details"
detail-text="Select a role from the list to view and edit its capabilities."
:has-selection="!!selected || isCreating"
>
<template #header-actions>
<button type="button" class="is-primary" @click="newRole">+ New Role</button>
</template>
<template #list>
<AppListItem
v-for="r in filtered"
:key="r.slug"
icon="dashicons-groups"
:title="r.name"
:subtitle="`${r.userCount} user${r.userCount === 1 ? '' : 's'}`"
:active="r.slug === selectedSlug"
@click="selectRole(r)"
/>
</template>
<template #detail>
<div class="role-editor">
<div class="role-editor__row">
<input
v-if="isCreating"
v-model="draft.slug"
type="text"
placeholder="role_slug"
class="role-editor__slug"
/>
<input v-model="draft.name" type="text" placeholder="Display name" class="role-editor__name" />
</div>
<p v-if="errorMsg" class="role-editor__error">{{ errorMsg }}</p>
<div class="role-editor__section">
<h3>Capabilities</h3>
<div class="role-editor__caps">
<div v-for="cap in allCapabilities" :key="cap" class="role-editor__cap">
<label>
<input type="checkbox" :checked="draft.capabilities.includes(cap)" @change="toggleCap(cap)" />
{{ cap }}
</label>
<span class="role-editor__cap-info" tabindex="0">
<span class="dashicons dashicons-info-outline" />
<span class="role-editor__tooltip" role="tooltip">{{ describeCapability(cap) }}</span>
</span>
</div>
</div>
</div>
<div class="role-editor__actions">
<button
type="button"
class="is-danger"
:disabled="!selectedSlug || selected?.isDefault"
:title="selected?.isDefault ? 'Default WordPress roles cannot be deleted' : ''"
@click="remove"
>
Delete
</button>
<button type="button" class="is-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</template>
</AppListShell>
</template>
<style scoped>
.role-editor { display: flex; flex-direction: column; gap: 18px; }
.role-editor__row { display: flex; gap: 10px; }
.role-editor__slug, .role-editor__name {
padding: 8px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.875rem;
}
.role-editor__name { flex: 1; font-weight: 700; }
.role-editor__slug { width: 160px; }
.role-editor__error { color: #ef4444; font-size: 0.8125rem; margin: 0; }
.role-editor__section h3 {
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em;
color: var(--aurora-text-muted); margin: 0 0 8px;
}
.role-editor__caps {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px 16px; max-height: 380px; overflow-y: auto;
/* Tooltips (position: absolute, below) need room to render outside the
grid cell without getting clipped by the scroll container. */
padding-bottom: 8px;
}
.role-editor__cap {
display: flex; align-items: center; justify-content: space-between; gap: 6px;
font-size: 0.8125rem; color: var(--aurora-text);
}
.role-editor__cap label { display: flex; align-items: center; gap: 6px; min-width: 0; }
.role-editor__cap label span,
.role-editor__cap label {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.role-editor__cap-info {
position: relative;
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; flex-shrink: 0;
color: var(--aurora-text-muted); cursor: help;
}
.role-editor__cap-info .dashicons { font-size: 15px; width: 15px; height: 15px; }
.role-editor__cap-info:hover { color: var(--aurora-accent); }
.role-editor__tooltip {
position: absolute;
bottom: calc(100% + 8px);
right: -8px;
z-index: 20;
width: 260px;
padding: 10px 12px;
border-radius: var(--aurora-radius-sm);
border: 1px solid var(--aurora-frame-border);
background: var(--aurora-frame-bg);
color: var(--aurora-text);
font-size: 0.75rem;
font-weight: 400;
line-height: 1.5;
white-space: normal;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
visibility: hidden;
opacity: 0;
transition: opacity 0.1s ease;
pointer-events: none;
}
.role-editor__cap-info:hover .role-editor__tooltip,
.role-editor__cap-info:focus .role-editor__tooltip {
visibility: visible;
opacity: 1;
}
.role-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
.role-editor__actions button {
border: none; border-radius: var(--aurora-radius-sm); padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.role-editor__actions .is-primary { background: var(--aurora-accent); color: #fff; }
.role-editor__actions .is-danger { color: #ef4444; }
.role-editor__actions button:disabled { opacity: 0.5; cursor: default; }
</style>
@@ -0,0 +1,101 @@
/**
* Human-readable descriptions for WordPress's capability system, shown as
* an info tooltip next to each capability checkbox in Role Editor.
*
* Two groups: the ~60 named capabilities WordPress core actually checks
* (edit_posts, manage_options, etc.), and the legacy level_0…level_10
* "user level" system from WordPress 1.x/early 2.x. Levels predate named
* capabilities and are deprecated, but WordPress still creates them on
* every default role for backward compatibility with old themes/plugins
* that call current_user_can('level_X') or check $user->user_level
* directly instead of a named capability — core itself hasn't checked a
* level anywhere for years. They don't map to anything meaningful beyond
* that compatibility shim, which is exactly why they look unexplained.
*/
const LEVEL_MATCH = /^level_(\d{1,2})$/;
const CORE_CAPABILITIES = {
read: 'The baseline capability every logged-in user needs just to access wp-admin at all.',
edit_posts: 'Edit their own posts.',
edit_others_posts: "Edit posts authored by other users.",
edit_private_posts: 'Edit private posts (not publicly visible).',
edit_published_posts: 'Edit posts that have already been published.',
publish_posts: 'Publish posts, rather than only saving them as drafts.',
delete_posts: 'Delete their own posts.',
delete_others_posts: "Delete posts authored by other users.",
delete_private_posts: 'Delete private posts.',
delete_published_posts: 'Delete posts that have already been published.',
read_private_posts: "View private posts that aren't publicly visible.",
edit_pages: 'Edit their own pages.',
edit_others_pages: "Edit pages authored by other users.",
edit_private_pages: 'Edit private pages.',
edit_published_pages: 'Edit pages that have already been published.',
publish_pages: 'Publish pages, rather than only saving them as drafts.',
delete_pages: 'Delete their own pages.',
delete_others_pages: "Delete pages authored by other users.",
delete_private_pages: 'Delete private pages.',
delete_published_pages: 'Delete pages that have already been published.',
read_private_pages: "View private pages that aren't publicly visible.",
moderate_comments: 'Approve, edit, and delete comments awaiting moderation.',
manage_categories: 'Create, edit, and delete post categories and tags.',
manage_links: 'Manage the legacy Links (blogroll) feature.',
upload_files: 'Upload files to the Media Library.',
list_users: 'View the list of registered users.',
create_users: 'Create new user accounts.',
edit_users: "Edit other users' profiles and roles.",
delete_users: 'Delete other user accounts.',
promote_users: "Change other users' roles.",
remove_users: 'Remove other users from a site (multisite: from this one site, not delete the account entirely).',
manage_options: "Change site-wide settings — one of the broadest capabilities short of being a full administrator.",
edit_theme_options: 'Change theme settings such as menus, widgets, and the Customizer.',
switch_themes: 'Activate a different installed theme.',
install_themes: 'Install new themes from the WordPress theme directory or by file upload.',
update_themes: 'Update installed themes to a newer version.',
delete_themes: 'Delete theme files from the site.',
edit_themes: 'Edit theme source code directly from the admin — high risk, equivalent to direct server file access.',
activate_plugins: 'Activate and deactivate plugins.',
install_plugins: 'Install new plugins from the WordPress plugin directory or by file upload.',
update_plugins: 'Update installed plugins to a newer version.',
delete_plugins: 'Delete plugin files from the site.',
edit_plugins: 'Edit plugin source code directly from the admin — high risk, equivalent to direct server file access.',
edit_files: 'Edit plugin and theme files directly from the admin (Theme/Plugin Editor) — high risk, equivalent to direct server file access.',
edit_dashboard: "Customize the admin dashboard's own widgets.",
update_core: 'Update WordPress core to a newer version.',
import: 'Import content into the site from an external source (Tools › Import).',
export: 'Export site content to a file (Tools › Export).',
customize: 'Access the Theme Customizer.',
unfiltered_html: 'Post raw, unfiltered HTML and JavaScript in content — a real cross-site-scripting (XSS) risk if granted to anyone other than a fully trusted administrator.',
unfiltered_upload: "Upload any file type without WordPress's normal file-type restrictions — a real security risk; only Administrators have this by default, and only on single-site installs.",
manage_network: '(Multisite) Access the network admin dashboard.',
manage_sites: '(Multisite) Create, edit, and delete individual sites on the network.',
manage_network_users: '(Multisite) Manage user accounts network-wide.',
manage_network_plugins: '(Multisite) Manage plugins network-wide.',
manage_network_themes: '(Multisite) Manage themes network-wide.',
manage_network_options: '(Multisite) Change network-wide settings.',
delete_site: '(Multisite) Delete this entire site.',
create_sites: '(Multisite) Create new sites on the network.',
};
export const describeCapability = (cap) => {
const levelMatch = cap.match(LEVEL_MATCH);
if (levelMatch) {
const n = Number(levelMatch[1]);
return (
`Legacy WordPress "user level" ${n} (0–10 scale, 10 highest) from the old pre-2.0 permissions ` +
"system. Deprecated in favor of the named capabilities below, but WordPress still assigns the " +
"full set of levels a role qualifies for, purely so old themes/plugins that check " +
`current_user_can('level_${n}') or a user's numeric level directly still work. Core itself hasn't ` +
'checked a level in years — safe to leave alone; removing it only affects compatibility with very old code.'
);
}
return CORE_CAPABILITIES[cap] || 'Custom capability — not part of WordPress core. Likely added by a plugin or theme.';
};
+402
View File
@@ -0,0 +1,402 @@
<script setup>
import { ref, reactive, computed, watch, nextTick } from 'vue';
import { settingsGroups, settingsCategories } from './settingsConfig.js';
import SettingsField from './components/SettingsField.vue';
import { applyThemePalette, applyFontFamily } from './applyLiveTheme.js';
const props = defineProps({
serverData: { type: Object, required: true },
});
const visibleCategories = settingsCategories;
// Working copy of the settings, seeded from the server + field defaults.
const settings = reactive({ ...(props.serverData.settings || {}) });
visibleCategories.forEach((cat) =>
cat.fields.forEach((f) => {
if (settings[f.key] === undefined && f.default !== undefined) {
settings[f.key] = f.default;
}
})
);
// Active category, synced with the URL ?category= param.
const initialCat = new URLSearchParams(window.location.search).get('category');
const activeId = ref(
visibleCategories.some((c) => c.id === initialCat) ? initialCat : visibleCategories[0].id
);
const activeCategory = computed(
() => visibleCategories.find((c) => c.id === activeId.value) || visibleCategories[0]
);
const groupOfCategory = (catId) =>
settingsGroups.find((g) => g.categories.some((c) => c.id === catId));
// Collapsible nav groups. Default open; collapse state persisted per group.
const OPEN_KEY = 'aurora-settings-groups-open';
const loadOpen = () => {
try {
return JSON.parse(localStorage.getItem(OPEN_KEY)) || {};
} catch {
return {};
}
};
const openState = reactive(loadOpen());
// Keep the active category's group open so its item is always reachable.
const activeGroup = groupOfCategory(activeId.value);
if (activeGroup) openState[activeGroup.id] = true;
const isGroupOpen = (id) => openState[id] !== false;
const toggleGroup = (id) => {
openState[id] = !isGroupOpen(id);
localStorage.setItem(OPEN_KEY, JSON.stringify(openState));
};
// The content panel scrolls independently of the nav (see .settings__content
// below) so picking a category from a scrolled-down spot in the nav doesn't
// require scrolling the whole page back up to see it. But the panel is one
// persistent DOM node across category switches — its scrollTop doesn't reset
// on its own unless the new content happens to be shorter, so without this
// a long category could inherit a scrolled-down position from the previous
// one and land on the same "content is off-screen" problem this was meant
// to fix.
const contentEl = ref(null);
const selectCategory = (id) => {
activeId.value = id;
const g = groupOfCategory(id);
if (g) openState[g.id] = true;
const url = new URL(window.location.href);
url.searchParams.set('category', id);
window.history.replaceState({}, '', url);
nextTick(() => {
if (contentEl.value) contentEl.value.scrollTop = 0;
});
};
// Search filter over the nav — matches on group title, category title, or
// subtitle; groups with no surviving categories drop out entirely.
const query = ref('');
const searching = computed(() => query.value.trim().length > 0);
const filteredGroups = computed(() => {
const q = query.value.trim().toLowerCase();
if (!q) return settingsGroups;
return settingsGroups
.map((g) => {
const groupMatch = g.title.toLowerCase().includes(q);
const categories = g.categories.filter(
(c) =>
groupMatch ||
c.title.toLowerCase().includes(q) ||
c.subtitle.toLowerCase().includes(q)
);
return { ...g, categories };
})
.filter((g) => g.categories.length > 0);
});
// Options for multiselect fields, resolved from server-provided lists.
const optionsFor = (field) => {
if (field.optionsSource === 'roles') return props.serverData.roles || [];
if (field.optionsSource === 'postTypes') return props.serverData.postTypes || [];
return field.options || [];
};
// Live preview — applied as soon as a picker changes, not just after
// Save + reload.
watch(
() => settings.theme_preset,
(preset) => applyThemePalette(preset || 'indigo'),
{ immediate: true }
);
watch(
() => settings.font_family,
(family) => applyFontFamily(family),
{ immediate: true }
);
// Save -------------------------------------------------------------------
const saving = ref(false);
const savedMsg = ref('');
const save = async () => {
saving.value = true;
savedMsg.value = '';
try {
const res = await fetch(`${props.serverData.restUrl}wp/v2/settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce },
body: JSON.stringify({ aurora_admin_settings: { ...settings } }),
});
if (!res.ok) throw new Error();
savedMsg.value = 'Saved';
setTimeout(() => (savedMsg.value = ''), 2500);
} catch {
savedMsg.value = 'Could not save';
} finally {
saving.value = false;
}
};
// Import / export --------------------------------------------------------
const exportSettings = () => {
const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'aurora-admin-settings.json';
a.click();
URL.revokeObjectURL(a.href);
};
const importInput = ref(null);
const importSettings = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const obj = JSON.parse(reader.result);
Object.assign(settings, obj);
} catch {
savedMsg.value = 'Invalid file';
}
};
reader.readAsText(file);
e.target.value = '';
};
</script>
<template>
<div class="settings">
<!-- Left nav panel -->
<aside class="settings__nav">
<div class="settings__nav-head">
<h1 class="settings__title">Settings</h1>
<div class="settings__actions">
<button type="button" title="Export settings" @click="exportSettings">
<span class="dashicons dashicons-download" />
</button>
<button type="button" title="Import settings" @click="importInput.click()">
<span class="dashicons dashicons-upload" />
</button>
<input
ref="importInput"
type="file"
accept="application/json"
class="settings__file"
@change="importSettings"
/>
</div>
</div>
<div class="settings__search">
<span class="dashicons dashicons-search" />
<input v-model="query" type="text" placeholder="Search settings…" />
</div>
<ul class="settings__groups">
<li v-for="group in filteredGroups" :key="group.id" class="settings__group">
<button
type="button"
class="settings__group-head"
:aria-expanded="isGroupOpen(group.id) || searching"
@click="toggleGroup(group.id)"
>
<span class="settings__group-icon dashicons" :class="group.icon" />
<span class="settings__group-title">{{ group.title }}</span>
<span
class="settings__group-caret dashicons dashicons-arrow-down-alt2"
:class="{ 'is-collapsed': !isGroupOpen(group.id) && !searching }"
/>
</button>
<ul v-show="isGroupOpen(group.id) || searching" class="settings__list">
<li v-for="cat in group.categories" :key="cat.id">
<button
type="button"
class="settings__cat"
:class="{ 'settings__cat--active': cat.id === activeId }"
@click="selectCategory(cat.id)"
>
<span class="settings__cat-icon dashicons" :class="cat.icon" />
<span class="settings__cat-title">{{ cat.title }}</span>
</button>
</li>
</ul>
</li>
<li v-if="!filteredGroups.length" class="settings__empty">
No settings match “{{ query }}”.
</li>
</ul>
</aside>
<!-- Right content panel -->
<section ref="contentEl" class="settings__content">
<header class="settings__content-head">
<div>
<h2 class="settings__content-title">{{ activeCategory.title }}</h2>
<p class="settings__content-sub">{{ activeCategory.subtitle }}</p>
</div>
<div class="settings__save-wrap">
<span v-if="savedMsg" class="settings__saved">{{ savedMsg }}</span>
<button type="button" class="settings__save" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</header>
<div class="settings__fields">
<SettingsField
v-for="f in activeCategory.fields"
:key="f.key"
:field="f"
:options="optionsFor(f)"
:disabled="!!f.dependsOn && !settings[f.dependsOn]"
v-model="settings[f.key]"
/>
</div>
</section>
</div>
</template>
<style scoped>
.settings {
display: flex;
align-items: flex-start;
gap: 20px;
/* height, not min-height — the outer #wpbody-content card (shell-
frame.css) already caps itself to the viewport and scrolls as a
whole. Filling it exactly (rather than growing taller than it) is
what lets .settings__nav and .settings__content become their own
independent scroll regions below instead of both scrolling together
as one long page. */
height: calc(100vh - var(--aurora-toolbar-h) - 60px);
margin: -32px; /* cancel the frame padding */
padding: 22px; /* inset the two boxes; the frame's right gutter adds the rest */
box-sizing: border-box;
overflow: hidden;
/* No background here — html/body already paint the page gradient
(shell-frame.css); repainting the same var(--aurora-app-bg) here was
just a redundant duplicate layer on top of it. */
}
/* Left nav — no background/border of its own; the page gradient shows
straight through so it reads as one surface instead of a boxed panel.
Scrolls independently of the content panel (own height + overflow)
so its scroll position is preserved when you switch categories. */
.settings__nav {
width: 340px;
flex-shrink: 0;
height: 100%;
overflow-y: auto;
border-radius: var(--aurora-radius-lg);
padding: 24px 20px;
box-sizing: border-box;
}
.settings__nav-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 18px;
}
.settings__title { font-size: 1.35rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
.settings__actions { display: flex; gap: 4px; }
.settings__actions button {
width: 30px; height: 30px; border: none; background: none;
color: var(--aurora-text-muted); cursor: pointer; border-radius: var(--aurora-radius-sm);
display: inline-flex; align-items: center; justify-content: center;
}
.settings__actions button:hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
.settings__file { display: none; }
.settings__search {
display: flex; align-items: center; gap: 8px;
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md); padding: 8px 12px; margin-bottom: 16px;
color: var(--aurora-text-muted);
}
.settings__search input {
border: none; background: none; outline: none; color: var(--aurora-text);
font-size: 0.8125rem; width: 100%;
}
.settings__groups { list-style: none; margin: 0; padding: 0; }
.settings__group { margin-bottom: 4px; }
.settings__group-head {
display: flex; align-items: center; gap: 8px; width: 100%;
padding: 6px 8px; border: none; background: none; cursor: pointer;
border-radius: var(--aurora-radius-sm); color: var(--aurora-text-muted);
}
.settings__group-head:hover { color: var(--aurora-text); }
.settings__group-icon { font-size: 15px; width: 16px; height: 16px; }
.settings__group-title {
font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.04em;
text-transform: uppercase;
}
.settings__group-caret {
margin-left: auto; font-size: 16px; width: 16px; height: 16px;
transition: transform 0.15s ease;
}
.settings__group-caret.is-collapsed { transform: rotate(-90deg); }
/* Indent the category items so they read as a submenu nested under their
group header, rather than sitting flush at the same level. */
.settings__list { list-style: none; margin: 2px 0 8px; padding: 0 0 0 14px; }
.settings__cat {
display: flex; align-items: center; gap: 10px; width: 100%;
padding: 8px 10px; border: none; background: none; cursor: pointer;
border-radius: var(--aurora-radius-md); text-align: left; margin-bottom: 1px;
color: var(--aurora-text);
}
.settings__cat:hover { background: var(--aurora-bg-subtle); }
.settings__cat--active { background: var(--aurora-accent-soft); }
.settings__cat-icon {
font-size: 16px; width: 18px; height: 18px; flex-shrink: 0;
color: var(--aurora-text-muted);
}
.settings__cat--active .settings__cat-icon { color: var(--aurora-accent); }
.settings__cat-title { font-size: 0.8125rem; font-weight: 500; }
.settings__cat--active .settings__cat-title { color: var(--aurora-accent); font-weight: 600; }
.settings__empty {
padding: 12px 10px; font-size: 0.8125rem; color: var(--aurora-text-muted);
}
/* Right content — no background/border of its own, same reasoning.
Own height + overflow (independent of .settings__nav's) is the actual
fix: clicking a category always shows its content from the top,
whatever the nav's own scroll position is — resetScrollTop on
category switch (see selectCategory() above) handles the case where
this panel itself was already scrolled down. */
.settings__content {
flex: 1; min-width: 0;
height: 100%;
overflow-y: auto;
border-radius: var(--aurora-radius-lg);
padding: 28px 32px;
box-sizing: border-box;
}
.settings__content-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 24px;
padding-bottom: 22px;
margin-bottom: 4px;
border-bottom: 1px solid var(--aurora-frame-border);
}
.settings__content-title { font-size: 1.6rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
.settings__content-sub { margin: 6px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
.settings__save-wrap { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.settings__saved { font-size: 0.8125rem; color: var(--aurora-text-muted); }
.settings__save {
background: var(--aurora-accent); color: #fff; border: none;
border-radius: var(--aurora-radius-md); padding: 9px 20px; font-size: 0.875rem; font-weight: 500; cursor: pointer;
}
.settings__save:disabled { opacity: 0.6; cursor: default; }
.settings__fields > :last-child { border-bottom: none; }
@media (max-width: 1000px) {
/* Independent-scroll panes only make sense side-by-side — stacked on a
narrow screen, revert to one natural-height column so the page
scrolls as a whole the normal way, same as before this change. */
.settings { flex-direction: column; height: auto; overflow: visible; }
.settings__nav { width: auto; height: auto; overflow: visible; }
.settings__content { height: auto; overflow: visible; }
}
</style>
+26
View File
@@ -0,0 +1,26 @@
/**
* Shared logic for applying the theme/font settings to the live document —
* used both for the settings page's live preview (Settings.vue) and the
* real per-page-load application (SiteShell.vue::onMounted).
*/
import { resolvePreset, buildPalette } from './themePresets.js';
export function applyThemePalette(themePresetId) {
const palette = buildPalette(resolvePreset(themePresetId));
const root = document.documentElement.style;
Object.entries(palette).forEach(([prop, value]) => root.setProperty(prop, value));
}
export function applyFontFamily(family) {
if (!family) return;
const linkId = 'aurora-google-font';
let link = document.getElementById(linkId);
if (!link) {
link = document.createElement('link');
link.id = linkId;
link.rel = 'stylesheet';
document.head.appendChild(link);
}
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@400;500;600;700&display=swap`;
document.documentElement.style.setProperty('--aurora-font-family', `'${family}', sans-serif`);
}
@@ -0,0 +1,409 @@
<script setup>
import { computed, ref } from 'vue';
import Toggle from '@/components/Toggle.vue';
import { themePresets, buildPalette } from '@/pages/settings/themePresets.js';
import { googleFonts } from '@/pages/settings/googleFonts.js';
const swatchPalette = (preset) => buildPalette(preset);
const props = defineProps({
field: { type: Object, required: true },
modelValue: { default: undefined },
// Options for multiselect fields (resolved by the page from optionsSource).
options: { type: Array, default: () => [] },
// 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 },
});
const emit = defineEmits(['update:modelValue']);
const val = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
});
// Repeater helpers ---------------------------------------------------------
const rows = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []));
const addRow = () => emit('update:modelValue', [...rows.value, { find: '', replace: '' }]);
const updateRow = (i, key, v) => {
const next = rows.value.map((r, idx) => (idx === i ? { ...r, [key]: v } : r));
emit('update:modelValue', next);
};
const removeRow = (i) => emit('update:modelValue', rows.value.filter((_, idx) => idx !== i));
// Multiselect helpers -------------------------------------------------------
const selectedValues = computed(() =>
Array.isArray(props.modelValue) ? props.modelValue : []
);
const toggleValue = (value) => {
const next = selectedValues.value.includes(value)
? selectedValues.value.filter((v) => v !== value)
: [...selectedValues.value, value];
emit('update:modelValue', next);
};
// Media picker (image fields) ---------------------------------------------
const pickImage = () => {
const wp = window.wp;
if (!wp || !wp.media) {
// Fallback: prompt for a URL if the media library isn't available.
const url = window.prompt('Image URL');
if (url) emit('update:modelValue', url);
return;
}
const frame = wp.media({ title: props.field.label, multiple: false });
frame.on('select', () => {
const att = frame.state().get('selection').first().toJSON();
emit('update:modelValue', att.url);
});
frame.open();
};
// Font picker (searchable Google Font dropdown) ----------------------------
const fontQuery = ref('');
const fontOpen = ref(false);
const filteredFonts = computed(() => {
const q = fontQuery.value.trim().toLowerCase();
const list = q ? googleFonts.filter((f) => f.toLowerCase().includes(q)) : googleFonts;
return list.slice(0, 40);
});
const pickFont = (name) => {
emit('update:modelValue', name);
fontQuery.value = name;
fontOpen.value = false;
};
const openFontList = () => {
// 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;
};
</script>
<template>
<div class="field">
<div class="field__info">
<div class="field__label">{{ field.label }}</div>
<p v-if="field.description" class="field__desc">{{ field.description }}</p>
</div>
<div class="field__control">
<!-- Text -->
<input
v-if="field.type === 'text'"
type="text"
class="field__input"
:placeholder="field.placeholder || ''"
:readonly="!!field.readonly"
:value="val || ''"
@input="val = $event.target.value"
/>
<!-- Textarea (multi-line plain text — moderation/blocklist fields) -->
<textarea
v-else-if="field.type === 'textarea'"
class="field__input field__textarea"
:class="{ 'field__textarea--mono': field.mono }"
:placeholder="field.placeholder || ''"
:rows="field.rows || 6"
:value="val || ''"
@input="val = $event.target.value"
></textarea>
<!-- Select (plain dropdown, distinct from the multiselect checkbox list below) -->
<select
v-else-if="field.type === 'select'"
class="field__input"
:value="val ?? ''"
:disabled="disabled"
@change="val = $event.target.value"
>
<option v-for="opt in (field.options || options)" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<!-- Toggle -->
<Toggle
v-else-if="field.type === 'toggle'"
:model-value="!!val"
:disabled="disabled"
@update:model-value="val = $event"
/>
<!-- Color -->
<div v-else-if="field.type === 'color'" class="field__color">
<input
type="color"
:value="val || field.default || '#4772fa'"
@input="val = $event.target.value"
/>
<span class="field__color-hex">{{ val || field.default || '#4772fa' }}</span>
</div>
<!-- Image / favicon -->
<button
v-else-if="field.type === 'image'"
type="button"
class="field__image"
@click="pickImage"
>
<img v-if="val" :src="val" alt="" />
<span v-else class="dashicons dashicons-format-image field__image-ph" />
<span class="field__image-label">{{ val ? 'Change' : 'Choose from media library' }}</span>
</button>
<!-- Multiselect (checkbox list) -->
<div v-else-if="field.type === 'multiselect'" class="field__multi">
<label v-for="opt in options" :key="opt.value" class="field__multi-item">
<input
type="checkbox"
:checked="selectedValues.includes(opt.value)"
@change="toggleValue(opt.value)"
/>
<span>{{ opt.label }}</span>
</label>
<p v-if="!options.length" class="field__multi-empty">No options available.</p>
</div>
<!-- Segmented No/Yes -->
<div v-else-if="field.type === 'segmented'" class="field__seg">
<button
type="button"
:class="{ 'field__seg--active': !val }"
@click="val = false"
>
No
</button>
<button
type="button"
:class="{ 'field__seg--active': !!val }"
@click="val = true"
>
Yes
</button>
</div>
<!-- Theme picker (10 fixed color presets, whole-UI preview) -->
<div v-else-if="field.type === 'theme-picker'" class="field__theme">
<button
v-for="preset in themePresets"
:key="preset.id"
type="button"
class="field__theme-card"
:class="{ 'field__theme-card--active': (val || field.default) === preset.id }"
@click="val = preset.id"
>
<span
class="field__theme-swatch"
:style="{
background: swatchPalette(preset)['--aurora-app-bg'],
borderColor: swatchPalette(preset)['--aurora-border'],
}"
>
<span
class="field__theme-swatch-accent"
:style="{ background: swatchPalette(preset)['--aurora-accent'] }"
/>
</span>
<span class="field__theme-name">{{ preset.label }}</span>
</button>
</div>
<!-- Font picker (searchable Google Font dropdown) -->
<div v-else-if="field.type === 'font-picker'" class="field__font">
<input
type="text"
class="field__input"
placeholder="Search Google Fonts…"
:value="fontOpen ? fontQuery : (val || 'Default')"
@focus="openFontList"
@input="fontQuery = $event.target.value"
@blur="() => setTimeout(() => (fontOpen = false), 150)"
/>
<div v-if="fontOpen" class="field__font-list">
<button
v-for="name in filteredFonts"
:key="name"
type="button"
class="field__font-item"
:class="{ 'field__font-item--active': val === name }"
:style="{ fontFamily: `'${name}', sans-serif` }"
@mousedown.prevent="pickFont(name)"
>
{{ name }}
</button>
<p v-if="!filteredFonts.length" class="field__multi-empty">No fonts match.</p>
</div>
</div>
<!-- Repeater -->
<div v-else-if="field.type === 'repeater'" class="field__repeater">
<div v-for="(row, i) in rows" :key="i" class="field__repeater-row">
<input
type="text"
class="field__input"
:placeholder="field.columns ? field.columns[0] : 'Find'"
:value="row.find"
@input="updateRow(i, 'find', $event.target.value)"
/>
<input
type="text"
class="field__input"
:placeholder="field.columns ? field.columns[1] : 'Replace'"
:value="row.replace"
@input="updateRow(i, 'replace', $event.target.value)"
/>
<button type="button" class="field__repeater-remove" @click="removeRow(i)">×</button>
</div>
<button type="button" class="field__newitem" @click="addRow">New item</button>
</div>
</div>
</div>
</template>
<style scoped>
.field {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 32px;
padding: 28px 0;
border-bottom: 1px solid var(--aurora-border);
align-items: start;
}
.field__label { font-size: 0.95rem; font-weight: 600; color: var(--aurora-text); }
.field__desc { margin: 6px 0 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.5; }
.field__control { display: flex; justify-content: flex-start; }
.field__input {
width: 100%;
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
color: var(--aurora-text);
border-radius: var(--aurora-radius-sm);
padding: 9px 12px;
font-size: 0.875rem;
}
.field__input:focus { outline: none; border-color: var(--aurora-accent); }
.field__textarea { resize: vertical; font-family: inherit; line-height: 1.5; }
.field__textarea--mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
font-size: 0.8125rem;
tab-size: 2;
}
.field__color { display: flex; align-items: center; gap: 10px; }
.field__color input[type='color'] {
width: 40px; height: 32px; border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-sm); background: none; cursor: pointer; padding: 2px;
}
.field__color-hex { font-size: 0.8125rem; color: var(--aurora-text-muted); }
.field__image {
width: 160px; height: 120px;
border: 1px dashed var(--aurora-border);
border-radius: var(--aurora-radius-md);
background: var(--aurora-bg-subtle);
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 8px; cursor: pointer; color: var(--aurora-text-muted);
position: relative; overflow: hidden;
}
.field__image img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.field__image-ph { font-size: 28px; width: 28px; height: 28px; }
.field__image-label {
position: absolute; bottom: 0; left: 0; right: 0;
background: var(--aurora-card-bg); padding: 6px; font-size: 0.75rem;
border-top: 1px solid var(--aurora-border);
}
.field__repeater { width: 100%; }
.field__repeater-row { display: flex; gap: 8px; margin-bottom: 8px; }
.field__repeater-remove {
flex-shrink: 0; width: 34px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
border-radius: var(--aurora-radius-sm); cursor: pointer; font-size: 1.1rem;
}
.field__newitem {
border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
border-radius: var(--aurora-radius-sm); padding: 8px 16px; font-size: 0.8125rem; cursor: pointer;
}
.field__newitem:hover { border-color: var(--aurora-accent); color: var(--aurora-accent); }
/* Multiselect */
.field__multi { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.field__multi-item {
display: flex; align-items: center; gap: 8px;
font-size: 0.8125rem; color: var(--aurora-text); cursor: pointer;
}
.field__multi-item input { accent-color: var(--aurora-accent); }
.field__multi-empty { font-size: 0.8125rem; color: var(--aurora-text-muted); margin: 0; }
/* Segmented No/Yes */
.field__seg {
display: inline-flex;
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md);
padding: 3px;
gap: 2px;
}
.field__seg button {
border: none; background: none; cursor: pointer;
padding: 6px 26px; border-radius: var(--aurora-radius-sm);
font-size: 0.8125rem; color: var(--aurora-text-muted);
}
.field__seg--active { background: var(--aurora-accent); color: #fff !important; }
/* Theme picker */
.field__theme { display: flex; flex-wrap: wrap; gap: 10px; width: 100%; }
.field__theme-card {
display: flex; flex-direction: column; align-items: center; gap: 6px;
width: 76px; padding: 10px 6px;
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md);
background: var(--aurora-bg-subtle);
cursor: pointer;
}
.field__theme-card--active { border-color: var(--aurora-accent); box-shadow: 0 0 0 1px var(--aurora-accent); }
.field__theme-swatch {
width: 36px; height: 36px; border-radius: var(--aurora-radius-md);
border: 1px solid var(--aurora-card-border);
display: flex; align-items: flex-end; justify-content: flex-end;
padding: 4px;
box-sizing: border-box;
}
.field__theme-swatch-accent {
width: 10px; height: 10px; border-radius: 50%;
}
.field__theme-name { font-size: 0.6875rem; color: var(--aurora-text-muted); }
/* Font picker */
.field__font { position: relative; width: 100%; max-width: 320px; }
.field__font-list {
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
max-height: 260px; overflow-y: auto; z-index: 20;
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md);
padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.field__font-item {
display: block; width: 100%; text-align: left;
border: none; background: none; cursor: pointer;
padding: 8px 10px; border-radius: var(--aurora-radius-sm);
font-size: 0.875rem; color: var(--aurora-text);
}
.field__font-item:hover { background: var(--aurora-bg-subtle); }
.field__font-item--active { color: var(--aurora-accent); }
@media (max-width: 782px) {
.field { grid-template-columns: 1fr; gap: 12px; }
}
</style>
+79
View File
@@ -0,0 +1,79 @@
/**
* Curated list of popular Google Font family names for the font-picker
* field. Not the full ~1700-family catalog — a representative set across
* sans, serif, display, and monospace so the picker has something useful
* to search without shipping/maintaining a giant metadata blob. Each
* family is requested from Google's CSS2 API on demand (see
* SiteShell.vue::applyFontFamily), never bundled as font files.
*/
export const googleFonts = [
'Inter',
'Roboto',
'Open Sans',
'Lato',
'Montserrat',
'Poppins',
'Nunito',
'Nunito Sans',
'Source Sans 3',
'Work Sans',
'Rubik',
'Manrope',
'DM Sans',
'Karla',
'Mulish',
'Figtree',
'Plus Jakarta Sans',
'Outfit',
'Space Grotesk',
'Sora',
'Albert Sans',
'Urbanist',
'Public Sans',
'IBM Plex Sans',
'Barlow',
'Cabin',
'Hanken Grotesk',
'Lexend',
'Jost',
'Red Hat Display',
'Playfair Display',
'Merriweather',
'Lora',
'PT Serif',
'Crimson Text',
'Source Serif 4',
'Libre Baskerville',
'Cormorant Garamond',
'EB Garamond',
'Bitter',
'Domine',
'Fraunces',
'Newsreader',
'Spectral',
'Frank Ruhl Libre',
'Josefin Sans',
'Quicksand',
'Comfortaa',
'Raleway',
'Oswald',
'Bebas Neue',
'Archivo',
'Archivo Black',
'Anton',
'Righteous',
'Fjalla One',
'Abril Fatface',
'Cormorant',
'Zilla Slab',
'IBM Plex Mono',
'JetBrains Mono',
'Space Mono',
'Roboto Mono',
'Fira Code',
'Source Code Pro',
'Courier Prime',
'Instrument Sans',
'Instrument Serif',
'Bricolage Grotesque',
];
+560
View File
@@ -0,0 +1,560 @@
/**
* Declarative settings schema for the Aurora Admin settings page.
*
* Categories are organized into top-level groups. Each group renders as a
* collapsible section in the left nav; each category within it renders as a
* nav item, and its fields render in the right panel. Field `key`s map
* directly into the aurora_admin_settings option, saved via the
* wp/v2/settings REST route.
*/
export const settingsGroups = [
{
id: 'general',
title: 'General',
icon: 'dashicons-admin-settings',
categories: [
{
id: 'general',
title: 'General',
subtitle: 'Core plugin settings',
icon: 'dashicons-admin-settings',
fields: [
{
key: 'logo',
type: 'image',
label: 'Logo',
description: 'Optional logo to be displayed in the menu.',
},
{
key: 'dark_logo',
type: 'image',
label: 'Dark mode logo',
description:
"Set an alternative logo to use in dark mode. If one isn't set it will fall back to the standard logo.",
},
{
key: 'disable_theme',
type: 'multiselect',
label: 'Disable theme',
description: 'Choose roles to disable the theme for.',
optionsSource: 'roles',
},
{
key: 'search_post_types',
type: 'multiselect',
label: 'Search post types',
description: 'Choose which post types are available in the global search.',
optionsSource: 'postTypes',
},
{
key: 'disable_global_search',
type: 'segmented',
label: 'Disable global search',
description: 'Disable global search for all users.',
},
],
},
{
id: 'analytics',
title: 'Analytics',
subtitle: 'Built-in analytics and tracking settings',
icon: 'dashicons-chart-bar',
fields: [
{
key: 'enable_analytics',
type: 'toggle',
label: 'Enable analytics',
description: 'Track first-party visitor analytics for the dashboard.',
default: true,
},
{
key: 'track_admins',
type: 'toggle',
label: 'Track logged-in admins',
description: 'Include administrator visits in analytics (off by default).',
},
],
},
],
},
{
id: 'appearance',
title: 'Appearance',
icon: 'dashicons-admin-appearance',
categories: [
{
id: 'theme',
title: 'Theme',
subtitle: 'Colors, styling, and visual customization',
icon: 'dashicons-admin-appearance',
fields: [
{
key: 'theme_preset',
type: 'theme-picker',
label: 'Theme',
description: 'Pick a color for the whole admin interface.',
default: 'indigo',
},
{
key: 'font_family',
type: 'font-picker',
label: 'Font family',
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.',
},
],
},
{
id: 'whitelabel',
title: 'White Label',
subtitle: 'Rebrand and customize interface text and icons',
icon: 'dashicons-tag',
fields: [
{
key: 'admin_favicon',
type: 'image',
label: 'Admin favicon',
description: 'Optional favicon to replace the WordPress favicon in the admin.',
},
{
key: 'plugin_name',
type: 'text',
label: 'Rename Aurora Admin',
description:
'This will rename Aurora Admin in the plugins list as well as renaming its settings link.',
placeholder: 'Aurora Admin',
},
{
key: 'text_replacements',
type: 'repeater',
label: 'Text Replacement',
description:
'Customize the text displayed in the WordPress admin area. Enter original words or phrases and their replacements to white-label the interface.',
columns: ['Find', 'Replace with'],
},
],
},
{
id: 'login',
title: 'Login',
subtitle: 'Customize login page appearance and security',
icon: 'dashicons-lock',
fields: [
{
key: 'style_login',
type: 'toggle',
label: 'Style login page',
description: 'Apply the Aurora theme to the WordPress login screen.',
},
{
key: 'login_logo',
type: 'image',
label: 'Login logo',
description: 'Replace the WordPress logo on the login screen.',
dependsOn: 'style_login',
},
{
key: 'login_bg_image',
type: 'image',
label: 'Background image',
description: 'Full-screen background for the login page. Overrides the background color below.',
dependsOn: 'style_login',
},
{
key: 'login_bg_color',
type: 'color',
label: 'Background color',
description: 'Solid background color for the login page (used when no background image is set).',
default: '#0f1420',
dependsOn: 'style_login',
},
{
key: 'login_form_bg',
type: 'color',
label: 'Form background',
description: 'Background color of the login form panel.',
default: '#1c2333',
dependsOn: 'style_login',
},
{
key: 'login_button_color',
type: 'color',
label: 'Button color',
description: 'Color of the primary “Log In” button and the field focus accent.',
default: '#4f7cff',
dependsOn: 'style_login',
},
{
key: 'login_custom_css',
type: 'textarea',
mono: true,
rows: 8,
label: 'Custom login CSS',
description: 'Extra CSS applied to the login screen, after Aurora’s own styles.',
placeholder: 'body.login { … }',
dependsOn: 'style_login',
},
],
},
{
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',
subtitle: 'Admin menu behavior and interaction settings',
icon: 'dashicons-menu',
fields: [
{
key: 'menu_search',
type: 'toggle',
label: 'Menu search',
description: 'Show a search box at the top of the admin menu.',
},
],
},
],
},
{
id: 'screens',
title: 'Screens',
icon: 'dashicons-desktop',
categories: [
{
id: 'dashboard',
title: 'Dashboard',
subtitle: 'Custom dashboard and admin interface settings',
icon: 'dashicons-dashboard',
fields: [
{
key: 'use_custom_dashboard',
type: 'toggle',
label: 'Custom dashboard',
description: "Replace the WordPress dashboard with Aurora's dashboard.",
default: true,
},
],
},
{
id: 'content-tables',
title: 'Content tables',
subtitle: 'Choose which admin list screens use the Aurora interface',
icon: 'dashicons-editor-table',
fields: [
{
key: 'modern_posts',
type: 'toggle',
label: 'Posts',
description: 'Use the Aurora posts management interface.',
default: true,
},
{
key: 'modern_pages',
type: 'toggle',
label: 'Pages',
description: 'Use the Aurora pages management interface.',
default: true,
},
{
key: 'modern_media',
type: 'toggle',
label: 'Media library',
description: 'Use the Aurora media library interface.',
default: true,
},
{
key: 'modern_users',
type: 'toggle',
label: 'Users',
description: 'Use the Aurora users management interface.',
default: true,
},
{
key: 'modern_comments',
type: 'toggle',
label: 'Comments',
description: 'Use the Aurora comments management interface.',
default: true,
},
{
key: 'modern_plugins',
type: 'toggle',
label: 'Plugins',
description: 'Use the Aurora plugins management interface.',
default: true,
},
],
},
{
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',
subtitle: 'Replace the remaining native WordPress admin screens with Aurora',
icon: 'dashicons-shield',
fields: [
{
key: 'full_admin_takeover',
type: 'toggle',
label: 'Full admin takeover',
description:
'Replace Media Upload, Add User, Profile, Appearance, Tools, and native Settings screens with Aurora equivalents. Off by default — these cover more ground than Posts/Pages/Media/Users/Comments, so it’s opt-in rather than on by default.',
default: false,
},
],
},
],
},
{
id: 'productivity',
title: 'Productivity',
icon: 'dashicons-superhero-alt',
categories: [
{
id: 'image-formats',
title: 'Modern Image Formats',
subtitle: 'Automatically generate WebP/AVIF versions of uploaded images',
icon: 'dashicons-format-image',
fields: [
{
key: 'image_formats_enabled',
type: 'toggle',
label: 'Enable modern image formats',
description:
'Automatically generate a WebP or AVIF copy of every size of a newly uploaded JPEG or PNG image, and serve it to browsers that support it. Only affects new uploads — existing library images are unaffected until re-uploaded or regenerated with a tool like Regenerate Thumbnails.',
default: true,
},
{
key: 'image_formats_format',
type: 'select',
label: 'Output format',
description:
'Automatic prefers AVIF (smaller files, wider quality range) when the server supports it, falling back to WebP. If your server supports neither, generation is silently skipped — ask your host about enabling GD or Imagick WebP/AVIF support.',
dependsOn: 'image_formats_enabled',
default: 'auto',
options: [
{ value: 'auto', label: 'Automatic (AVIF, falling back to WebP)' },
{ value: 'webp', label: 'WebP only' },
{ value: 'avif', label: 'AVIF only' },
],
},
{
key: 'image_formats_picture_element',
type: 'toggle',
label: 'Serve via <picture> element',
description:
'Rewrite <img> tags (post content and post thumbnails) into a <picture> element offering the modern format with the original JPEG/PNG as a fallback. Turn this off if it conflicts with your theme’s image styling, while still generating the files for other tools to use.',
dependsOn: 'image_formats_enabled',
default: true,
},
],
},
{
id: 'media',
title: 'Media',
subtitle: 'Media library and upload options',
icon: 'dashicons-admin-media',
fields: [
{
key: 'enable_svg',
type: 'toggle',
label: 'Allow SVG uploads',
description: 'Permit SVG files to be uploaded to the media library.',
},
],
},
],
},
{
id: 'security',
title: 'Security & performance',
icon: 'dashicons-shield',
categories: [
{
id: 'security-hardening',
title: 'Hardening',
subtitle: 'Reduce your site’s attack surface and trim unnecessary output',
icon: 'dashicons-shield-alt',
fields: [
{
key: 'disable_xmlrpc',
type: 'toggle',
label: 'Disable XML-RPC',
description:
'Turn off the XML-RPC endpoint, a common brute-force and pingback-DDoS vector. Leave on only if you rely on the WordPress mobile app or remote publishing.',
},
{
key: 'disable_comments',
type: 'toggle',
label: 'Disable comments',
description:
'Turn comments off site-wide — closes them on every post type, hides existing ones, and removes the Comments admin screen and dashboard widget.',
},
{
key: 'remove_head_junk',
type: 'toggle',
label: 'Clean up <head>',
description:
'Remove the WordPress generator/version tag, RSD link, WLW manifest, and shortlink meta from your site’s <head>.',
},
],
},
{
id: 'performance-tweaks',
title: 'Performance',
subtitle: 'Trim requests and scripts WordPress loads by default',
icon: 'dashicons-performance',
fields: [
{
key: 'disable_emojis',
type: 'toggle',
label: 'Disable emoji scripts',
description:
'Stop WordPress loading its emoji-detection script and styles on every front-end and admin page.',
},
{
key: 'disable_oembed',
type: 'toggle',
label: 'Disable oEmbeds',
description:
'Remove oEmbed discovery links and the wp-embed script (stops auto-embedding of pasted URLs and the embed REST route).',
},
{
key: 'heartbeat_mode',
type: 'select',
label: 'Heartbeat API',
description:
'The Heartbeat API polls the server on a timer (autosave, post-lock notices). Slow it down or disable it to cut admin-ajax load.',
default: 'default',
options: [
{ value: 'default', label: 'Default (15–60s)' },
{ value: 'slow', label: 'Slow (60s)' },
{ value: 'disabled', label: 'Disabled' },
],
},
],
},
],
},
{
id: 'code',
title: 'Code',
icon: 'dashicons-editor-code',
categories: [
{
id: 'custom-code',
title: 'Custom CSS & JS',
subtitle: 'Inject your own CSS and JavaScript into the WordPress admin',
icon: 'dashicons-admin-customizer',
fields: [
{
key: 'custom_admin_css',
type: 'textarea',
mono: true,
rows: 10,
label: 'Admin CSS',
description:
'CSS added to every wp-admin page, inside a <style> tag in the admin <head>. Enter CSS only — no <style> tags needed.',
placeholder: '.wrap { max-width: 1200px; }',
},
{
key: 'custom_admin_js',
type: 'textarea',
mono: true,
rows: 10,
label: 'Admin JavaScript',
description:
'JavaScript added to the admin footer, inside a <script> tag. Enter JS only — no <script> tags needed.',
placeholder: "console.log('Hello from Aurora');",
},
],
},
{
id: 'header-footer',
title: 'Header & footer scripts',
subtitle: 'Inject raw code into your site’s front-end <head> and footer',
icon: 'dashicons-editor-code',
fields: [
{
key: 'header_scripts',
type: 'textarea',
mono: true,
rows: 8,
label: 'Header scripts',
description:
'Raw HTML/JS printed in the front-end <head> — analytics snippets, verification meta tags, etc. Include the full <script>/<meta> tags.',
placeholder: '<!-- e.g. analytics -->\n<script>…</script>',
},
{
key: 'footer_scripts',
type: 'textarea',
mono: true,
rows: 8,
label: 'Footer scripts',
description:
'Raw HTML/JS printed just before </body> on the front end. Include the full <script> tags.',
placeholder: '<script>…</script>',
},
],
},
],
},
];
/**
* Flattened category list — every category across all groups, in order.
* Used for default-seeding, active-category lookup, and URL deep-links.
*/
export const settingsCategories = settingsGroups.flatMap((g) => g.categories);
+103
View File
@@ -0,0 +1,103 @@
/**
* Fixed set of theme presets for the theme-picker field. No custom color
* picker — exactly these 10 predetermined options. Each preset is a hue;
* the main background gradient is mostly two vivid, saturated stops of
* that hue (like FleekDash's "Fresh"/"Sunrise" palette bars), only
* dipping to a near-black anchor in the last stretch — not a 50/50
* dark-to-vivid split, which reads washed out rather than "poppy".
* Cards/borders/text derive as solid, more muted shades of the primary
* hue so the gradient stays a deliberate hero background rather than
* making every surface busy.
*/
export const themePresets = [
{ id: 'indigo', label: 'Indigo', hue: 235 },
{ id: 'sky', label: 'Sky', hue: 205 },
{ id: 'teal', label: 'Teal', hue: 175 },
{ id: 'forest', label: 'Forest', hue: 140 },
{ id: 'lime', label: 'Lime', hue: 95 },
{ id: 'amber', label: 'Amber', hue: 45 },
{ id: 'orange', label: 'Orange', hue: 22 },
{ id: 'rose', label: 'Rose', hue: 345 },
{ id: 'berry', label: 'Berry', hue: 320 },
{ id: 'violet', label: 'Violet', hue: 265 },
// Two flat neutrals, no gradient — for anyone who finds the color
// themes above too much. Same values the shell originally shipped with
// before theme presets existed (zinc dark / zinc light).
{
id: 'mono',
label: 'Black & Grey',
palette: {
'--aurora-app-bg': '#09090b',
'--aurora-app-bg-solid': '#09090b',
'--aurora-bg': '#18181b',
'--aurora-card-bg': '#1f1f23',
'--aurora-card-bg-deep': '#131316',
'--aurora-bg-subtle': '#27272a',
'--aurora-text': '#f4f4f5',
'--aurora-text-muted': '#a1a1aa',
'--aurora-border': '#27272a',
'--aurora-card-border': '#2e2e33',
'--aurora-frame-bg': '#1c1c20',
'--aurora-frame-border': '#34343b',
'--aurora-accent': 'rgb(125 158 255)',
'--aurora-accent-soft': 'rgb(125 158 255 / 0.14)',
},
},
{
id: 'white',
label: 'White',
palette: {
'--aurora-app-bg': '#fafafa',
'--aurora-app-bg-solid': '#fafafa',
'--aurora-bg': '#ffffff',
'--aurora-card-bg': '#fafafa',
'--aurora-card-bg-deep': '#f0f0f2',
'--aurora-bg-subtle': '#f4f4f5',
'--aurora-text': '#18181b',
'--aurora-text-muted': '#71717a',
'--aurora-border': '#e4e4e7',
'--aurora-card-border': '#ececef',
'--aurora-frame-bg': '#ffffff',
'--aurora-frame-border': '#e0e0e3',
'--aurora-accent': 'rgb(71 114 250)',
'--aurora-accent-soft': 'rgb(71 114 250 / 0.1)',
},
},
];
const hsl = (h, s, l, a) =>
a === undefined ? `hsl(${h} ${s}% ${l}%)` : `hsl(${h} ${s}% ${l}% / ${a})`;
/** The full set of --aurora-* color tokens for a preset. */
export function buildPalette(preset) {
if (preset.palette) return preset.palette;
const { hue } = preset;
// Two vivid, bright stops of the same hue family carry most of the bar
// (0% → 55%); the near-black anchor only takes over in the final
// stretch (100%). That's the "mostly color, dark tail" look the
// FleekDash palette bars have — full saturation start to middle keeps
// it feeling poppy instead of muddy.
const hue2 = (hue + 35) % 360;
const solidAppBg = hsl(hue, 45, 16);
return {
'--aurora-app-bg': `linear-gradient(135deg, ${hsl(hue, 78, 48)} 0%, ${hsl(hue2, 78, 45)} 55%, ${hsl(hue, 32, 9)} 100%)`,
'--aurora-app-bg-solid': solidAppBg,
'--aurora-bg': hsl(hue, 40, 20),
'--aurora-card-bg': hsl(hue, 36, 24),
'--aurora-card-bg-deep': hsl(hue, 38, 14),
'--aurora-bg-subtle': hsl(hue, 32, 28),
'--aurora-text': hsl(hue, 20, 97),
'--aurora-text-muted': hsl(hue, 14, 74),
'--aurora-border': hsl(hue, 28, 30),
'--aurora-card-border': hsl(hue, 26, 33),
'--aurora-frame-bg': hsl(hue, 34, 22),
'--aurora-frame-border': hsl(hue, 26, 33),
'--aurora-accent': hsl(hue, 85, 62),
'--aurora-accent-soft': hsl(hue, 85, 62, 0.16),
};
}
/** Resolve a theme_preset id to the preset object (falls back to the first preset). */
export function resolvePreset(themePresetId) {
return themePresets.find((p) => p.id === themePresetId) || themePresets[0];
}
+311
View File
@@ -0,0 +1,311 @@
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue';
import SettingsField from '@/pages/settings/components/SettingsField.vue';
import { applyThemePalette, applyFontFamily } from '@/pages/settings/applyLiveTheme.js';
const props = defineProps({
serverData: { type: Object, required: true },
});
// Step definitions. Welcome and Done are custom content; the middle three
// render their `fields` through the shared SettingsField component (same
// controls as the settings page — theme picker, font picker, media-library
// image fields, toggles).
const steps = [
{ id: 'welcome', label: 'Welcome' },
{
id: 'appearance',
label: 'Appearance',
title: 'Choose your look',
subtitle:
'Pick a color theme and font for the whole admin. You can change these any time in Settings.',
fields: [
{
key: 'theme_preset',
type: 'theme-picker',
label: 'Theme',
description: 'The accent and surface colors used across the admin.',
default: 'indigo',
},
{
key: 'font_family',
type: 'font-picker',
label: 'Font family',
description:
'Optional. Leave empty for your system font (no external requests). ' +
'Choosing a font loads it from Google Fonts.',
},
],
},
{
id: 'branding',
label: 'Branding',
title: 'Add your branding',
subtitle:
'All optional — drop in a logo and favicon, or rename Aurora for a fully white-labeled admin.',
fields: [
{ key: 'logo', type: 'image', label: 'Logo', description: 'Shown at the top of the sidebar menu.' },
{
key: 'dark_logo',
type: 'image',
label: 'Dark mode logo',
description: 'Optional alternative logo; falls back to the standard one.',
},
{
key: 'admin_favicon',
type: 'image',
label: 'Admin favicon',
description: 'Replaces the WordPress favicon in the admin.',
},
{
key: 'plugin_name',
type: 'text',
label: 'Rename Aurora Admin',
description: 'White-labels the plugin name in menus and the plugins list.',
placeholder: 'Aurora Admin',
},
],
},
{
id: 'features',
label: 'Features',
title: 'Choose your features',
subtitle:
'Aurora replaces these WordPress admin screens with faster versions of its own. Turn off any you’d rather keep native.',
fields: [
{ key: 'use_custom_dashboard', type: 'toggle', label: 'Dashboard', description: 'Replace the WordPress dashboard with Aurora’s.', default: true },
{ key: 'modern_posts', type: 'toggle', label: 'Posts', description: 'Aurora posts management screen.', default: true },
{ key: 'modern_pages', type: 'toggle', label: 'Pages', description: 'Aurora pages management screen.', default: true },
{ key: 'modern_media', type: 'toggle', label: 'Media library', description: 'Aurora media library screen.', default: true },
{ key: 'modern_users', type: 'toggle', label: 'Users', description: 'Aurora users management screen.', default: true },
{ key: 'modern_comments', type: 'toggle', label: 'Comments', description: 'Aurora comments management screen.', default: true },
{ key: 'modern_plugins', type: 'toggle', label: 'Plugins', description: 'Aurora plugins management screen.', default: true },
{
key: 'full_admin_takeover',
type: 'toggle',
label: 'Full admin takeover',
description: 'Also replace Appearance, Tools, and the native Settings screens. Off by default.',
default: false,
},
],
},
{ id: 'done', label: 'Done', title: 'You’re all set' },
];
// Working settings, seeded from the server + each field's default.
const settings = reactive({ ...(props.serverData.settings || {}) });
steps.forEach((s) =>
(s.fields || []).forEach((f) => {
if (settings[f.key] === undefined && f.default !== undefined) settings[f.key] = f.default;
})
);
const stepIndex = ref(0);
const current = computed(() => steps[stepIndex.value]);
const isFirst = computed(() => stepIndex.value === 0);
const isDone = computed(() => current.value.id === 'done');
const goNext = () => {
if (stepIndex.value < steps.length - 1) stepIndex.value++;
};
const goBack = () => {
if (stepIndex.value > 0) stepIndex.value--;
};
// Live preview of theme + font as they're picked (nothing is persisted until
// the wizard is finished, but the whole admin previews immediately).
watch(() => settings.theme_preset, (p) => applyThemePalette(p || 'indigo'), { immediate: true });
watch(() => settings.font_family, (f) => applyFontFamily(f), { immediate: true });
onMounted(() => {
// The shell (which normally does this) is disabled on this page.
document.documentElement.classList.add('aurora-dark');
applyThemePalette(settings.theme_preset || 'indigo');
applyFontFamily(settings.font_family);
});
const saving = ref(false);
const finish = async (target) => {
if (saving.value) return;
saving.value = true;
try {
await fetch(`${props.serverData.restUrl}wp/v2/settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce },
body: JSON.stringify({ aurora_admin_settings: { ...settings } }),
});
await fetch(`${props.serverData.restUrl}aurora-admin/v1/setup/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce },
});
} catch {
/* Even if persistence fails, don't trap the user in the wizard. */
}
window.location.href = target || props.serverData.dashboardUrl || '/wp-admin/';
};
</script>
<template>
<div class="wiz">
<div class="wiz__inner">
<div class="wiz__brand">
<span class="wiz__mark">A</span>
<span class="wiz__brandname">Aurora Admin</span>
</div>
<!-- Progress -->
<ol class="wiz__steps">
<template v-for="(s, i) in steps" :key="s.id">
<li
class="wiz__step"
:class="{ 'is-active': i === stepIndex, 'is-done': i < stepIndex }"
>
<span class="wiz__dot">
<span v-if="i < stepIndex" class="dashicons dashicons-yes" />
<span v-else>{{ i + 1 }}</span>
</span>
<span class="wiz__step-label">{{ s.label }}</span>
</li>
<li v-if="i < steps.length - 1" class="wiz__connector" :class="{ 'is-on': i < stepIndex }" />
</template>
</ol>
<!-- Card -->
<section class="wiz__card">
<!-- Welcome -->
<div v-if="current.id === 'welcome'" class="wiz__welcome">
<h1 class="wiz__h1">Welcome to Aurora Admin<span v-if="serverData.userName">, {{ serverData.userName }}</span></h1>
<p class="wiz__lead">
A modern, fast redesign of the WordPress admin. This quick setup takes about a
minute — choose a look, add your branding, and pick which screens Aurora takes over.
You can skip it and change everything later in Settings.
</p>
</div>
<!-- Done -->
<div v-else-if="current.id === 'done'" class="wiz__welcome">
<span class="wiz__done-icon dashicons dashicons-yes-alt" />
<h1 class="wiz__h1">You’re all set</h1>
<p class="wiz__lead">
Aurora is configured and ready. Head to your dashboard, or fine-tune anything in
Settings.
</p>
<div class="wiz__done-actions">
<button type="button" class="wiz__btn wiz__btn--primary" :disabled="saving" @click="finish(serverData.dashboardUrl)">
{{ saving ? 'Finishing…' : 'Go to dashboard' }}
</button>
<button type="button" class="wiz__btn" :disabled="saving" @click="finish(serverData.settingsUrl)">
Open settings
</button>
</div>
</div>
<!-- Field steps -->
<div v-else>
<h1 class="wiz__h1">{{ current.title }}</h1>
<p v-if="current.subtitle" class="wiz__lead">{{ current.subtitle }}</p>
<div class="wiz__fields">
<SettingsField
v-for="f in current.fields"
:key="f.key"
:field="f"
v-model="settings[f.key]"
/>
</div>
</div>
</section>
<!-- Footer nav (hidden on the Done step, which has its own actions) -->
<div v-if="!isDone" class="wiz__nav">
<button type="button" class="wiz__btn wiz__btn--ghost" :disabled="saving" @click="finish(serverData.dashboardUrl)">
Skip setup
</button>
<div class="wiz__nav-right">
<button v-if="!isFirst" type="button" class="wiz__btn" @click="goBack">Back</button>
<button type="button" class="wiz__btn wiz__btn--primary" @click="goNext">
{{ isFirst ? 'Get started' : 'Continue' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.wiz {
position: fixed;
inset: 0;
z-index: 100000;
overflow-y: auto;
background:
radial-gradient(120% 90% at 50% 0%, color-mix(in srgb, var(--aurora-accent) 12%, var(--aurora-app-bg-solid)) 0%, var(--aurora-app-bg-solid) 65%);
color: var(--aurora-text);
font-family: var(--aurora-font-family);
}
.wiz__inner {
max-width: 760px;
margin: 0 auto;
padding: 48px 24px 64px;
}
.wiz__brand { display: flex; align-items: center; justify-content: center; gap: 10px; margin-bottom: 30px; }
.wiz__mark {
width: 30px; height: 30px; border-radius: var(--aurora-radius-md);
background: var(--aurora-accent); color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700;
}
.wiz__brandname { font-size: 1rem; font-weight: 500; }
/* Progress */
.wiz__steps { display: flex; align-items: center; justify-content: center; list-style: none; margin: 0 0 30px; padding: 0; }
.wiz__step { display: flex; flex-direction: column; align-items: center; gap: 7px; flex: 0 0 auto; }
.wiz__dot {
width: 28px; height: 28px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; font-weight: 500;
border: 1.5px solid var(--aurora-border); background: var(--aurora-card-bg); color: var(--aurora-text-muted);
}
.wiz__dot .dashicons { font-size: 16px; width: 16px; height: 16px; }
.wiz__step.is-done .wiz__dot { background: var(--aurora-accent); border-color: var(--aurora-accent); color: #fff; }
.wiz__step.is-active .wiz__dot { border-color: var(--aurora-accent); color: var(--aurora-accent); }
.wiz__step-label { font-size: 0.6875rem; color: var(--aurora-text-muted); }
.wiz__step.is-active .wiz__step-label, .wiz__step.is-done .wiz__step-label { color: var(--aurora-text); }
.wiz__connector { flex: 1 1 auto; height: 1.5px; min-width: 22px; background: var(--aurora-border); margin: 0 6px; position: relative; top: -10px; }
.wiz__connector.is-on { background: var(--aurora-accent); }
/* Card */
.wiz__card {
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-xl);
padding: 30px 32px;
}
.wiz__h1 { font-size: 1.5rem; font-weight: 600; margin: 0; }
.wiz__lead { margin: 10px 0 0; font-size: 0.9rem; color: var(--aurora-text-muted); line-height: 1.6; }
.wiz__welcome { text-align: center; padding: 20px 8px; }
.wiz__welcome .wiz__lead { max-width: 520px; margin: 12px auto 0; }
.wiz__done-icon { font-size: 52px; width: 52px; height: 52px; color: var(--aurora-accent); }
.wiz__done-actions { display: flex; gap: 12px; justify-content: center; margin-top: 26px; }
/* Fields: SettingsField renders its own rows; drop the first row's top pad
and the last row's divider so they sit cleanly inside the card. */
.wiz__fields { margin-top: 8px; }
.wiz__fields > :deep(.field):first-child { padding-top: 12px; }
.wiz__fields > :deep(.field):last-child { border-bottom: none; padding-bottom: 4px; }
/* Nav */
.wiz__nav { display: flex; align-items: center; justify-content: space-between; margin-top: 22px; }
.wiz__nav-right { display: flex; gap: 10px; }
.wiz__btn {
font-size: 0.875rem; font-weight: 500;
padding: 10px 20px; border-radius: var(--aurora-radius-md); cursor: pointer;
border: 1px solid var(--aurora-border); background: var(--aurora-card-bg); color: var(--aurora-text);
}
.wiz__btn:hover { border-color: var(--aurora-accent); }
.wiz__btn:disabled { opacity: 0.6; cursor: default; }
.wiz__btn--ghost { border-color: transparent; color: var(--aurora-text-muted); }
.wiz__btn--ghost:hover { color: var(--aurora-text); border-color: transparent; }
.wiz__btn--primary { background: var(--aurora-accent); color: #fff; border-color: var(--aurora-accent); }
.wiz__btn--primary:hover { filter: brightness(1.08); }
</style>
+162
View File
@@ -0,0 +1,162 @@
<script setup>
import { ref, onMounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path) => `${props.serverData.restUrl}aurora-admin/v1${path}`;
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
const themes = ref([]);
const loading = ref(true);
const error = ref('');
const busy = ref('');
const load = async () => {
loading.value = true;
try {
const res = await fetch(api('/themes'), { headers: headers() });
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not load themes.');
themes.value = data;
} catch (e) {
error.value = e.message || 'Could not load themes.';
} finally {
loading.value = false;
}
};
onMounted(load);
const activate = async (theme) => {
busy.value = theme.stylesheet;
try {
const res = await fetch(api('/themes/activate'), {
method: 'POST',
headers: headers(),
body: JSON.stringify({ stylesheet: theme.stylesheet }),
});
if (!res.ok) throw new Error('Could not activate theme.');
await load();
} catch (e) {
error.value = e.message;
} finally {
busy.value = '';
}
};
const remove = async (theme) => {
if (!confirm(`Delete "${theme.name}"? This can't be undone.`)) return;
busy.value = theme.stylesheet;
try {
const res = await fetch(api(`/themes/${encodeURIComponent(theme.stylesheet)}`), {
method: 'DELETE',
headers: headers(),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not delete theme.');
await load();
} catch (e) {
error.value = e.message;
setTimeout(() => (error.value = ''), 3000);
} finally {
busy.value = '';
}
};
</script>
<template>
<main class="themes-page">
<header class="themes-page__header">
<div>
<p class="themes-page__eyebrow">Appearance</p>
<h1>Themes</h1>
</div>
<div class="themes-page__actions">
<a v-if="serverData.addThemeUrl" class="button-primary" :href="serverData.addThemeUrl">
Add New Theme
</a>
<a class="button" :href="serverData.customizeUrl">Customize</a>
</div>
</header>
<div v-if="error" class="themes-notice themes-notice--error">{{ error }}</div>
<p v-if="loading" class="themes-empty">Loading…</p>
<div v-else class="themes-grid">
<article v-for="theme in themes" :key="theme.stylesheet" class="theme-card" :class="{ 'theme-card--active': theme.active }">
<div class="theme-card__shot">
<img v-if="theme.screenshot" :src="theme.screenshot" :alt="theme.name" />
<span v-else class="dashicons dashicons-admin-appearance" />
</div>
<div class="theme-card__body">
<h3>{{ theme.name }} <span>{{ theme.version }}</span></h3>
<p>{{ theme.description }}</p>
</div>
<div class="theme-card__actions">
<span v-if="theme.active" class="theme-card__badge">Active</span>
<button
v-else
type="button"
class="button-primary"
:disabled="busy === theme.stylesheet"
@click="activate(theme)"
>
{{ busy === theme.stylesheet ? 'Activating…' : 'Activate' }}
</button>
<button
v-if="!theme.active"
type="button"
class="icon-button"
title="Delete theme"
:disabled="busy === theme.stylesheet"
@click="remove(theme)"
>
<span class="dashicons dashicons-trash" />
</button>
</div>
</article>
</div>
</main>
</template>
<style scoped>
.themes-page { max-width: 1100px; margin: 0 auto; }
.themes-page__header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; }
.themes-page__actions { display: flex; align-items: center; gap: 8px; }
.themes-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.themes-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.themes-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
.themes-notice { padding: 10px 14px; border-radius: var(--aurora-radius-sm); margin-bottom: 16px; font-size: 0.875rem; }
.themes-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.themes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; }
.theme-card {
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md); overflow: hidden; display: flex; flex-direction: column;
}
.theme-card--active { border-color: var(--aurora-accent); }
.theme-card__shot {
aspect-ratio: 4 / 3; background: var(--aurora-bg);
display: flex; align-items: center; justify-content: center;
color: var(--aurora-text-muted);
}
.theme-card__shot img { width: 100%; height: 100%; object-fit: cover; }
.theme-card__shot .dashicons { font-size: 40px; width: 40px; height: 40px; }
.theme-card__body { padding: 14px 16px 8px; flex: 1; }
.theme-card__body h3 { margin: 0 0 6px; font-size: 0.9375rem; color: var(--aurora-text); }
.theme-card__body h3 span { font-weight: 400; font-size: 0.75rem; color: var(--aurora-text-muted); margin-left: 6px; }
.theme-card__body p {
margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.theme-card__actions { display: flex; align-items: center; gap: 8px; padding: 12px 16px 16px; }
.theme-card__badge {
font-size: 0.75rem; font-weight: 600; color: var(--aurora-accent);
background: var(--aurora-accent-soft); padding: 4px 10px; border-radius: 999px;
}
</style>
+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>
+152
View File
@@ -0,0 +1,152 @@
<script setup>
import { reactive, ref } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const form = reactive({
username: '',
email: '',
first_name: '',
last_name: '',
password: '',
roles: [props.serverData.defaultRole || 'subscriber'],
});
const saving = ref(false);
const error = ref('');
const success = ref('');
const generatePassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
form.password = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
};
const submit = async () => {
error.value = '';
success.value = '';
if (!form.username || !form.email) {
error.value = 'Username and email are required.';
return;
}
saving.value = true;
try {
const res = await fetch(`${props.serverData.restUrl}wp/v2/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce },
body: JSON.stringify({ ...form, password: form.password || undefined }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not create the user.');
success.value = `User "${data.username || form.username}" created.`;
form.username = '';
form.email = '';
form.first_name = '';
form.last_name = '';
form.password = '';
} catch (e) {
error.value = e.message || 'Could not create the user.';
} finally {
saving.value = false;
}
};
</script>
<template>
<main class="add-user-page">
<header class="add-user-page__header">
<div>
<p class="add-user-page__eyebrow">Accounts</p>
<h1>Add New User</h1>
</div>
<a class="button" :href="serverData.usersUrl">Back to Users</a>
</header>
<div v-if="success" class="add-user-notice add-user-notice--success">{{ success }}</div>
<div v-if="error" class="add-user-notice add-user-notice--error">{{ error }}</div>
<form class="add-user-form" @submit.prevent="submit">
<label class="add-user-field">
<span>Username <em>(required)</em></span>
<input v-model="form.username" type="text" autocomplete="off" />
</label>
<label class="add-user-field">
<span>Email <em>(required)</em></span>
<input v-model="form.email" type="email" autocomplete="off" />
</label>
<label class="add-user-field">
<span>First Name</span>
<input v-model="form.first_name" type="text" />
</label>
<label class="add-user-field">
<span>Last Name</span>
<input v-model="form.last_name" type="text" />
</label>
<label class="add-user-field">
<span>Password</span>
<div class="add-user-field__row">
<input v-model="form.password" type="text" placeholder="Leave blank to auto-generate" />
<button type="button" class="button" @click="generatePassword">Generate</button>
</div>
</label>
<label class="add-user-field">
<span>Role</span>
<select v-model="form.roles[0]">
<option v-for="role in serverData.roles" :key="role.value" :value="role.value">{{ role.label }}</option>
</select>
</label>
<div class="add-user-form__actions">
<button type="submit" class="button-primary" :disabled="saving">
{{ saving ? 'Adding User…' : 'Add New User' }}
</button>
</div>
</form>
</main>
</template>
<style scoped>
.add-user-page { max-width: 640px; margin: 0 auto; }
.add-user-page__header {
display: flex; align-items: flex-start; justify-content: space-between;
margin-bottom: 20px;
}
.add-user-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.add-user-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.add-user-notice {
padding: 10px 14px; border-radius: var(--aurora-radius-sm); margin-bottom: 16px; font-size: 0.875rem;
}
.add-user-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.add-user-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.add-user-form {
display: flex; flex-direction: column; gap: 18px;
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md);
padding: 24px;
}
.add-user-field { display: flex; flex-direction: column; gap: 6px; }
.add-user-field span { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-text); }
.add-user-field em { font-weight: 400; font-style: normal; color: var(--aurora-text-muted); }
.add-user-field input,
.add-user-field select {
padding: 9px 12px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.875rem;
}
.add-user-field__row { display: flex; gap: 8px; }
.add-user-field__row input { flex: 1; }
.add-user-form__actions { margin-top: 4px; }
</style>
+569
View File
@@ -0,0 +1,569 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const users = ref([]);
const stats = ref({ all: 0, byRole: {} });
const pagination = ref({ page: 1, perPage: 20, total: 0, totalPages: 1 });
const loading = ref(true);
const busyId = ref(0);
const error = ref('');
const notice = ref('');
const search = ref('');
const role = ref('all');
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
const request = async (path, options = {}) => {
const response = await fetch(`${apiBase.value}${path}`, {
credentials: 'same-origin',
...options,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(options.headers || {}),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.message || data.error || 'Request failed');
}
return data;
};
const load = async () => {
loading.value = true;
error.value = '';
try {
const params = new URLSearchParams({
role: role.value,
search: search.value.trim(),
page: String(pagination.value.page),
perPage: String(pagination.value.perPage),
});
const data = await request(`/users?${params.toString()}`);
users.value = data.items || [];
stats.value = data.stats || stats.value;
pagination.value = data.pagination || pagination.value;
} catch (err) {
error.value = err.message || 'Could not load users';
} finally {
loading.value = false;
}
};
onMounted(load);
let searchTimer = 0;
watch(search, () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
pagination.value.page = 1;
load();
}, 250);
});
const setRole = (next) => {
role.value = next;
pagination.value.page = 1;
load();
};
const filters = computed(() => {
const roleFilters = Object.entries(stats.value.byRole || {}).map(([key, count]) => ({
key,
label: key.charAt(0).toUpperCase() + key.slice(1),
count,
}));
return [{ key: 'all', label: 'All', count: stats.value.all }, ...roleFilters];
});
const deleteUser = async (user) => {
if (!user || busyId.value) return;
if (!window.confirm(`Delete "${user.displayName}"? Their content will be reassigned to you. This cannot be undone.`)) return;
busyId.value = user.id;
error.value = '';
notice.value = '';
try {
const data = await request(`/users/${user.id}`, { method: 'DELETE' });
notice.value = data.message || 'User deleted.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const goPage = (page) => {
const next = Math.min(Math.max(1, page), pagination.value.totalPages || 1);
if (next === pagination.value.page) return;
pagination.value.page = next;
load();
};
const rowRange = computed(() => {
if (!pagination.value.total) return '0 to 0';
const start = (pagination.value.page - 1) * pagination.value.perPage + 1;
const end = Math.min(pagination.value.total, pagination.value.page * pagination.value.perPage);
return `${start} to ${end}`;
});
const initials = (name) =>
(name || '?')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('');
</script>
<template>
<main class="users-page">
<header class="users-page__header">
<div>
<p class="users-page__eyebrow">Accounts</p>
<h1>Users</h1>
</div>
<div class="users-page__actions">
<a class="button-primary" :href="serverData.newUserUrl">Add User</a>
<button type="button" class="icon-button" title="Refresh users" :disabled="loading" @click="load">
<span class="dashicons dashicons-update" />
</button>
</div>
</header>
<section class="users-page__toolbar" aria-label="User filters and search">
<div class="users-page__filters">
<button
v-for="filter in filters"
:key="filter.key"
type="button"
class="filter-pill"
:class="{ 'is-active': role === filter.key }"
@click="setRole(filter.key)"
>
<span>{{ filter.label }}</span>
<strong>{{ filter.count }}</strong>
</button>
</div>
<label class="users-page__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Search users..." />
</label>
</section>
<div v-if="notice" class="users-notice users-notice--success">{{ notice }}</div>
<div v-if="error" class="users-notice users-notice--error">{{ error }}</div>
<section class="users-table" aria-label="Users">
<div class="users-table__head">
<div>User</div>
<div>Email</div>
<div>Role</div>
<div>Posts</div>
<div>Registered</div>
<div>Actions</div>
</div>
<div v-if="loading" class="users-table__empty">Loading users...</div>
<div v-else-if="!users.length" class="users-table__empty">No users match your filters.</div>
<article v-for="user in users" v-else :key="user.id" class="user-row">
<div class="user-row__user">
<img v-if="user.avatarUrl" :src="user.avatarUrl" alt="" class="user-row__avatar" />
<div v-else class="user-row__initials">{{ initials(user.displayName) }}</div>
<div class="user-row__person">
<strong>{{ user.displayName }}</strong>
<span>{{ user.username }}</span>
</div>
</div>
<div class="user-row__cell" data-label="Email">{{ user.email }}</div>
<div class="user-row__cell" data-label="Role">{{ user.roles.join(', ') || '—' }}</div>
<div class="user-row__cell" data-label="Posts">{{ user.postCount }}</div>
<div class="user-row__cell" data-label="Registered">{{ user.registeredDate }}</div>
<div class="user-row__actions">
<a class="icon-button icon-button--inline" title="Edit user" :href="user.editUrl">
<span class="dashicons dashicons-edit" />
</a>
<button
v-if="user.canDelete"
type="button"
class="icon-button icon-button--inline"
title="Delete user"
:disabled="busyId === user.id"
@click="deleteUser(user)"
>
<span class="dashicons dashicons-trash" />
</button>
</div>
</article>
<footer v-if="!loading && users.length" class="users-table__footer">
<div>Page Size: {{ pagination.perPage }}</div>
<div>{{ rowRange }} of {{ pagination.total }}</div>
<div class="users-table__pager">
<button type="button" :disabled="pagination.page <= 1" @click="goPage(1)">
<span class="dashicons dashicons-controls-skipback" />
</button>
<button type="button" :disabled="pagination.page <= 1" @click="goPage(pagination.page - 1)">
<span class="dashicons dashicons-arrow-left-alt2" />
</button>
<span>Page {{ pagination.page }} of {{ pagination.totalPages }}</span>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.page + 1)"
>
<span class="dashicons dashicons-arrow-right-alt2" />
</button>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.totalPages)"
>
<span class="dashicons dashicons-controls-skipforward" />
</button>
</div>
</footer>
</section>
</main>
</template>
<style scoped>
.users-page {
min-height: calc(100vh - 112px);
padding: 0;
color: var(--aurora-text);
background: transparent;
}
.users-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 24px;
}
.users-page__eyebrow {
margin: 0 0 7px;
color: var(--aurora-text-muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.users-page h1 {
margin: 0;
color: var(--aurora-text);
font-size: 1.45rem;
line-height: 1.15;
}
.users-page__actions,
.users-page__toolbar,
.users-page__filters,
.user-row__actions {
display: flex;
align-items: center;
}
.users-page__actions {
gap: 10px;
}
.button-primary {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 16px;
border-radius: var(--aurora-radius-sm);
background: var(--aurora-accent);
color: var(--aurora-accent-text);
font-size: 0.84rem;
font-weight: 700;
text-decoration: none;
}
.users-page__toolbar {
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.users-page__filters {
flex-wrap: wrap;
gap: 8px;
}
.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 12px;
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
}
.filter-pill strong {
min-width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--aurora-frame-bg);
color: inherit;
font-size: 0.72rem;
}
.filter-pill.is-active {
border-color: var(--aurora-text-muted);
background: var(--aurora-frame-bg);
color: var(--aurora-text);
}
.users-page__search {
width: min(320px, 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);
}
.users-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;
}
.users-page__search input::placeholder {
color: var(--aurora-text-muted);
}
.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;
text-decoration: none;
}
.icon-button:hover:not(:disabled) {
border-color: var(--aurora-text-muted);
color: var(--aurora-text);
}
.icon-button--inline {
width: 30px;
min-height: 30px;
border: 0;
background: transparent;
color: var(--aurora-text-muted);
}
.icon-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.users-notice {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
font-size: 0.78rem;
font-weight: 750;
}
.users-table {
overflow: hidden;
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-sm);
background: var(--aurora-frame-bg);
}
.users-table__head,
.user-row {
display: grid;
grid-template-columns: minmax(200px, 1.4fr) minmax(180px, 1.2fr) minmax(110px, 0.8fr) 90px 130px 100px;
align-items: center;
}
.users-table__head {
min-height: 48px;
border-bottom: 1px solid var(--aurora-border);
color: var(--aurora-text);
font-size: 0.79rem;
font-weight: 850;
}
.users-table__head > div {
min-height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
}
.user-row {
min-height: 66px;
border-bottom: 1px solid var(--aurora-border);
}
.user-row:hover {
background: var(--aurora-bg-subtle);
}
.user-row__user {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
gap: 12px;
align-items: center;
min-width: 0;
padding: 14px 16px;
}
.user-row__avatar,
.user-row__initials {
width: 36px;
height: 36px;
border-radius: 50%;
}
.user-row__avatar {
display: block;
}
.user-row__initials {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
font-weight: 900;
}
.user-row__person {
min-width: 0;
}
.user-row__person strong {
display: block;
overflow: hidden;
color: var(--aurora-text);
font-size: 0.86rem;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-row__person span {
display: block;
overflow: hidden;
color: var(--aurora-text-muted);
font-size: 0.74rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-row__cell {
min-width: 0;
padding: 14px 16px;
color: var(--aurora-text-muted);
font-size: 0.8rem;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-row__actions {
gap: 8px;
padding: 14px 16px;
}
.users-table__empty {
padding: 38px 18px;
color: var(--aurora-text-muted);
font-size: 0.85rem;
text-align: center;
}
.users-table__footer {
min-height: 54px;
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;
}
.users-table__pager {
display: flex;
align-items: center;
gap: 8px;
}
.users-table__pager button {
width: 28px;
height: 28px;
border: 0;
border-radius: var(--aurora-radius-sm);
background: transparent;
color: var(--aurora-text-muted);
cursor: pointer;
}
.users-table__pager button:not(:disabled):hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.users-table__pager button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 1280px) {
.users-table__head,
.user-row {
grid-template-columns: minmax(180px, 1.4fr) minmax(160px, 1.2fr) minmax(100px, 0.8fr) 80px 120px 90px;
}
}
@media (max-width: 1080px) {
.users-table__head {
display: none;
}
.user-row {
grid-template-columns: 1fr;
align-items: stretch;
padding: 8px 0;
}
.user-row__cell,
.user-row__actions {
padding: 8px 16px;
}
.user-row__cell {
display: flex;
justify-content: space-between;
gap: 16px;
white-space: normal;
}
.user-row__cell::before {
content: attr(data-label);
color: var(--aurora-text-muted);
font-weight: 800;
}
.users-table__footer,
.users-page__header,
.users-page__toolbar {
align-items: stretch;
flex-direction: column;
}
.users-table__footer {
padding: 14px 16px;
gap: 10px;
}
.users-page__search {
width: 100%;
}
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<script setup>
import { ref, onMounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path) => `${props.serverData.restUrl}aurora-admin/v1${path}`;
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
const sidebars = ref([]);
const loading = ref(true);
const error = ref('');
const load = async () => {
loading.value = true;
try {
const res = await fetch(api('/widgets'), { headers: headers() });
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not load widgets.');
sidebars.value = data.sidebars || [];
} catch (e) {
error.value = e.message || 'Could not load widgets.';
} finally {
loading.value = false;
}
};
onMounted(load);
const move = async (widget, fromSidebar, toSidebar, order = -1) => {
await fetch(api('/widgets/move'), {
method: 'POST',
headers: headers(),
body: JSON.stringify({ widgetId: widget.id, fromSidebar, toSidebar, order }),
});
await load();
};
const moveUp = (widget, sidebarId) => {
if (widget.order <= 0) return;
move(widget, sidebarId, sidebarId, widget.order - 1);
};
const moveDown = (widget, sidebarId, count) => {
if (widget.order >= count - 1) return;
move(widget, sidebarId, sidebarId, widget.order + 1);
};
const remove = async (widget) => {
if (!confirm(`Delete "${widget.name}"? This can't be undone.`)) return;
const res = await fetch(api(`/widgets/${encodeURIComponent(widget.id)}`), { method: 'DELETE', headers: headers() });
const data = await res.json();
if (!res.ok) {
error.value = data?.message || 'Could not delete widget.';
setTimeout(() => (error.value = ''), 3000);
return;
}
await load();
};
</script>
<template>
<main class="widgets-page">
<header class="widgets-page__header">
<div>
<p class="widgets-page__eyebrow">Appearance</p>
<h1>Widgets</h1>
<p class="widgets-page__sub">
Move widgets between your theme's sidebars, reorder them, or deactivate them. Adding a brand new
widget type still uses WordPress's own block-based widgets screen.
</p>
</div>
</header>
<div v-if="error" class="widgets-notice widgets-notice--error">{{ error }}</div>
<p v-if="loading" class="widgets-empty">Loading…</p>
<div v-else class="widgets-grid">
<section v-for="sidebar in sidebars" :key="sidebar.id" class="widgets-sidebar">
<h2>{{ sidebar.name }}</h2>
<p v-if="!sidebar.widgets.length" class="widgets-empty">No widgets here.</p>
<div v-for="widget in sidebar.widgets" :key="widget.id" class="widgets-item">
<div class="widgets-item__info">
<strong>{{ widget.name }}</strong>
<span v-if="widget.title">{{ widget.title }}</span>
</div>
<div class="widgets-item__actions">
<button type="button" title="Move up" @click="moveUp(widget, sidebar.id)"><span class="dashicons dashicons-arrow-up-alt2" /></button>
<button type="button" title="Move down" @click="moveDown(widget, sidebar.id, sidebar.widgets.length)"><span class="dashicons dashicons-arrow-down-alt2" /></button>
<select :value="sidebar.id" @change="move(widget, sidebar.id, $event.target.value)">
<option v-for="s in sidebars" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
<button v-if="sidebar.id === 'wp_inactive_widgets'" type="button" title="Delete" @click="remove(widget)">
<span class="dashicons dashicons-trash" />
</button>
</div>
</div>
</section>
</div>
</main>
</template>
<style scoped>
.widgets-page { max-width: 1100px; margin: 0 auto; }
.widgets-page__header { margin-bottom: 20px; }
.widgets-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.widgets-page h1 { margin: 0 0 6px; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.widgets-page__sub { margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); max-width: 640px; }
.widgets-notice { padding: 10px 14px; border-radius: var(--aurora-radius-sm); margin-bottom: 16px; font-size: 0.875rem; }
.widgets-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.widgets-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }
.widgets-sidebar {
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md); padding: 18px;
}
.widgets-sidebar h2 { font-size: 0.9375rem; margin: 0 0 10px; color: var(--aurora-text); }
.widgets-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
.widgets-item {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 8px 10px; border-radius: var(--aurora-radius-sm); margin-bottom: 6px;
background: var(--aurora-bg); border: 1px solid var(--aurora-border);
font-size: 0.8125rem;
}
.widgets-item__info { display: flex; flex-direction: column; min-width: 0; }
.widgets-item__info strong { color: var(--aurora-text); }
.widgets-item__info span { color: var(--aurora-text-muted); font-size: 0.75rem; }
.widgets-item__actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.widgets-item__actions button {
border: none; background: none; color: var(--aurora-text-muted); cursor: pointer;
width: 26px; height: 26px; border-radius: var(--aurora-radius-sm); display: inline-flex; align-items: center; justify-content: center;
}
.widgets-item__actions button:hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
.widgets-item__actions select {
font-size: 0.75rem; padding: 4px 6px; border-radius: var(--aurora-radius-sm);
border: 1px solid var(--aurora-border); background: var(--aurora-bg); color: var(--aurora-text);
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<script setup>
import { ref, onMounted } from 'vue';
import Sidebar from './components/Sidebar.vue';
import Toolbar from './components/Toolbar.vue';
import { applyThemePalette, applyFontFamily } from '@/pages/settings/applyLiveTheme.js';
import { initDashboardFrame } from './dashboardFrame.js';
const props = defineProps({
serverData: {
type: Object,
required: true,
},
});
// Minified (icon-only) menu — a per-browser preference kept in
// localStorage; the html class drives all layout via --aurora-sidebar-w.
const collapsed = ref(localStorage.getItem('aurora-menu-collapsed') === '1');
const applyCollapsed = () => {
document.documentElement.classList.toggle('aurora-collapsed', collapsed.value);
};
const toggleMenu = () => {
collapsed.value = !collapsed.value;
applyCollapsed();
localStorage.setItem('aurora-menu-collapsed', collapsed.value ? '1' : '0');
};
onMounted(() => {
const settings = props.serverData?.settings || {};
// Every theme preset is its own self-contained dark-toned palette now —
// no separate light/dark mode, so this class is always on. It's kept
// (rather than renamed) because shell-frame.css and native-dark.css both
// still gate their rules on it.
document.documentElement.classList.add('aurora-dark');
applyThemePalette(settings.theme_preset || 'indigo');
applyCollapsed();
applyFontFamily(settings.font_family);
initDashboardFrame();
// Signal the shell is live: reveals the framed content and commits to
// keeping native chrome hidden (the PHP failsafe stops watching once
// aurora-ready is present).
document.documentElement.classList.add('aurora-ready');
});
</script>
<template>
<div class="site-shell">
<Sidebar
:menu="serverData.menu"
:aurora-nav="serverData.auroraNav"
:current-url="serverData.currentUrl"
:active-parent-file="serverData.activeParentFile || ''"
:logo="serverData.settings?.logo || ''"
:dark-logo="serverData.settings?.dark_logo || ''"
:collapsed="collapsed"
:server-data="serverData"
/>
<Toolbar
:server-data="serverData"
@toggle-menu="toggleMenu"
/>
</div>
</template>
<style scoped>
/* Sidebar and Toolbar are both position:fixed, so this wrapper has no
layout role — it just groups them under one mounted root. */
.site-shell {
display: contents;
}
</style>
+231
View File
@@ -0,0 +1,231 @@
<script setup>
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
open: { type: Boolean, default: false },
});
const emit = defineEmits(['close']);
const input = ref(null);
const query = ref('');
const results = ref([]);
const loading = ref(false);
const highlighted = ref(0);
let debounceTimer;
let requestSeq = 0;
// wp/v2/search returns titles with HTML entities (e.g. "Ben &amp; 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) {
results.value = [];
return;
}
const seq = ++requestSeq;
loading.value = true;
try {
const url = new URL(`${props.serverData.restUrl}wp/v2/search`);
url.searchParams.set('search', q);
url.searchParams.set('per_page', '10');
// Settings > General > "Search post types" limits which post types the
// global search covers; empty means all searchable types.
const types = props.serverData.settings?.search_post_types;
if (Array.isArray(types) && types.length) {
url.searchParams.set('subtype', types.join(','));
}
const res = await fetch(url, {
headers: { 'X-WP-Nonce': props.serverData.restNonce },
});
if (!res.ok) throw new Error();
const items = await res.json();
if (seq === requestSeq) {
results.value = items;
highlighted.value = 0;
}
} catch {
if (seq === requestSeq) results.value = [];
} finally {
if (seq === requestSeq) loading.value = false;
}
};
watch(query, () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(search, 250);
});
watch(
() => props.open,
(open) => {
if (open) {
query.value = '';
results.value = [];
nextTick(() => input.value?.focus());
}
}
);
const openResult = (item) => {
if (!item) return;
window.location.href = `${props.serverData.adminUrl}post.php?post=${item.id}&action=edit`;
};
const onKeydown = (e) => {
if (!props.open) return;
if (e.key === 'Escape') {
emit('close');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
highlighted.value = Math.min(highlighted.value + 1, results.value.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlighted.value = Math.max(highlighted.value - 1, 0);
} else if (e.key === 'Enter') {
openResult(results.value[highlighted.value]);
}
};
onMounted(() => document.addEventListener('keydown', onKeydown));
onUnmounted(() => document.removeEventListener('keydown', onKeydown));
</script>
<template>
<Teleport to="body">
<div v-if="open" class="smodal__overlay" @click.self="emit('close')">
<div class="smodal">
<div class="smodal__inputwrap">
<span class="dashicons dashicons-search smodal__icon" />
<input
ref="input"
v-model="query"
type="text"
class="smodal__input"
placeholder="Search posts, pages…"
/>
<span class="smodal__esc">Esc</span>
</div>
<div v-if="loading" class="smodal__state">Searching…</div>
<div v-else-if="query.trim().length >= 2 && !results.length" class="smodal__state">
No results for “{{ query.trim() }}”
</div>
<ul v-if="results.length" class="smodal__results">
<li v-for="(item, i) in results" :key="item.id">
<button
type="button"
class="smodal__result"
:class="{ 'smodal__result--active': i === highlighted }"
@mouseenter="highlighted = i"
@click="openResult(item)"
>
<span class="smodal__result-title">{{ decodeEntities(item.title) }}</span>
<span class="smodal__result-type">{{ item.subtype }}</span>
</button>
</li>
</ul>
</div>
</div>
</Teleport>
</template>
<style scoped>
.smodal__overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 100000;
display: flex;
justify-content: center;
padding-top: 12vh;
}
.smodal {
width: min(620px, calc(100vw - 40px));
align-self: flex-start;
background: var(--aurora-frame-bg, #1c1c20);
border: 1px solid var(--aurora-frame-border, #34343b);
border-radius: var(--aurora-radius-md);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.smodal__inputwrap {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--aurora-border, #27272a);
}
.smodal__icon { color: var(--aurora-text-muted); }
.smodal__input {
flex: 1;
border: none;
background: none;
outline: none;
color: var(--aurora-text);
font-size: 0.95rem;
}
.smodal__esc {
font-size: 0.6875rem;
color: var(--aurora-text-muted);
border: 1px solid var(--aurora-border, #27272a);
border-radius: var(--aurora-radius-sm);
padding: 2px 6px;
}
.smodal__state {
padding: 18px 16px;
font-size: 0.875rem;
color: var(--aurora-text-muted);
}
.smodal__results {
list-style: none;
margin: 0;
padding: 6px;
max-height: 50vh;
overflow-y: auto;
}
.smodal__result {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
border: none;
background: none;
cursor: pointer;
text-align: left;
padding: 10px 12px;
border-radius: var(--aurora-radius-md);
}
.smodal__result--active { background: var(--aurora-accent-soft); }
.smodal__result-title {
color: var(--aurora-text);
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.smodal__result-type {
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--aurora-text-muted);
border: 1px solid var(--aurora-border, #27272a);
border-radius: 999px;
padding: 2px 8px;
text-transform: capitalize;
}
</style>
+530
View File
@@ -0,0 +1,530 @@
<script setup>
import { ref, computed } from 'vue';
import UserMenu from './UserMenu.vue';
const props = defineProps({
menu: {
type: Array,
default: () => [],
},
currentUrl: {
type: String,
default: '',
},
activeParentFile: {
type: String,
default: '',
},
logo: {
type: String,
default: '',
},
darkLogo: {
type: String,
default: '',
},
collapsed: {
type: Boolean,
default: false,
},
auroraNav: {
type: Array,
default: () => [],
},
serverData: {
type: Object,
required: true,
},
});
// Custom logo from Settings > General. Every theme preset is a dark-toned
// palette now (no separate light mode), so the dark-variant logo is always
// preferred when set; falls back to the standard logo, or the "Aurora"
// wordmark if neither is set.
const activeLogo = computed(() => props.darkLogo || props.logo);
// Normalize any admin URL down to the part after /wp-admin/ so we can
// compare the current page against menu targets regardless of host/scheme.
const normalize = (url) => {
if (!url) return '';
const idx = url.indexOf('/wp-admin/');
const path = idx === -1 ? url : url.slice(idx + '/wp-admin/'.length);
// The bare admin root ("/wp-admin/" with nothing after it) IS the
// dashboard, same as an explicit index.php — normalize them to one form
// so the Dashboard section correctly reads as active/expanded there.
return path === '' || path.startsWith('?') ? 'index.php' + path : path;
};
const current = computed(() => normalize(props.currentUrl));
const isItemActive = (item) => {
if (!item.url) return false;
return normalize(item.url) === current.value;
};
const isSectionActive = (item) => {
if (isItemActive(item)) return true;
if ((item.submenu || []).some((sub) => normalize(sub.url) === current.value)) return true;
// Covers native "hidden child" screens WP core itself doesn't list as a
// submenu link but still treats as belonging to this section for
// highlighting purposes (theme-install.php under Appearance, media-new.php
// under Media, user-new.php under Users, ...) — see Shell::print_mount()'s
// activeParentFile, sourced from WP core's own $parent_file global.
return !!props.activeParentFile && item.id === props.activeParentFile;
};
// Accordion: only one section's submenu open at a time — the WP-native
// menu list and the Aurora branded section below it are one shared
// accordion, not two independent ones, so expanding either closes
// whichever else was open (including Aurora, previously the one
// exception). AURORA_ID is a sentinel alongside the real native-menu item
// ids, since Aurora's own nav isn't one of the `menu` array's entries.
const AURORA_ID = '__aurora__';
const isAuroraSectionActive = () =>
props.auroraNav.some((item) => normalize(item.url) === current.value);
// Starts on whichever section is active (each nav is a full page load in
// this multi-page admin, so there's no need to react to URL changes
// afterward); falls back to Aurora expanded (its long-standing default)
// unless the user previously collapsed it, matching the old per-browser
// localStorage preference.
const openId = ref(
props.menu.find((item) => isSectionActive(item))?.id ??
(isAuroraSectionActive() || localStorage.getItem('aurora-nav-expanded') !== '0'
? AURORA_ID
: null)
);
const isExpanded = (item) => isSearching.value || openId.value === item.id;
const toggle = (item) => {
openId.value = openId.value === item.id ? null : item.id;
};
const auroraExpanded = computed(() => isSearching.value || openId.value === AURORA_ID);
const toggleAuroraNav = () => {
openId.value = auroraExpanded.value ? null : AURORA_ID;
// Only Aurora's own expand state needs to persist across page loads —
// native sections already resolve their open/closed state from whether
// they're active on the current page.
localStorage.setItem('aurora-nav-expanded', auroraExpanded.value ? '1' : '0');
};
const isAuroraActive = (item) => normalize(item.url) === current.value;
// Settings > Menu > "Show a search box at the top of the admin menu" —
// filters both the WP-native menu list and the Aurora nav section by
// title (own item or any submenu item), auto-expanding every section with
// a match so results are visible without also having to click into them.
const showMenuSearch = computed(() => !!props.serverData?.settings?.menu_search);
const searchQuery = ref('');
const isSearching = computed(() => searchQuery.value.trim() !== '');
const matchesQuery = (text) =>
(text || '').toLowerCase().includes(searchQuery.value.trim().toLowerCase());
const filteredMenu = computed(() => {
if (!isSearching.value) return props.menu;
return props.menu
.filter((item) => !item.separator)
.map((item) => {
const titleMatch = matchesQuery(item.title);
const matchingSubs = (item.submenu || []).filter((sub) => matchesQuery(sub.title));
if (!titleMatch && !matchingSubs.length) return null;
return titleMatch ? item : { ...item, submenu: matchingSubs };
})
.filter(Boolean);
});
const filteredAuroraNav = computed(() => {
if (!isSearching.value) return props.auroraNav;
return props.auroraNav.filter((item) => matchesQuery(item.title));
});
</script>
<template>
<nav class="sidebar" :class="{ 'sidebar--collapsed': collapsed }">
<div class="sidebar__scroll">
<div class="sidebar__brand">
<img v-if="activeLogo" :src="activeLogo" alt="" class="sidebar__brand-img" />
<template v-else>{{ collapsed ? 'A' : 'Aurora' }}</template>
</div>
<div v-if="showMenuSearch && !collapsed" class="sidebar__menu-search">
<span class="dashicons dashicons-search" />
<input v-model="searchQuery" type="text" placeholder="Search menu…" />
</div>
<ul class="sidebar__list">
<template v-for="(item, index) in filteredMenu" :key="item.id || index">
<li v-if="item.separator" class="sidebar__separator" />
<li v-else class="sidebar__item">
<div
class="sidebar__row"
:class="{ 'sidebar__row--active': isSectionActive(item) }"
>
<a :href="item.url" class="sidebar__link" :title="collapsed ? item.title : null">
<span class="sidebar__icon">
<span
v-if="item.icon && item.icon.type === 'dashicon'"
class="dashicons"
:class="item.icon.value"
/>
<img
v-else-if="item.icon && item.icon.type === 'image'"
:src="item.icon.value"
alt=""
class="sidebar__icon-img"
/>
<span v-else class="sidebar__icon-dot" />
</span>
<span class="sidebar__title">{{ item.title }}</span>
<span v-if="item.count" class="sidebar__count">{{ item.count }}</span>
</a>
<button
v-if="item.submenu && item.submenu.length && !isSearching"
type="button"
class="sidebar__chevron"
:class="{ 'sidebar__chevron--open': isExpanded(item) }"
:aria-label="`Toggle ${item.title} submenu`"
@click="toggle(item)"
>
›
</button>
</div>
<ul
v-if="item.submenu && item.submenu.length && isExpanded(item)"
class="sidebar__sublist"
>
<li
v-for="(sub, subIndex) in item.submenu"
:key="sub.id || subIndex"
class="sidebar__subitem"
>
<a
:href="sub.url"
class="sidebar__sublink"
:class="{ 'sidebar__sublink--active': normalize(sub.url) === current }"
>
{{ sub.title }}
<span v-if="sub.count" class="sidebar__count">{{ sub.count }}</span>
</a>
</li>
</ul>
</li>
</template>
</ul>
<p v-if="isSearching && !filteredMenu.length && !filteredAuroraNav.length" class="sidebar__no-results">
No matching menu items.
</p>
<div v-if="filteredAuroraNav.length" class="sidebar__separator" />
<div v-if="filteredAuroraNav.length" class="sidebar__aurora">
<div class="sidebar__row sidebar__aurora-brand" @click="toggleAuroraNav">
<a href="#" class="sidebar__link" @click.prevent :title="collapsed ? 'Aurora' : null">
<span class="sidebar__icon">
<span class="dashicons dashicons-superhero-alt" />
</span>
<span class="sidebar__title">Aurora</span>
</a>
<button
v-if="!isSearching"
type="button"
class="sidebar__chevron"
:class="{ 'sidebar__chevron--open': auroraExpanded }"
aria-label="Toggle Aurora menu"
>
›
</button>
</div>
<ul v-if="auroraExpanded" class="sidebar__sublist">
<li v-for="item in filteredAuroraNav" :key="item.id" class="sidebar__subitem">
<a
:href="item.url"
class="sidebar__sublink"
:class="{ 'sidebar__sublink--active': isAuroraActive(item) }"
>
{{ item.title }}
</a>
</li>
</ul>
</div>
</div>
<UserMenu
:server-data="serverData"
:collapsed="collapsed"
/>
</nav>
</template>
<style scoped>
.sidebar {
position: fixed;
top: var(--aurora-sidebar-gap);
left: var(--aurora-sidebar-gap);
bottom: var(--aurora-sidebar-gap);
width: var(--aurora-sidebar-w);
height: auto;
/* Same surface treatment as the dashboard cards (AuroraCard.vue) —
solid card background + rounded corners, floating over the page
gradient with a gap on every side now that it's inset instead of
flush against the viewport edges. */
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-card-border);
border-radius: var(--aurora-radius-lg);
transition: width 0.2s ease;
z-index: 99999;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
display: flex;
flex-direction: column;
}
/* Scrollable nav content — separate from the pinned UserMenu footer below
it, so the account card stays visible regardless of scroll position. */
.sidebar__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 0;
box-sizing: border-box;
}
.sidebar__brand {
font-size: 1.25rem;
font-weight: 700;
color: var(--aurora-text);
padding: 8px 20px 16px;
letter-spacing: -0.02em;
}
.sidebar__brand-img {
display: block;
max-height: 30px;
max-width: 190px;
object-fit: contain;
}
.sidebar__menu-search {
display: flex;
align-items: center;
gap: 8px;
margin: 0 12px 10px;
padding: 6px 10px;
border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
}
.sidebar__menu-search .dashicons {
font-size: 15px;
width: 15px;
height: 15px;
flex-shrink: 0;
}
.sidebar__menu-search input {
flex: 1;
min-width: 0;
border: none;
background: none;
outline: none;
color: var(--aurora-text);
font-size: 0.8125rem;
}
.sidebar__menu-search input::placeholder {
color: var(--aurora-text-muted);
}
.sidebar__no-results {
margin: 4px 16px;
font-size: 0.8125rem;
color: var(--aurora-text-muted);
}
.sidebar__list,
.sidebar__sublist {
list-style: none;
margin: 0;
padding: 0;
}
.sidebar__separator {
height: 1px;
background: var(--aurora-border);
margin: 8px 16px;
}
.sidebar__row {
display: flex;
align-items: center;
border-radius: var(--aurora-radius-sm);
margin: 0 8px;
}
.sidebar__row--active {
background: var(--aurora-accent-soft);
}
.sidebar__link {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
padding: 2px 10px;
text-decoration: none;
color: var(--aurora-text);
font-size: 0.875rem;
min-width: 0;
}
.sidebar__row--active .sidebar__link {
color: var(--aurora-accent);
font-weight: 600;
}
.sidebar__icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
transition: background 0.12s ease, color 0.12s ease;
}
.sidebar__row--active .sidebar__icon {
background: var(--aurora-accent-soft);
color: var(--aurora-accent);
}
.sidebar__icon .dashicons {
font-size: 16px;
width: 16px;
height: 16px;
}
.sidebar__icon-img {
width: 16px;
height: 16px;
object-fit: contain;
}
.sidebar__icon-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--aurora-text-muted);
}
.sidebar__title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__count {
background: var(--aurora-accent);
color: var(--aurora-accent-text);
font-size: 0.6875rem;
font-weight: 600;
border-radius: 999px;
padding: 1px 7px;
flex-shrink: 0;
}
.sidebar__chevron {
background: none;
border: none;
color: var(--aurora-text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 4px 12px;
transition: transform 0.15s ease;
}
.sidebar__chevron--open {
transform: rotate(90deg);
}
.sidebar__sublist {
padding: 2px 0 6px;
}
.sidebar__sublink {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 2px 10px 2px 42px;
text-decoration: none;
color: var(--aurora-text-muted);
font-size: 0.8125rem;
border-radius: var(--aurora-radius-sm);
margin: 0 8px;
}
.sidebar__sublink:hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.sidebar__sublink--active {
color: var(--aurora-accent);
font-weight: 600;
}
/* --- Aurora branded nav (right under the native menu's own divider) ---- */
.sidebar__aurora {
padding-bottom: 4px;
}
.sidebar__aurora-brand {
cursor: pointer;
font-weight: 600;
}
.sidebar__aurora-brand .sidebar__title {
color: var(--aurora-text-muted);
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sidebar__aurora-brand .sidebar__icon {
background: var(--aurora-accent-soft);
color: var(--aurora-accent);
}
/* --- Collapsed (icon-only) rail --------------------------------------- */
.sidebar--collapsed {
overflow-x: hidden;
}
.sidebar--collapsed .sidebar__brand {
padding: 8px 0 16px;
text-align: center;
font-size: 1.1rem;
}
.sidebar--collapsed .sidebar__brand-img {
margin: 0 auto;
max-width: 40px;
}
.sidebar--collapsed .sidebar__title,
.sidebar--collapsed .sidebar__count,
.sidebar--collapsed .sidebar__chevron,
.sidebar--collapsed .sidebar__sublist {
display: none;
}
.sidebar--collapsed .sidebar__row {
margin: 1px 10px;
}
.sidebar--collapsed .sidebar__link {
justify-content: center;
padding: 10px 0;
gap: 0;
}
</style>
+167
View File
@@ -0,0 +1,167 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import SearchModal from './SearchModal.vue';
const props = defineProps({
serverData: {
type: Object,
required: true,
},
});
const emit = defineEmits(['toggle-menu']);
const searchOpen = ref(false);
// Settings > General > "Disable global search" hides the search entirely.
const searchDisabled = computed(
() => !!props.serverData.settings?.disable_global_search
);
const onKeydown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
if (searchDisabled.value) return;
e.preventDefault();
searchOpen.value = true;
}
};
onMounted(() => document.addEventListener('keydown', onKeydown));
onUnmounted(() => document.removeEventListener('keydown', onKeydown));
</script>
<template>
<header class="toolbar">
<div class="toolbar__left">
<button
type="button"
class="toolbar__icon-btn"
title="Toggle menu"
@click="emit('toggle-menu')"
>
<span class="dashicons dashicons-menu" />
</button>
<a
:href="serverData.siteUrl || '/'"
class="toolbar__home"
title="View website"
target="_blank"
rel="noopener noreferrer"
>
<span class="dashicons dashicons-admin-home" />
</a>
<a :href="serverData.adminUrl" class="toolbar__home" title="Dashboard">
<span class="dashicons dashicons-dashboard" />
</a>
<button
v-if="!searchDisabled"
type="button"
class="toolbar__search"
@click="searchOpen = true"
>
<span class="dashicons dashicons-search toolbar__search-icon" />
<span class="toolbar__search-text">Search…</span>
<span class="toolbar__search-kbd">Ctrl K</span>
</button>
</div>
<SearchModal
:server-data="serverData"
:open="searchOpen"
@close="searchOpen = false"
/>
</header>
</template>
<style scoped>
.toolbar {
position: fixed;
top: 0;
left: calc(var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2);
right: 0;
height: var(--aurora-toolbar-h);
/* No background — see Sidebar.vue; the fixed html/body gradient shows
through so the whole shell reads as one continuous background. */
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
z-index: 99998;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.toolbar__left {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar__home {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--aurora-radius-sm);
color: var(--aurora-text-muted);
text-decoration: none;
}
.toolbar__home:hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.toolbar__search {
display: flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 10px;
min-width: 240px;
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md);
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
font-size: 0.8125rem;
cursor: text;
}
.toolbar__search-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
.toolbar__search-text {
flex: 1;
text-align: left;
}
.toolbar__search-kbd {
font-size: 0.6875rem;
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-sm);
padding: 1px 6px;
background: var(--aurora-bg);
}
.toolbar__icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: none;
background: none;
border-radius: var(--aurora-radius-sm);
cursor: pointer;
color: var(--aurora-text-muted);
}
.toolbar__icon-btn:hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
</style>
+194
View File
@@ -0,0 +1,194 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
collapsed: { type: Boolean, default: false },
});
const open = ref(false);
const root = ref(null);
const profileUrl = computed(() => `${props.serverData.adminUrl}profile.php`);
const settingsUrl = computed(() => `${props.serverData.adminUrl}options-general.php`);
const onDocClick = (e) => {
if (root.value && !root.value.contains(e.target)) {
open.value = false;
}
};
onMounted(() => document.addEventListener('click', onDocClick));
onUnmounted(() => document.removeEventListener('click', onDocClick));
</script>
<template>
<div ref="root" class="user-menu">
<div v-if="open" class="user-menu__card">
<div class="user-menu__header">
<img v-if="serverData.avatar" :src="serverData.avatar" alt="" class="user-menu__avatar" />
<div class="user-menu__id">
<div class="user-menu__name">{{ serverData.userName }}</div>
<div class="user-menu__email">{{ serverData.userEmail }}</div>
</div>
</div>
<div class="user-menu__items">
<a :href="profileUrl" class="user-menu__item">
<span class="dashicons dashicons-admin-users" />
Edit Profile
</a>
<a :href="serverData.siteUrl" target="_blank" rel="noopener noreferrer" class="user-menu__item">
<span class="dashicons dashicons-admin-home" />
Visit site
</a>
<a :href="settingsUrl" class="user-menu__item">
<span class="dashicons dashicons-admin-generic" />
Site Settings
</a>
<a :href="serverData.logoutUrl" class="user-menu__item user-menu__item--danger">
<span class="dashicons dashicons-migrate" />
Sign Out
</a>
</div>
</div>
<button
type="button"
class="user-menu__trigger"
:class="{ 'user-menu__trigger--collapsed': collapsed }"
@click="open = !open"
>
<img v-if="serverData.avatar" :src="serverData.avatar" alt="" class="user-menu__trigger-avatar" />
<span v-if="!collapsed" class="user-menu__trigger-name">{{ serverData.userName }}</span>
<span v-if="!collapsed" class="dashicons dashicons-arrow-up-alt2 user-menu__trigger-chevron" />
</button>
</div>
</template>
<style scoped>
.user-menu {
position: relative;
flex-shrink: 0;
padding: 8px;
border-top: 1px solid var(--aurora-border);
}
.user-menu__trigger {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
border: none;
background: none;
cursor: pointer;
padding: 6px 8px;
border-radius: var(--aurora-radius-sm);
color: var(--aurora-text);
}
.user-menu__trigger:hover {
background: var(--aurora-bg-subtle);
}
.user-menu__trigger--collapsed {
justify-content: center;
padding: 6px 0;
}
.user-menu__trigger-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
flex-shrink: 0;
}
.user-menu__trigger-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8125rem;
text-align: left;
}
.user-menu__trigger-chevron {
font-size: 14px;
width: 14px;
height: 14px;
color: var(--aurora-text-muted);
}
.user-menu__card {
position: absolute;
bottom: calc(100% + 8px);
left: 8px;
width: 240px;
background: var(--aurora-bg);
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
overflow: hidden;
z-index: 100000;
}
.user-menu__header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px;
border-bottom: 1px solid var(--aurora-border);
}
.user-menu__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
}
.user-menu__id { min-width: 0; }
.user-menu__name {
font-size: 0.875rem;
font-weight: 600;
color: var(--aurora-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-menu__email {
font-size: 0.75rem;
color: var(--aurora-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-menu__items {
padding: 6px;
border-bottom: 1px solid var(--aurora-border);
}
.user-menu__item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--aurora-radius-sm);
text-decoration: none;
color: var(--aurora-text);
font-size: 0.8125rem;
}
.user-menu__item:hover {
background: var(--aurora-bg-subtle);
}
.user-menu__item .dashicons {
font-size: 16px;
width: 16px;
height: 16px;
color: var(--aurora-text-muted);
}
.user-menu__item--danger {
color: #ef4444;
}
.user-menu__item--danger .dashicons {
color: #ef4444;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
/**
* Structural setup for the takeover Dashboard.
*
* DashboardTakeover.php renders the dashboard into a separate
* #aurora-admin-dashboard-root sibling (above the hidden #wpbody), so unlike
* every other page it never sits inside #wpbody-content and never got that
* element's card frame. Wrap it in .aurora-dashboard-frame so it gets the
* same card treatment, and pull the custom admin notices
* (Notices::render_for_dashboard(), printed as a sibling just before the
* mount point) into that frame so they render as part of the card.
*
* This is the only DOM restructuring the shell does — there is no window
* chrome (title bars, traffic-light controls, minimize taskbar); that whole
* macOS-window metaphor was removed.
*/
export function initDashboardFrame() {
const dashboardRoot = document.getElementById('aurora-admin-dashboard-root');
if (!dashboardRoot) {
return;
}
// Dedupe: if SiteShell re-mounts, don't wrap a second time.
if (dashboardRoot.parentNode.querySelector('.aurora-dashboard-frame')) {
return;
}
const frame = document.createElement('div');
frame.className = 'aurora-dashboard-frame';
dashboardRoot.parentNode.insertBefore(frame, dashboardRoot);
const notices = document.getElementById('aurora-admin-dashboard-notices');
if (notices) {
frame.appendChild(notices);
}
frame.appendChild(dashboardRoot);
}
+458
View File
@@ -0,0 +1,458 @@
/*
* The ONLY global stylesheet in the plugin. Every rule here either defines
* an --aurora-* theme variable or targets a specific WordPress structural ID
* to frame the native content. No element/tag resets, no utility classes —
* so it cannot leak onto or restyle WordPress's own page content the way the
* old plugin's Tailwind preflight did.
*
* Theme colors live here as plain CSS variables we fully control. Dark mode
* is a single class on <html> (.aurora-dark) that swaps the variable values.
*/
/* Palette carried over from the original plugin's design tokens: a zinc
neutral scale with a blue/indigo accent, large rounded surfaces. */
:root {
--aurora-sidebar-w: 264px;
--aurora-toolbar-h: 56px;
/* Space between the sidebar card and the browser's left/top/bottom
edges (Sidebar.vue) — the toolbar and #wpcontent offsets below add
the same amount so nothing overlaps the floating sidebar. */
--aurora-sidebar-gap: 16px;
/* Breathing room between page content and the right browser edge. */
--aurora-gutter-r: 44px;
/* Corner-radius scale, one notch rounder across the UI — tune here and
every surface follows (all radii are tokenized onto this scale). */
--aurora-radius-sm: 10px; /* chips, badges, small/icon controls */
--aurora-radius-md: 14px; /* buttons, inputs, search, list rows */
--aurora-radius-lg: 20px; /* cards, panels, larger buttons */
--aurora-radius-xl: 28px; /* page frames: content card, dashboard, sidebar, editor */
/* Light theme */
--aurora-app-bg: #fafafa; /* zinc-50 */
/* Flat fallback for the handful of consumers that need a solid color
(--aurora-app-bg itself may be a gradient once a theme preset applies). */
--aurora-app-bg-solid: #fafafa;
--aurora-bg: #ffffff; /* frame */
--aurora-card-bg: #fafafa; /* zinc-50 */
/* Dashboard cards specifically (AuroraCard.vue) — a step further from
--aurora-frame-bg than --aurora-card-bg so they read as distinct
surfaces against the Dashboard's own frame, not a near-identical tone. */
--aurora-card-bg-deep: #f0f0f2;
--aurora-bg-subtle: #f4f4f5; /* zinc-100 */
--aurora-text: #18181b; /* zinc-900 */
--aurora-text-muted: #71717a; /* zinc-500 */
--aurora-border: #e4e4e7; /* zinc-200 */
--aurora-card-border: #ececef;
--aurora-frame-bg: #ffffff; /* native-page content panel */
--aurora-frame-border: #e0e0e3;
--aurora-accent: rgb(71 114 250);
--aurora-accent-soft: rgb(71 114 250 / 0.1);
--aurora-accent-text: #ffffff;
/* Overridden by the font-picker setting (see SiteShell.vue::applyFontFamily);
this is the fallback when no Google Font has been chosen. */
--aurora-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Minified menu: the toolbar's collapse button toggles this class; every
layout offset derives from --aurora-sidebar-w, so this one override
reflows the toolbar and content automatically. */
html.aurora-collapsed {
--aurora-sidebar-w: 68px;
}
html.aurora-dark {
/* Dark theme — the immersive near-black look of the original */
--aurora-app-bg: #09090b; /* zinc-950 */
--aurora-app-bg-solid: #09090b;
--aurora-bg: #18181b; /* zinc-900 frame */
--aurora-card-bg: #1f1f23; /* slightly elevated */
--aurora-card-bg-deep: #131316;
--aurora-bg-subtle: #27272a; /* zinc-800 */
--aurora-text: #f4f4f5; /* zinc-100 */
--aurora-text-muted: #a1a1aa; /* zinc-400 */
--aurora-border: #27272a; /* zinc-800 */
--aurora-card-border: #2e2e33;
--aurora-frame-bg: #1c1c20; /* slightly elevated panel */
--aurora-frame-border: #34343b;
--aurora-accent: rgb(125 158 255);
--aurora-accent-soft: rgb(125 158 255 / 0.14);
}
/* --- Framing: only applies once the Vue shell has signalled ready --- */
html.aurora-ready,
html.aurora-ready body {
/* !important: WP core ships `body.js.block-editor-page { background:
#fff }` on the block editor, which has higher specificity (two
classes) than this selector (one) despite this having more type
selectors — CSS specificity compares class count before type count.
Without !important that rule wins and paints body solid white,
hiding the gradient completely on that page (everywhere else this
conflict doesn't exist, so it was never needed until now). */
background: var(--aurora-app-bg) !important;
/* Fixed so the gradient stays pinned to the viewport instead of
scrolling with document height — the sidebar/toolbar are also
position:fixed and paint no background of their own (see
Sidebar.vue/Toolbar.vue), so this one gradient plane has to line up
with them at every scroll position to read as a single unified
background instead of a seam between "sidebar" and "page". */
background-attachment: fixed;
font-family: var(--aurora-font-family);
/* The sidebar/toolbar are position:fixed and every page's real content
now scrolls internally within its own frame (#wpbody-content or
.aurora-dashboard-frame, both capped to the viewport with their own
overflow-y: auto below) — so the document itself is never meant to
scroll. Some native pages (Add Plugins' plugin-card grid is the one
that surfaced this) leave a huge dead scrollable area at the document
level — a WP/browser quirk around lazily-rendered content reserving
scroll space against the page rather than the inner container — which
showed up as being able to scroll the whole window down to a big
empty gap below the frame. Locking the document's own scroll removes
that dead space outright, regardless of what's causing it. */
overflow: hidden;
}
/* !important here defeats WordPress's own responsive #wpcontent margin
rules (.auto-fold / folded menu states live in media queries at equal
specificity). This is positioning the frame, not restyling content.
The toolbar clearance lives here (on #wpcontent, which holds everything)
rather than on #wpbody, so content rendered above #wpbody — like the
takeover dashboard — also clears the fixed toolbar. */
html.aurora-ready #wpcontent {
margin-left: calc(var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2) !important;
padding-left: 0 !important;
padding-top: var(--aurora-toolbar-h);
transition: margin-left 0.2s ease;
}
/* The block editor (Gutenberg) doesn't live inside #wpcontent's normal
box model — it renders its own fixed, full-viewport root, completely
independent of the margin-left above. Left unchecked it spans the full
viewport and renders underneath the (higher z-index) sidebar and
toolbar. Framed the same way #wpbody-content is everywhere else — same
insets, same card background/border/radius — just expressed as
fixed-position offsets instead of margin, since this element can't
participate in normal document flow.
Two different pages, two different fixed roots, both matched here:
- post.php/post-new.php: .interface-interface-skeleton itself is
position:fixed, inset:0.
- site-editor.php: .interface-interface-skeleton is only
position:relative, nested inside #site-editor, which is the actual
position:fixed, inset:0 element there — the rule below with
"#site-editor .interface-interface-skeleton" resets the nested one
back to a plain unpositioned child so the two frames don't stack. */
html.aurora-ready .interface-interface-skeleton,
html.aurora-ready #site-editor {
top: calc(var(--aurora-toolbar-h) + 16px) !important;
left: calc(var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
right: var(--aurora-gutter-r) !important;
bottom: 24px !important;
/* WP core's own .edit-site class (also present on #site-editor) sets
height: 100vh. A position:fixed box with top, height, AND bottom all
specified is over-constrained — per spec, 'bottom' is then silently
recomputed from top+height instead of honoring the value here, which
is exactly why the frame ran past the bottom of the viewport instead
of stopping at the inset above. Forcing height back to auto lets
top+bottom actually determine the box's size again. */
height: auto !important;
background: var(--aurora-frame-bg);
/* No border — an asymmetric one (3 sides, one "none") combined with
border-radius and this element's sub-pixel calc()-derived fixed
position produced a visible double-edge rendering artifact right at
the rounded corners. The background/gradient contrast alone is
enough to read as a distinct surface without it. */
border-radius: var(--aurora-radius-xl);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
transition: left 0.2s ease;
box-sizing: border-box;
/* border-radius alone doesn't clip children — Gutenberg's own white
canvas background ran straight past the rounded bottom corners as a
hard square edge without this. Floating UI (block toolbars, popovers)
portals to document.body rather than nesting here, so this doesn't
clip anything that needs to escape the frame. */
overflow: hidden;
}
#site-editor .interface-interface-skeleton {
position: relative !important;
top: auto !important;
left: auto !important;
right: auto !important;
bottom: auto !important;
background: transparent !important;
border-radius: 0 !important;
box-shadow: none !important;
padding-top: 0 !important;
overflow: visible !important;
width: 100%;
height: 100%;
}
html.aurora-ready #wpbody-content {
background: var(--aurora-frame-bg);
color: var(--aurora-text);
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-xl);
/* 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);
/* Cap the card's own height to whatever's left below the toolbar and let
*it* scroll internally, instead of growing to fit tall native pages
(Comments, Plugins, Add User, Settings, Activity Log, Database
Explorer, ...) and forcing the whole document to scroll — the sidebar/
toolbar are fixed, so a tall #wpbody-content just meant a lot of empty
scroll distance below the fold with nothing visibly moving except the
content. Same fix as the taskbar-present case below, just unconditional. */
max-height: calc(100vh - var(--aurora-toolbar-h) - 40px);
overflow-y: auto;
/* Chrome's scroll anchoring tries to keep whatever's in view visually
stable when content shifts above it. Several native pages (Add Plugins'
plugin-card grid, most visibly) render their real content via JS well
after the initial paint, inserting it above stuff that was already
visible in this now-scrollable box — anchoring then "corrects" by
jumping scrollTop down to compensate, landing the page scrolled to
the middle/bottom on load instead of the top. Opting this container
out of anchoring is the standard fix for that class of bug. */
overflow-anchor: none;
box-sizing: border-box;
/* WP core sets #wpbody-content { float: left; width: 100% }, which (with
box-sizing left alone) ignores the margin above and overflows past the
right edge by margin-left + margin-right. Force it back to shrink-to-fit
so the margin is actually respected. */
float: none;
width: auto;
}
/* Gutenberg renders in its own fixed skeleton. The regular #wpbody-content
wrapper still exists on post-new.php/post.php, but it is inert; if framed
like normal native pages, it appears as a stray empty pill above the editor. */
html.aurora-ready body.block-editor-page #wpbody-content,
html.aurora-ready body.post-new-php #wpbody-content,
html.aurora-ready body.post-php #wpbody-content {
background: transparent !important;
border: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
margin: 0 !important;
min-height: 0 !important;
padding: 0 !important;
}
/* Theme installer preview ("Add Themes" > click a theme > full "Preview &
Install" overlay, theme-install.php). WP core's own .wp-full-overlay
component (shared with the Customizer, but only .theme-install-overlay
specifically is touched here — customize.php is out of scope) is a
true-viewport fixed overlay by design, so left unstyled it covers
Aurora's sidebar/toolbar entirely. Reframed the same way the block
editor is above: shrink the overlay's own box to the frame's insets,
then independently reposition its two POSITION:FIXED children (sidebar,
main) to match — fixed descendants are NOT relative to an ancestor's
padding box the way absolutely-positioned ones are, so the overlay's own
inset alone doesn't move them; each needs the same left/top/bottom math
applied directly. margin-left on .expanded/.collapsed mirrors WP core's
own three breakpoints (themes.css) with Aurora's frame offset added on
top, so the sidebar collapse/expand slide animation keeps working
unmodified — only the resting position changed, not the transition.
One more WP core mechanism to counteract: opening the overlay adds
body.full-overlay-active, and core's own CSS sets
`body.full-overlay-active { visibility: hidden }` with only
`.theme-install-overlay` (and #customize-container) set back to
visible — intended to hide the native admin chrome underneath, which
normally doesn't matter since core's own sidebar/toolbar are redundant
once covered anyway. Aurora's shell inherits that hidden visibility
too (it's nested in body), which is exactly what silently erased the
sidebar/toolbar entirely instead of just having them covered. Since
descendants can locally override an inherited `visibility: hidden`
back to visible, one rule undoes it for Aurora's own root only —
everything else under body stays hidden, harmless since it's covered
by the overlay's insets anyway. */
body.full-overlay-active #aurora-admin-shell-root {
visibility: visible !important;
}
html.aurora-ready .theme-install-overlay {
top: calc(var(--aurora-toolbar-h) + 16px) !important;
right: var(--aurora-gutter-r) !important;
bottom: 24px !important;
/* WP core's own .wp-full-overlay sets height: 100% — combined with top
AND bottom both set here, that's the exact same over-constrained box
as .interface-interface-skeleton above: per spec 'bottom' silently
loses to the explicit height, so the box ran past the true viewport
edge instead of stopping at this inset. Same fix. */
height: auto !important;
}
html.aurora-ready .theme-install-overlay.expanded {
margin-left: calc(300px + var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
}
html.aurora-ready .theme-install-overlay.collapsed {
margin-left: calc(var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
}
@media screen and (min-width: 1667px) {
html.aurora-ready .theme-install-overlay.expanded {
margin-left: calc(18% + var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
}
}
@media screen and (min-width: 3333px) {
html.aurora-ready .theme-install-overlay.expanded {
margin-left: calc(600px + var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
}
}
html.aurora-ready .theme-install-overlay .wp-full-overlay-sidebar {
left: calc(var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
top: calc(var(--aurora-toolbar-h) + 16px) !important;
bottom: 24px !important;
height: auto !important;
border-top-left-radius: var(--aurora-radius-xl);
}
html.aurora-ready .theme-install-overlay .wp-full-overlay-main {
top: 0 !important;
border-top-right-radius: var(--aurora-radius-xl);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
/* The Dashboard bypasses #wpbody-content (DashboardTakeover.php renders
it into a separate sibling — see dashboardFrame.js), so it never got the
card frame every other page has. dashboardFrame.js wraps the dashboard's
mount point in this div to give it the same treatment. */
.aurora-dashboard-frame {
background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-xl);
/* 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
element never got it, since the Dashboard bypasses #wpbody-content
entirely (a separate sibling — see dashboardFrame.js). Once html/body
were locked to overflow:hidden (to kill a phantom document-level
scrollbar on another page), the dashboard's content past the first
~1500px became completely unreachable: no internal scroll here, and
no document
scroll left to fall back on either. */
max-height: calc(100vh - var(--aurora-toolbar-h) - 40px);
/* 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
here by dashboardFrame.js) — native-dark.css already themes .notice
elsewhere via #wpbody-content, but that stylesheet isn't loaded on the
Dashboard at all (Shell::is_own_page() treats index.php as one of
Aurora's own pages), so it needs its own copy of the same treatment
here instead. */
#aurora-admin-dashboard-notices:empty {
display: none;
}
#aurora-admin-dashboard-notices .notice {
background: var(--aurora-card-bg);
color: var(--aurora-text);
margin: 16px 20px 0;
}
/* The settings page renders its own two boxed panels, so drop the outer
content frame there — leaving just those panels on the dark backdrop.
No background here: #wpbody-content is inset from the sidebar/gutter,
not the full viewport, so painting var(--aurora-app-bg) on it computes
its own independent (and visibly mismatched) gradient instance instead
of sharing the one fixed gradient plane painted on html/body. */
html.aurora-ready body.toplevel_page_aurora-admin #wpbody-content {
border: none;
border-radius: 0;
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
* on dark mode. It restyles WP's postboxes, forms, tables, buttons, etc.
* Keeping that large surface in one isolated, native-only file means it
* can never leak onto Aurora's own pages or affect light mode.
*/
+70
View File
@@ -0,0 +1,70 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
// Multi-entry build: one entry per admin screen (see rollupOptions.input).
// Each is enqueued as its own ES module by admin/src/Utility/Assets.php,
// which also walks the manifest's `imports` chain for shared-chunk CSS.
// Rollup automatically splits code shared by 2+ entries into its own chunk;
// `vue` is pinned to a named `vendor-vue` chunk below so its hash stays
// stable across app-code changes (returning users keep it cached across
// plugin updates instead of re-downloading it every release).
//
// Sourcemaps are on by default for local debugging (the dev loop hot-patches
// app/dist into a LocalWP install). The distribution build sets
// AURORA_SOURCEMAP=false so the shipped zip doesn't carry ~1.2 MB of .map
// files that are useless to end users.
const emitSourcemaps = process.env.AURORA_SOURCEMAP !== 'false';
export default defineConfig({
root: fileURLToPath(new URL('./src', import.meta.url)),
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: fileURLToPath(new URL('./dist', import.meta.url)),
emptyOutDir: true,
sourcemap: emitSourcemaps,
manifest: true,
rollupOptions: {
input: {
shell: fileURLToPath(new URL('./src/entries/shell.js', import.meta.url)),
dashboard: fileURLToPath(new URL('./src/entries/dashboard.js', import.meta.url)),
settings: fileURLToPath(new URL('./src/entries/settings.js', import.meta.url)),
'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)),
plugins: fileURLToPath(new URL('./src/entries/plugins.js', import.meta.url)),
comments: fileURLToPath(new URL('./src/entries/comments.js', import.meta.url)),
posts: fileURLToPath(new URL('./src/entries/posts.js', import.meta.url)),
pages: fileURLToPath(new URL('./src/entries/pages.js', import.meta.url)),
media: fileURLToPath(new URL('./src/entries/media.js', import.meta.url)),
users: fileURLToPath(new URL('./src/entries/users.js', import.meta.url)),
'native-settings': fileURLToPath(new URL('./src/entries/native-settings.js', import.meta.url)),
'media-upload': fileURLToPath(new URL('./src/entries/media-upload.js', import.meta.url)),
'add-user': fileURLToPath(new URL('./src/entries/add-user.js', import.meta.url)),
'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)),
},
output: {
// Keep Vue in its own long-lived vendor chunk (see header note).
manualChunks: { 'vendor-vue': ['vue'] },
entryFileNames: '[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash][extname]',
},
},
},
});