Initial commit - Aurora Admin v1.0
Co-Authored-By: NixReaper <[email protected]>
This commit is contained in:
Generated
+1363
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "aurora-admin-app",
|
||||
"version": "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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import ActivityLog from '@/pages/activity-log/ActivityLog.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-activity-log-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(ActivityLog, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import 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);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import AdminNotices from '@/pages/admin-notices/AdminNotices.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-admin-notices-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(AdminNotices, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue';
|
||||
// Pulls in the shared --aurora-* theme variables. Harmless if the site-wide
|
||||
// shell already loaded them (identical values); keeps this page self-sufficient.
|
||||
import '@/shell/shell-frame.css';
|
||||
import DashboardPage from '@/pages/dashboard/Dashboard.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-dashboard-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(DashboardPage, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import Export from '@/pages/export/Export.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-export-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(Export, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import Import from '@/pages/import/Import.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-import-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(Import, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import MenuCreator from '@/pages/menu-creator/MenuCreator.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-menu-creator-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(MenuCreator, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import Modules from '@/pages/modules/Modules.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-modules-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(Modules, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import RoleEditor from '@/pages/role-editor/RoleEditor.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-role-editor-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(RoleEditor, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import SettingsPage from '@/pages/settings/Settings.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-settings-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(SettingsPage, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import 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);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@/shell/shell-frame.css';
|
||||
import SiteShell from '@/shell/SiteShell.vue';
|
||||
|
||||
const mountEl = document.getElementById('aurora-admin-shell-root');
|
||||
|
||||
if (mountEl) {
|
||||
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||
createApp(SiteShell, { serverData: data }).mount(mountEl);
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const contentType = ref('all');
|
||||
|
||||
const filters = reactive({
|
||||
category: '',
|
||||
post_author: '',
|
||||
post_start_date: '',
|
||||
post_end_date: '',
|
||||
post_status: '',
|
||||
page_author: '',
|
||||
page_start_date: '',
|
||||
page_end_date: '',
|
||||
page_status: '',
|
||||
attachment_start_date: '',
|
||||
attachment_end_date: '',
|
||||
});
|
||||
|
||||
// Standard WP post statuses (matches core's own get_post_stati({internal: false})).
|
||||
const STATUSES = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'publish', label: 'Published' },
|
||||
{ value: 'future', label: 'Scheduled' },
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'pending', label: 'Pending Review' },
|
||||
{ value: 'private', label: 'Private' },
|
||||
{ value: 'trash', label: 'Trash' },
|
||||
];
|
||||
|
||||
const categories = ref([]);
|
||||
const authors = ref([]);
|
||||
const loadingOptions = ref(true);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const headers = { 'X-WP-Nonce': props.serverData.restNonce };
|
||||
const [catsRes, usersRes] = await Promise.all([
|
||||
fetch(`${props.serverData.restUrl}wp/v2/categories?per_page=100`, { headers }),
|
||||
fetch(`${props.serverData.restUrl}wp/v2/users?per_page=100`, { headers }),
|
||||
]);
|
||||
categories.value = catsRes.ok ? await catsRes.json() : [];
|
||||
authors.value = usersRes.ok ? await usersRes.json() : [];
|
||||
} finally {
|
||||
loadingOptions.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Building and navigating to a real export.php?download=true&... URL (not a
|
||||
// fetch) so the browser's normal file-download flow handles the response —
|
||||
// this is the exact same request native WordPress's own form would submit,
|
||||
// so the file it returns is unmodified.
|
||||
//
|
||||
// Every relevant param is always sent, even when blank. Native's own HTML
|
||||
// <select> elements always submit a name=value pair regardless of which
|
||||
// option is picked, and export.php's own PHP reads $_GET['cat'] etc.
|
||||
// directly with no isset() guard — omitting a param when "All" is selected
|
||||
// (as this used to) triggers PHP "Undefined array key" warnings that, with
|
||||
// display_errors on (any site with debugging enabled), get echoed straight
|
||||
// into the downloaded file before the XML, corrupting it.
|
||||
const download = () => {
|
||||
const params = new URLSearchParams({ download: 'true', content: contentType.value });
|
||||
|
||||
if (contentType.value === 'posts') {
|
||||
params.set('cat', filters.category);
|
||||
params.set('post_author', filters.post_author);
|
||||
params.set('post_start_date', filters.post_start_date);
|
||||
params.set('post_end_date', filters.post_end_date);
|
||||
params.set('post_status', filters.post_status);
|
||||
} else if (contentType.value === 'pages') {
|
||||
params.set('page_author', filters.page_author);
|
||||
params.set('page_start_date', filters.page_start_date);
|
||||
params.set('page_end_date', filters.page_end_date);
|
||||
params.set('page_status', filters.page_status);
|
||||
} else if (contentType.value === 'attachment') {
|
||||
params.set('attachment_start_date', filters.attachment_start_date);
|
||||
params.set('attachment_end_date', filters.attachment_end_date);
|
||||
}
|
||||
|
||||
window.location.href = `${props.serverData.exportUrl}?${params.toString()}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="export-page">
|
||||
<header class="export-page__header">
|
||||
<p class="export-page__eyebrow">Aurora Admin</p>
|
||||
<h1>Export</h1>
|
||||
<p class="export-page__lead">
|
||||
Create an XML file (WordPress eXtended RSS, or WXR) containing your posts, pages, comments,
|
||||
custom fields, categories, and tags. Import it into another WordPress site to bring this
|
||||
content over.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="export-page__card">
|
||||
<h2>Choose what to export</h2>
|
||||
|
||||
<div class="export-page__options">
|
||||
<label class="export-page__radio">
|
||||
<input type="radio" value="all" v-model="contentType" />
|
||||
<div>
|
||||
<strong>All content</strong>
|
||||
<p>All of your posts, pages, comments, custom fields, terms, navigation menus, and custom posts.</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="export-page__radio">
|
||||
<input type="radio" value="posts" v-model="contentType" />
|
||||
<strong>Posts</strong>
|
||||
</label>
|
||||
<div v-if="contentType === 'posts'" class="export-page__filters">
|
||||
<label>
|
||||
Category
|
||||
<select v-model="filters.category" :disabled="loadingOptions">
|
||||
<option value="">All</option>
|
||||
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Author
|
||||
<select v-model="filters.post_author" :disabled="loadingOptions">
|
||||
<option value="">All</option>
|
||||
<option v-for="a in authors" :key="a.id" :value="a.id">{{ a.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Start date <input type="date" v-model="filters.post_start_date" /></label>
|
||||
<label>End date <input type="date" v-model="filters.post_end_date" /></label>
|
||||
<label>
|
||||
Status
|
||||
<select v-model="filters.post_status">
|
||||
<option v-for="s in STATUSES" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="export-page__radio">
|
||||
<input type="radio" value="pages" v-model="contentType" />
|
||||
<strong>Pages</strong>
|
||||
</label>
|
||||
<div v-if="contentType === 'pages'" class="export-page__filters">
|
||||
<label>
|
||||
Author
|
||||
<select v-model="filters.page_author" :disabled="loadingOptions">
|
||||
<option value="">All</option>
|
||||
<option v-for="a in authors" :key="a.id" :value="a.id">{{ a.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Start date <input type="date" v-model="filters.page_start_date" /></label>
|
||||
<label>End date <input type="date" v-model="filters.page_end_date" /></label>
|
||||
<label>
|
||||
Status
|
||||
<select v-model="filters.page_status">
|
||||
<option v-for="s in STATUSES" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="export-page__radio">
|
||||
<input type="radio" value="attachment" v-model="contentType" />
|
||||
<strong>Media</strong>
|
||||
</label>
|
||||
<div v-if="contentType === 'attachment'" class="export-page__filters">
|
||||
<label>Start date <input type="date" v-model="filters.attachment_start_date" /></label>
|
||||
<label>End date <input type="date" v-model="filters.attachment_end_date" /></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="export-page__submit" @click="download">Download Export File</button>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.export-page { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
|
||||
.export-page__eyebrow {
|
||||
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
|
||||
font-weight: 800; text-transform: uppercase;
|
||||
}
|
||||
.export-page__header h1 { margin: 0; font-size: 1.45rem; line-height: 1.15; }
|
||||
.export-page__lead {
|
||||
margin: 12px 0 0; max-width: 640px; color: var(--aurora-text-muted);
|
||||
font-size: 0.875rem; line-height: 1.6;
|
||||
}
|
||||
|
||||
.export-page__card {
|
||||
margin-top: 24px; background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
|
||||
border-radius: var(--aurora-radius-lg); padding: 22px 24px; max-width: 560px;
|
||||
}
|
||||
.export-page__card h2 { margin: 0 0 16px; font-size: 1rem; font-weight: 700; }
|
||||
|
||||
.export-page__options { display: flex; flex-direction: column; gap: 4px; }
|
||||
.export-page__radio {
|
||||
display: flex; align-items: flex-start; gap: 10px; padding: 8px 4px;
|
||||
cursor: pointer; font-size: 0.875rem;
|
||||
}
|
||||
.export-page__radio input { margin-top: 3px; accent-color: var(--aurora-accent); }
|
||||
.export-page__radio p { margin: 4px 0 0; font-size: 0.8125rem; color: var(--aurora-text-muted); font-weight: 400; }
|
||||
|
||||
.export-page__filters {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
|
||||
margin: 0 0 8px 30px; padding: 12px; background: var(--aurora-bg-subtle);
|
||||
border-radius: var(--aurora-radius-md);
|
||||
}
|
||||
.export-page__filters label {
|
||||
display: flex; flex-direction: column; gap: 4px; font-size: 0.75rem;
|
||||
color: var(--aurora-text-muted); font-weight: 600;
|
||||
}
|
||||
.export-page__filters select, .export-page__filters input[type='date'] {
|
||||
padding: 6px 8px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
|
||||
background: var(--aurora-card-bg); color: var(--aurora-text); font-size: 0.8125rem; font-weight: 400;
|
||||
}
|
||||
|
||||
.export-page__submit {
|
||||
margin-top: 20px; border: none; border-radius: var(--aurora-radius-sm); padding: 10px 20px;
|
||||
font-size: 0.875rem; font-weight: 600; cursor: pointer; background: var(--aurora-accent); color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup>
|
||||
import { reactive } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const api = (path, options = {}) =>
|
||||
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
}).then(async (r) => {
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.message || 'Request failed');
|
||||
return data;
|
||||
});
|
||||
|
||||
// Local, mutable copy so a row's status/action can update in place once
|
||||
// installed/activated, without needing a full page reload.
|
||||
const importers = reactive(props.serverData.importers.map((i) => ({ ...i, busy: false, error: '' })));
|
||||
|
||||
const goToImporter = (importer) => {
|
||||
window.location.href = importer.runUrl;
|
||||
};
|
||||
|
||||
const activate = async (importer) => {
|
||||
importer.busy = true;
|
||||
importer.error = '';
|
||||
try {
|
||||
await api('/plugins/activate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file: importer.pluginFile }),
|
||||
});
|
||||
goToImporter(importer);
|
||||
} catch (e) {
|
||||
importer.error = e.message || 'Could not activate the importer.';
|
||||
} finally {
|
||||
importer.busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
const installAndActivate = async (importer) => {
|
||||
importer.busy = true;
|
||||
importer.error = '';
|
||||
try {
|
||||
// Reuses WordPress core's own admin-ajax "install-plugin" action — the
|
||||
// exact same request native's "Install Now" button triggers — rather
|
||||
// than reimplementing plugin installation.
|
||||
const body = new URLSearchParams({
|
||||
action: 'install-plugin',
|
||||
slug: importer.slug,
|
||||
_ajax_nonce: props.serverData.installNonce,
|
||||
});
|
||||
const res = await fetch(props.serverData.ajaxUrl, { method: 'POST', body });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!data.success) {
|
||||
throw new Error(data.data?.errorMessage || 'Installation failed.');
|
||||
}
|
||||
|
||||
// Find the freshly-installed plugin's file path (rather than assuming a
|
||||
// naming convention) via Aurora's own plugins list, then activate it
|
||||
// through the same route the Plugins screen uses.
|
||||
const list = await api('/plugins');
|
||||
const installed = (Array.isArray(list) ? list : []).find((p) => p.slug === importer.slug);
|
||||
if (!installed) {
|
||||
throw new Error('Installed, but the plugin could not be found afterward.');
|
||||
}
|
||||
await api('/plugins/activate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file: installed.file }),
|
||||
});
|
||||
goToImporter(importer);
|
||||
} catch (e) {
|
||||
importer.error = e.message || 'Could not install the importer.';
|
||||
} finally {
|
||||
importer.busy = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="import-page">
|
||||
<header class="import-page__header">
|
||||
<p class="import-page__eyebrow">Aurora Admin</p>
|
||||
<h1>Import</h1>
|
||||
<p class="import-page__lead">
|
||||
If you have posts or comments in another system, WordPress can import those into this
|
||||
site. Choose a system to import from below.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="import-page__list">
|
||||
<article v-for="imp in importers" :key="imp.id" class="import-card">
|
||||
<div class="import-card__info">
|
||||
<h3>{{ imp.name }}</h3>
|
||||
<p>{{ imp.description }}</p>
|
||||
<p v-if="imp.error" class="import-card__error">{{ imp.error }}</p>
|
||||
</div>
|
||||
<div class="import-card__action">
|
||||
<a v-if="imp.status === 'active'" class="import-card__btn" @click.prevent="goToImporter(imp)" href="#">
|
||||
Run Importer
|
||||
</a>
|
||||
<button
|
||||
v-else-if="imp.status === 'installed_inactive'"
|
||||
type="button"
|
||||
class="import-card__btn"
|
||||
:disabled="imp.busy"
|
||||
@click="activate(imp)"
|
||||
>
|
||||
{{ imp.busy ? 'Activating…' : 'Run Importer' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="import-card__btn"
|
||||
:disabled="imp.busy"
|
||||
@click="installAndActivate(imp)"
|
||||
>
|
||||
{{ imp.busy ? 'Installing…' : 'Install Now' }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p v-if="!importers.length" class="import-page__empty">No importers are available.</p>
|
||||
</div>
|
||||
|
||||
<p v-if="serverData.canInstallPlugins" class="import-page__more">
|
||||
If the importer you need is not listed,
|
||||
<a :href="serverData.pluginSearchUrl">search the plugin directory</a>
|
||||
to see if an importer is available.
|
||||
</p>
|
||||
|
||||
<!-- Rendered from WordPress's own `import_filters` hook — whatever other
|
||||
active plugins add there. Not Aurora-authored content, only
|
||||
Aurora-styled, so nothing another plugin adds here disappears. -->
|
||||
<div v-if="serverData.nativeFiltersHtml" class="import-page__native" v-html="serverData.nativeFiltersHtml" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.import-page { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
|
||||
.import-page__eyebrow {
|
||||
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
|
||||
font-weight: 800; text-transform: uppercase;
|
||||
}
|
||||
.import-page__header h1 { margin: 0; font-size: 1.45rem; line-height: 1.15; }
|
||||
.import-page__lead {
|
||||
margin: 12px 0 0; max-width: 640px; color: var(--aurora-text-muted);
|
||||
font-size: 0.875rem; line-height: 1.6;
|
||||
}
|
||||
|
||||
.import-page__list { margin-top: 24px; display: flex; flex-direction: column; gap: 10px; max-width: 720px; }
|
||||
.import-card {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 20px;
|
||||
background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
|
||||
border-radius: var(--aurora-radius-lg); padding: 16px 20px;
|
||||
}
|
||||
.import-card__info h3 { margin: 0 0 4px; font-size: 0.9375rem; font-weight: 700; }
|
||||
.import-card__info p { margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.5; }
|
||||
.import-card__error { color: #e5484d !important; margin-top: 6px !important; }
|
||||
.import-card__action { flex-shrink: 0; }
|
||||
.import-card__btn {
|
||||
display: inline-block; border: none; border-radius: var(--aurora-radius-sm); padding: 8px 16px;
|
||||
font-size: 0.8125rem; font-weight: 600; cursor: pointer; background: var(--aurora-accent);
|
||||
color: #fff; text-decoration: none; white-space: nowrap;
|
||||
}
|
||||
.import-card__btn:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
.import-page__empty { color: var(--aurora-text-muted); font-size: 0.875rem; }
|
||||
.import-page__more { margin-top: 18px; font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||
.import-page__more a { color: var(--aurora-accent); }
|
||||
|
||||
.import-page__native { margin-top: 18px; }
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup>
|
||||
import { reactive, onMounted } from 'vue';
|
||||
import { companionPlugins } from './companionPlugins.js';
|
||||
|
||||
const props = defineProps({
|
||||
serverData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const api = (path, options = {}) =>
|
||||
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': props.serverData.restNonce,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
}).then(async (r) => {
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.message || 'Request failed');
|
||||
return data;
|
||||
});
|
||||
|
||||
// Per-plugin UI state, keyed by slug: { status: {installed,active}|null, busy, error }
|
||||
const state = reactive(
|
||||
Object.fromEntries(companionPlugins.map((p) => [p.slug, { status: null, busy: false, error: '' }]))
|
||||
);
|
||||
|
||||
const loadStatus = async (plugin) => {
|
||||
try {
|
||||
state[plugin.slug].status = await api(plugin.statusEndpoint);
|
||||
} catch {
|
||||
state[plugin.slug].status = { installed: false, active: false };
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
companionPlugins.forEach(loadStatus);
|
||||
});
|
||||
|
||||
const install = async (plugin) => {
|
||||
const s = state[plugin.slug];
|
||||
if (s.busy) return;
|
||||
s.busy = true;
|
||||
s.error = '';
|
||||
try {
|
||||
const result = await api(plugin.installEndpoint, { method: 'POST' });
|
||||
if (!result.success) {
|
||||
s.error = result.message || 'Installation failed.';
|
||||
}
|
||||
await loadStatus(plugin);
|
||||
} catch (e) {
|
||||
s.error = e.message || 'Installation failed.';
|
||||
} finally {
|
||||
s.busy = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="modules">
|
||||
<header class="modules__head">
|
||||
<p class="modules__eyebrow">Aurora Admin</p>
|
||||
<h1>Modules</h1>
|
||||
<p class="modules__lead">
|
||||
Install additional modules to extend Aurora Admin. Each is a separate, standalone plugin —
|
||||
install and activate it here with one click.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="modules__grid">
|
||||
<article v-for="p in companionPlugins" :key="p.slug" class="module-card">
|
||||
<div class="module-card__top">
|
||||
<span class="module-card__icon dashicons" :class="p.icon" />
|
||||
<span
|
||||
v-if="state[p.slug].status"
|
||||
class="module-card__badge"
|
||||
:class="{ 'is-active': state[p.slug].status.active }"
|
||||
>
|
||||
{{ state[p.slug].status.active ? 'Active' : state[p.slug].status.installed ? 'Installed' : 'Not installed' }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="module-card__title">{{ p.label }}</h3>
|
||||
<p class="module-card__desc">{{ p.description }}</p>
|
||||
|
||||
<div class="module-card__action">
|
||||
<div v-if="!state[p.slug].status" class="module-card__checking">Checking…</div>
|
||||
<a v-else-if="state[p.slug].status.active" class="module-card__manage" :href="p.manageUrl">
|
||||
Open {{ p.label }} →
|
||||
</a>
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
class="module-card__install"
|
||||
:disabled="state[p.slug].busy"
|
||||
@click="install(p)"
|
||||
>
|
||||
{{ state[p.slug].busy ? 'Installing…' : state[p.slug].status.installed ? 'Activate' : 'Install & Activate' }}
|
||||
</button>
|
||||
<p v-if="state[p.slug].error" class="module-card__error">{{ state[p.slug].error }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modules { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
|
||||
.modules__eyebrow {
|
||||
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
|
||||
font-weight: 800; text-transform: uppercase;
|
||||
}
|
||||
.modules__head h1 { margin: 0; font-size: 1.45rem; line-height: 1.15; }
|
||||
.modules__lead { margin: 8px 0 0; color: var(--aurora-text-muted); font-size: 0.9rem; max-width: 640px; line-height: 1.6; }
|
||||
.modules__grid {
|
||||
margin-top: 24px; display: grid; gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
.module-card {
|
||||
background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
|
||||
border-radius: var(--aurora-radius-lg); padding: 20px 22px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.module-card__top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.module-card__icon { font-size: 26px; width: 26px; height: 26px; color: var(--aurora-accent); }
|
||||
.module-card__badge {
|
||||
font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em;
|
||||
color: var(--aurora-text-muted); background: var(--aurora-bg-subtle);
|
||||
border-radius: 999px; padding: 3px 10px;
|
||||
}
|
||||
.module-card__badge.is-active { color: var(--aurora-accent); }
|
||||
.module-card__title { margin: 4px 0 0; font-size: 1rem; font-weight: 700; }
|
||||
.module-card__desc { margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.55; flex: 1; }
|
||||
.module-card__action { margin-top: 6px; }
|
||||
.module-card__checking { font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||
.module-card__manage { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-accent); text-decoration: none; }
|
||||
.module-card__manage:hover { text-decoration: underline; }
|
||||
.module-card__install {
|
||||
border: none; border-radius: var(--aurora-radius-sm); padding: 9px 16px; font-size: 0.8125rem;
|
||||
font-weight: 600; cursor: pointer; background: var(--aurora-accent); color: #fff;
|
||||
}
|
||||
.module-card__install:disabled { opacity: 0.6; cursor: default; }
|
||||
.module-card__error { margin: 8px 0 0; font-size: 0.8125rem; color: #e5484d; }
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Aurora's standalone companion plugins, installable from the Modules page.
|
||||
* Single source of truth for both the card metadata and the REST endpoints
|
||||
* that back them (admin/src/Rest/CompanionPluginData.php) — a new companion
|
||||
* plugin only needs an entry here plus a matching PHP-side entry, no other
|
||||
* wiring.
|
||||
*/
|
||||
export const companionPlugins = [
|
||||
{
|
||||
slug: 'file-manager',
|
||||
icon: 'dashicons-portfolio',
|
||||
label: 'Aurora File Manager',
|
||||
description:
|
||||
'Browse, edit, and manage your site’s files (themes, plugins, uploads, and more) directly from wp-admin.',
|
||||
statusEndpoint: '/companion-plugins/file-manager/status',
|
||||
installEndpoint: '/companion-plugins/file-manager/install',
|
||||
manageUrl: 'admin.php?page=aurora-file-manager',
|
||||
},
|
||||
{
|
||||
slug: 'database-explorer',
|
||||
icon: 'dashicons-editor-table',
|
||||
label: 'Aurora Database Explorer',
|
||||
description:
|
||||
'A Supabase-style database browser inside wp-admin — see every table with row counts and sizes, browse paginated data, and run your own read-only queries.',
|
||||
statusEndpoint: '/companion-plugins/database-explorer/status',
|
||||
installEndpoint: '/companion-plugins/database-explorer/install',
|
||||
manageUrl: 'admin.php?page=aurora-database-explorer',
|
||||
},
|
||||
{
|
||||
slug: 'site-backup',
|
||||
icon: 'dashicons-migrate',
|
||||
label: 'Aurora Site Backup',
|
||||
description:
|
||||
'Full site backup (database + files) and restore-in-place, on demand or on a recurring schedule, stored locally or on your own S3-compatible or Google Drive remote storage.',
|
||||
statusEndpoint: '/companion-plugins/site-backup/status',
|
||||
installEndpoint: '/companion-plugins/site-backup/install',
|
||||
manageUrl: 'admin.php?page=aurora-site-backup',
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.';
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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);
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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]',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user