Initial commit: Aurora Admin plugin

This commit is contained in:
Kris Parish
2026-07-03 02:53:29 -05:00
commit 03cabe2fe4
73 changed files with 9591 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
<script setup>
defineProps({
icon: { type: String, default: '' },
title: { type: String, required: true },
subtitle: { type: String, default: '' },
active: { type: Boolean, default: false },
});
defineEmits(['click']);
</script>
<template>
<button
type="button"
class="app-list-item"
:class="{ 'app-list-item--active': active }"
@click="$emit('click')"
>
<span v-if="icon" class="app-list-item__icon">
<span class="dashicons" :class="icon" />
</span>
<span class="app-list-item__text">
<span class="app-list-item__title">{{ title }}</span>
<span v-if="subtitle" class="app-list-item__subtitle">{{ subtitle }}</span>
</span>
<slot name="trailing" />
</button>
</template>
<style scoped>
.app-list-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px;
border: none;
background: none;
cursor: pointer;
border-radius: 12px;
text-align: left;
}
.app-list-item:hover {
background: var(--aurora-bg-subtle);
}
.app-list-item--active {
background: var(--aurora-accent-soft);
}
.app-list-item__icon {
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: 9px;
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
display: flex;
align-items: center;
justify-content: center;
}
.app-list-item--active .app-list-item__icon {
color: var(--aurora-accent);
}
.app-list-item__text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.app-list-item__title {
font-size: 0.875rem;
font-weight: 600;
color: var(--aurora-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-list-item--active .app-list-item__title {
color: var(--aurora-accent);
}
.app-list-item__subtitle {
font-size: 0.75rem;
color: var(--aurora-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+178
View File
@@ -0,0 +1,178 @@
<script setup>
defineProps({
title: { type: String, required: true },
searchPlaceholder: { type: String, default: 'Search…' },
countLabel: { type: String, default: '' },
page: { type: Number, default: 1 },
hasPrev: { type: Boolean, default: false },
hasNext: { type: Boolean, default: false },
// Right-panel placeholder, shown until something is selected.
detailIcon: { type: String, default: 'dashicons-admin-generic' },
detailTitle: { type: String, default: '' },
detailText: { type: String, default: '' },
hasSelection: { type: Boolean, default: false },
});
defineEmits(['prev-page', 'next-page']);
const search = defineModel('search', { type: String, default: '' });
</script>
<template>
<div class="app-list-shell">
<!-- Left: list panel -->
<aside class="app-list-shell__nav">
<div class="app-list-shell__nav-head">
<h1 class="app-list-shell__title">{{ title }}</h1>
<div class="app-list-shell__actions">
<slot name="header-actions" />
</div>
</div>
<div class="app-list-shell__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="text" :placeholder="searchPlaceholder" />
</div>
<div v-if="$slots.filters" class="app-list-shell__filters">
<slot name="filters" />
</div>
<div v-if="countLabel || hasPrev || hasNext" class="app-list-shell__meta">
<span>{{ countLabel }}</span>
<span v-if="hasPrev || hasNext" class="app-list-shell__pager">
Page {{ page }}
<button type="button" :disabled="!hasPrev" @click="$emit('prev-page')">‹</button>
<button type="button" :disabled="!hasNext" @click="$emit('next-page')">›</button>
</span>
</div>
<div class="app-list-shell__list">
<slot name="list" />
</div>
</aside>
<!-- Right: detail panel -->
<section class="app-list-shell__content">
<slot v-if="hasSelection" name="detail" />
<div v-else class="app-list-shell__empty">
<span class="app-list-shell__empty-icon">
<span class="dashicons" :class="detailIcon" />
</span>
<h2 class="app-list-shell__empty-title">{{ detailTitle }}</h2>
<p class="app-list-shell__empty-text">{{ detailText }}</p>
<slot name="detail-empty-actions" />
</div>
</section>
</div>
</template>
<style scoped>
.app-list-shell {
display: flex;
align-items: flex-start;
gap: 20px;
min-height: calc(100vh - var(--aurora-toolbar-h) - 60px);
margin: -32px;
padding: 22px;
box-sizing: border-box;
background: var(--aurora-app-bg);
}
/* Left — list box */
.app-list-shell__nav {
width: 340px;
flex-shrink: 0;
background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border);
border-radius: 16px;
padding: 24px 20px;
box-sizing: border-box;
}
.app-list-shell__nav-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 18px;
}
.app-list-shell__title { font-size: 1.35rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
.app-list-shell__actions { display: flex; gap: 6px; align-items: center; }
.app-list-shell__actions :deep(button) {
display: inline-flex; align-items: center; justify-content: center;
height: 30px; padding: 0 10px; border: none; border-radius: 7px;
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
font-size: 0.75rem; font-weight: 500; cursor: pointer; gap: 6px;
}
.app-list-shell__actions :deep(button.is-primary) {
background: var(--aurora-accent); color: #fff;
}
.app-list-shell__actions :deep(button):hover { opacity: 0.85; }
.app-list-shell__search {
display: flex; align-items: center; gap: 8px;
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
border-radius: 10px; padding: 8px 12px; margin-bottom: 14px;
color: var(--aurora-text-muted);
}
.app-list-shell__search input {
border: none; background: none; outline: none; color: var(--aurora-text);
font-size: 0.8125rem; width: 100%;
}
.app-list-shell__filters {
display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap;
}
.app-list-shell__filters :deep(button) {
border: none; border-radius: 999px; padding: 5px 12px;
font-size: 0.75rem; font-weight: 500; cursor: pointer;
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
}
.app-list-shell__filters :deep(button.is-active) {
background: var(--aurora-text); color: var(--aurora-app-bg);
}
.app-list-shell__meta {
display: flex; align-items: center; justify-content: space-between;
font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.03em;
text-transform: uppercase; color: var(--aurora-text-muted);
margin-bottom: 8px; padding: 0 2px;
}
.app-list-shell__pager { display: flex; align-items: center; gap: 4px; text-transform: none; }
.app-list-shell__pager button {
border: none; background: none; color: var(--aurora-text-muted);
cursor: pointer; font-size: 0.9rem; padding: 0 2px;
}
.app-list-shell__pager button:disabled { opacity: 0.3; cursor: default; }
.app-list-shell__list { display: flex; flex-direction: column; gap: 2px; }
/* Right — content box */
.app-list-shell__content {
flex: 1; min-width: 0;
background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border);
border-radius: 16px;
padding: 28px 32px;
min-height: 480px;
box-sizing: border-box;
}
.app-list-shell__empty {
height: 100%;
min-height: 420px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; gap: 6px;
}
.app-list-shell__empty-icon {
width: 56px; height: 56px; border-radius: 14px;
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
display: flex; align-items: center; justify-content: center;
font-size: 22px; margin-bottom: 10px;
}
.app-list-shell__empty-title { font-size: 1.05rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
.app-list-shell__empty-text { font-size: 0.8125rem; color: var(--aurora-text-muted); max-width: 320px; margin: 0; }
@media (max-width: 1000px) {
.app-list-shell { flex-direction: column; }
.app-list-shell__nav { width: auto; }
}
</style>
+153
View File
@@ -0,0 +1,153 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import AppListShell from '../_shared/AppListShell.vue';
import AppListItem from '../_shared/AppListItem.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
headers: { 'X-WP-Nonce': props.serverData.restNonce },
}).then((r) => {
if (!r.ok) throw new Error('Request failed');
return r.json();
});
const ACTION_TYPES = [
['', 'All Actions'],
['post_created', 'Post created'],
['post_updated', 'Post updated'],
['post_deleted', 'Post deleted'],
['user_registered', 'User registered'],
['user_updated', 'User updated'],
['login', 'Login'],
['plugin_activated', 'Plugin activated'],
['plugin_deactivated', 'Plugin deactivated'],
['theme_switched', 'Theme switched'],
['settings_updated', 'Settings updated'],
];
const OBJECT_TYPES = [
['', 'All Types'],
['post', 'Post'],
['user', 'User'],
['plugin', 'Plugin'],
['theme', 'Theme'],
['settings', 'Settings'],
];
const items = ref([]);
const loading = ref(true);
const total = ref(0);
const page = ref(1);
const totalPages = ref(1);
const actionFilter = ref('');
const objectFilter = ref('');
const search = ref('');
const selectedId = ref(null);
const load = async () => {
loading.value = true;
try {
const params = new URLSearchParams();
if (actionFilter.value) params.set('action_type', actionFilter.value);
if (objectFilter.value) params.set('object_type', objectFilter.value);
if (search.value.trim()) params.set('search', search.value.trim());
params.set('page', page.value);
const data = await api(`/activity-log?${params.toString()}`);
items.value = data.items;
total.value = data.total;
totalPages.value = data.totalPages;
} finally {
loading.value = false;
}
};
onMounted(load);
watch([actionFilter, objectFilter, search], () => {
page.value = 1;
load();
});
watch(page, load);
const selected = computed(() => items.value.find((i) => i.id === selectedId.value) || null);
const formatDate = (s) => (s ? new Date(s.replace(' ', 'T')).toLocaleString() : '');
</script>
<template>
<AppListShell
v-model:search="search"
title="Activity Log"
search-placeholder="Search activities…"
:count-label="`${total} ITEMS`"
:page="page"
:has-prev="page > 1"
:has-next="page < totalPages"
detail-icon="dashicons-clock"
detail-title="Activity Details"
detail-text="Select an activity from the list to view detailed information."
:has-selection="!!selected"
@prev-page="page--"
@next-page="page++"
>
<template #header-actions>
<button type="button" @click="load">
<span class="dashicons dashicons-update" />
</button>
</template>
<template #filters>
<select v-model="actionFilter" class="activity-log__select">
<option v-for="[v, l] in ACTION_TYPES" :key="v" :value="v">{{ l }}</option>
</select>
<select v-model="objectFilter" class="activity-log__select">
<option v-for="[v, l] in OBJECT_TYPES" :key="v" :value="v">{{ l }}</option>
</select>
</template>
<template #list>
<p v-if="!loading && !items.length" style="font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 20px 4px; text-align: center;">
No activities found
</p>
<AppListItem
v-for="i in items"
:key="i.id"
icon="dashicons-clock"
:title="i.objectLabel || i.actionType"
:subtitle="`${i.userName || 'System'} · ${formatDate(i.createdAt)}`"
:active="i.id === selectedId"
@click="selectedId = i.id"
/>
</template>
<template #detail>
<div v-if="selected" class="activity-detail">
<h2>{{ selected.objectLabel || selected.actionType }}</h2>
<dl>
<dt>Action</dt><dd>{{ selected.actionType }}</dd>
<dt>Object type</dt><dd>{{ selected.objectType }}</dd>
<dt>User</dt><dd>{{ selected.userName || 'System' }}</dd>
<dt>Date</dt><dd>{{ formatDate(selected.createdAt) }}</dd>
</dl>
<pre v-if="selected.details">{{ JSON.stringify(selected.details, null, 2) }}</pre>
</div>
</template>
</AppListShell>
</template>
<style scoped>
.activity-log__select {
padding: 5px 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.75rem;
}
.activity-detail h2 { margin: 0 0 16px; font-size: 1.1rem; color: var(--aurora-text); }
.activity-detail dl { display: grid; grid-template-columns: 120px 1fr; gap: 8px 12px; font-size: 0.8125rem; margin: 0; }
.activity-detail dt { color: var(--aurora-text-muted); }
.activity-detail dd { margin: 0; color: var(--aurora-text); }
.activity-detail pre {
margin-top: 18px; padding: 12px; background: var(--aurora-bg-subtle);
border-radius: 8px; font-size: 0.75rem; overflow-x: auto; color: var(--aurora-text);
}
</style>
@@ -0,0 +1,217 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import AppListShell from '../_shared/AppListShell.vue';
import AppListItem from '../_shared/AppListItem.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path, opts = {}) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(opts.headers || {}),
},
}).then((r) => {
if (!r.ok) throw new Error('Request failed');
return r.json();
});
const notices = ref([]);
const loading = ref(true);
const search = ref('');
const selectedId = ref(null);
const isCreating = ref(false);
const roleOptions = ref([]);
const load = async () => {
loading.value = true;
try {
notices.value = await api('/notices');
} finally {
loading.value = false;
}
};
onMounted(() => {
load();
roleOptions.value = props.serverData.roles || [];
});
const filtered = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return notices.value;
return notices.value.filter((n) => n.title.toLowerCase().includes(q));
});
const selected = computed(() => notices.value.find((n) => n.id === selectedId.value) || null);
const draft = reactive({
title: '', message: '', type: 'info', roles: [], pages: '', dismissible: true, active: true,
});
const selectNotice = (n) => {
selectedId.value = n.id;
isCreating.value = false;
Object.assign(draft, {
title: n.title, message: n.message, type: n.type, roles: [...n.roles],
pages: n.pages.join(', '), dismissible: n.dismissible, active: n.active,
});
};
const newNotice = () => {
selectedId.value = null;
isCreating.value = true;
Object.assign(draft, { title: 'New notice', message: '', type: 'info', roles: [], pages: '', dismissible: true, active: true });
};
const toggleRole = (slug) => {
const idx = draft.roles.indexOf(slug);
if (idx === -1) draft.roles.push(slug);
else draft.roles.splice(idx, 1);
};
const saving = ref(false);
const save = async () => {
saving.value = true;
try {
const payload = {
title: draft.title,
message: draft.message,
type: draft.type,
roles: draft.roles,
pages: draft.pages.split(',').map((p) => p.trim()).filter(Boolean),
dismissible: draft.dismissible,
active: draft.active,
};
if (isCreating.value) {
const created = await api('/notices', { method: 'POST', body: JSON.stringify(payload) });
await load();
selectNotice(created);
} else if (selectedId.value) {
await api(`/notices/${selectedId.value}`, { method: 'PUT', body: JSON.stringify(payload) });
await load();
}
} finally {
saving.value = false;
}
};
const remove = async () => {
if (!selectedId.value) return;
await api(`/notices/${selectedId.value}`, { method: 'DELETE' });
selectedId.value = null;
await load();
};
</script>
<template>
<AppListShell
v-model:search="search"
title="Admin Notices"
search-placeholder="Search notices…"
detail-icon="dashicons-megaphone"
detail-title="Notice Editor"
detail-text="Select a notice from the list to edit it, or create a new one to get started."
:has-selection="!!selected || isCreating"
>
<template #header-actions>
<button type="button" @click="newNotice">+</button>
</template>
<template #detail-empty-actions>
<button type="button" class="is-primary" style="margin-top: 14px;" @click="newNotice">
Create Your First Notice
</button>
</template>
<template #list>
<AppListItem
v-for="n in filtered"
:key="n.id"
icon="dashicons-megaphone"
:title="n.title"
:subtitle="n.active ? n.type : `${n.type} (inactive)`"
:active="n.id === selectedId"
@click="selectNotice(n)"
/>
</template>
<template #detail>
<div class="notice-editor">
<div class="notice-editor__row">
<input v-model="draft.title" type="text" placeholder="Notice title" class="notice-editor__title" />
<select v-model="draft.type">
<option value="info">Info</option>
<option value="success">Success</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
</div>
<textarea v-model="draft.message" placeholder="Message" rows="4" />
<div class="notice-editor__section">
<h3>Target roles (empty = all)</h3>
<div class="notice-editor__roles">
<label v-for="r in roleOptions" :key="r.value" class="notice-editor__role">
<input type="checkbox" :checked="draft.roles.includes(r.value)" @change="toggleRole(r.value)" />
{{ r.label }}
</label>
</div>
</div>
<div class="notice-editor__section">
<h3>Target pages (comma-separated, e.g. index.php, edit.php — empty = all)</h3>
<input v-model="draft.pages" type="text" placeholder="index.php, edit.php" />
</div>
<div class="notice-editor__toggles">
<label><input type="checkbox" v-model="draft.dismissible" /> Dismissible</label>
<label><input type="checkbox" v-model="draft.active" /> Active</label>
</div>
<div class="notice-editor__actions">
<button type="button" class="is-danger" :disabled="!selectedId" @click="remove">Delete</button>
<button type="button" class="is-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</template>
</AppListShell>
</template>
<style scoped>
.notice-editor { display: flex; flex-direction: column; gap: 18px; }
.notice-editor__row { display: flex; gap: 10px; }
.notice-editor__title {
flex: 1; font-size: 1.05rem; font-weight: 700; padding: 8px 10px;
border: 1px solid var(--aurora-border); border-radius: 8px;
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.notice-editor select, .notice-editor input[type='text'], .notice-editor textarea {
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.8125rem;
font-family: inherit; width: 100%; box-sizing: border-box;
}
.notice-editor__section h3 {
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em;
color: var(--aurora-text-muted); margin: 0 0 8px;
}
.notice-editor__roles { display: flex; flex-wrap: wrap; gap: 12px; }
.notice-editor__role { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; color: var(--aurora-text); }
.notice-editor__toggles { display: flex; gap: 20px; font-size: 0.8125rem; color: var(--aurora-text); }
.notice-editor__toggles label { display: flex; align-items: center; gap: 6px; }
.notice-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
.notice-editor__actions button {
border: none; border-radius: 8px; padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.notice-editor__actions .is-primary { background: var(--aurora-accent); color: #fff; }
.notice-editor__actions .is-danger { color: #ef4444; }
.notice-editor__actions button:disabled { opacity: 0.5; cursor: default; }
</style>
+249
View File
@@ -0,0 +1,249 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useDashboardData } from './useDashboardData.js';
import { useAnalyticsData } from './useAnalyticsData.js';
import RecentContent from './cards/RecentContent.vue';
import ScheduledContent from './cards/ScheduledContent.vue';
import RecentComments from './cards/RecentComments.vue';
import UserAnalytics from './cards/UserAnalytics.vue';
import MediaAnalytics from './cards/MediaAnalytics.vue';
import ServerHealth from './cards/ServerHealth.vue';
import PageViews from './cards/PageViews.vue';
import ActiveUsers from './cards/ActiveUsers.vue';
import DeviceUsage from './cards/DeviceUsage.vue';
import Engagement from './cards/Engagement.vue';
import TopList from './cards/TopList.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const { data, loading, load } = useDashboardData(props.serverData);
const { data: analytics, load: loadAnalytics } = useAnalyticsData(props.serverData);
// Date range — default last 7 days, with simple presets.
const today = new Date();
const range = ref([new Date(new Date().setDate(today.getDate() - 7)), new Date()]);
const presetsOpen = ref(false);
const fmtLabel = (d) =>
d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
const reload = (from, to) => {
load(from, to);
loadAnalytics(from, to);
};
const applyPreset = (days) => {
const to = new Date();
const from = new Date();
from.setDate(to.getDate() - days);
range.value = [from, to];
presetsOpen.value = false;
reload(from, to);
};
const activeTab = ref('overview');
onMounted(() => reload(range.value[0], range.value[1]));
</script>
<template>
<div class="aurora-dashboard">
<header class="dash__head">
<div>
<h1 class="dash__title">Dashboard</h1>
<p class="dash__subtitle">Overview of your site activity</p>
</div>
<div class="dash__range">
<button type="button" class="dash__range-btn" @click="presetsOpen = !presetsOpen">
<span class="dash__range-icon">▦</span>
{{ fmtLabel(range[0]) }} — {{ fmtLabel(range[1]) }}
</button>
<div v-if="presetsOpen" class="dash__presets">
<button type="button" @click="applyPreset(7)">Last 7 days</button>
<button type="button" @click="applyPreset(30)">Last 30 days</button>
<button type="button" @click="applyPreset(90)">Last 90 days</button>
</div>
</div>
</header>
<div class="dash__tabs">
<button
type="button"
:class="['dash__tab', { 'dash__tab--active': activeTab === 'overview' }]"
@click="activeTab = 'overview'"
>
Overview
</button>
<button
type="button"
:class="['dash__tab', { 'dash__tab--active': activeTab === 'analytics' }]"
@click="activeTab = 'analytics'"
>
Analytics
</button>
</div>
<div v-if="activeTab === 'overview'" class="dash__grid">
<div class="dash__cell" style="grid-column: span 4">
<RecentContent :data="data ? data.recentContent : {}" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<ScheduledContent :data="data ? data.scheduledContent : {}" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<RecentComments :data="data ? data.recentComments : {}" />
</div>
<div class="dash__cell" style="grid-column: span 6">
<UserAnalytics :data="data ? data.userAnalytics : {}" />
</div>
<div class="dash__cell" style="grid-column: span 6">
<MediaAnalytics :data="data ? data.mediaAnalytics : {}" />
</div>
<div class="dash__cell" style="grid-column: span 6">
<ServerHealth :data="data ? data.serverHealth : {}" />
</div>
</div>
<div v-else class="dash__grid">
<div class="dash__cell" style="grid-column: span 8">
<PageViews :data="analytics ? analytics.pageViews : {}" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<ActiveUsers :count="analytics ? analytics.activeUsers : 0" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<DeviceUsage :items="analytics ? analytics.deviceUsage : []" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<Engagement :bounce="analytics ? analytics.bounce : {}" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<TopList
title="Top Countries"
subtitle="Where visitors are from"
:items="analytics ? analytics.topCountries : []"
empty-text="No location data yet."
/>
</div>
<div class="dash__cell" style="grid-column: span 6">
<TopList
title="Top Pages"
subtitle="Most viewed pages"
:items="analytics ? analytics.topPages : []"
/>
</div>
<div class="dash__cell" style="grid-column: span 6">
<TopList
title="Top Referrers"
subtitle="Where traffic comes from"
:items="analytics ? analytics.topReferrers : []"
/>
</div>
</div>
</div>
</template>
<style scoped>
.aurora-dashboard {
padding: 24px var(--aurora-gutter-r, 44px) 40px 32px;
box-sizing: border-box;
}
.dash__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.dash__title {
margin: 0;
font-size: 1.6rem;
font-weight: 700;
color: var(--aurora-text);
letter-spacing: -0.02em;
}
.dash__subtitle { margin: 4px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
.dash__range { position: relative; }
.dash__range-btn {
display: flex; align-items: center; gap: 8px;
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-card-border);
color: var(--aurora-text);
border-radius: 10px;
padding: 8px 12px;
font-size: 0.8125rem;
cursor: pointer;
}
.dash__range-icon { color: var(--aurora-text-muted); }
.dash__presets {
position: absolute; right: 0; top: calc(100% + 6px);
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-card-border);
border-radius: 10px;
padding: 6px;
z-index: 10;
min-width: 160px;
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
}
.dash__presets button {
display: block; width: 100%; text-align: left;
background: none; border: none; color: var(--aurora-text);
padding: 8px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8125rem;
}
.dash__presets button:hover { background: var(--aurora-bg-subtle); }
.dash__tabs {
display: inline-flex;
gap: 2px;
background: var(--aurora-bg-subtle);
border-radius: 10px;
padding: 3px;
margin-bottom: 20px;
}
.dash__tab {
border: none; background: none; cursor: pointer;
padding: 6px 16px; border-radius: 8px;
font-size: 0.8125rem; font-weight: 500;
color: var(--aurora-text-muted);
}
.dash__tab--active { background: var(--aurora-card-bg); color: var(--aurora-text); }
.dash__grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 18px;
}
.dash__cell { min-width: 0; display: flex; }
.dash__cell > * { width: 100%; }
.dash__analytics-empty {
color: var(--aurora-text-muted);
font-size: 0.875rem;
padding: 40px 0;
text-align: center;
}
@media (max-width: 1100px) {
.dash__cell { grid-column: span 12 !important; }
}
</style>
<style>
/* Shared bits used inside the card slots (head-right badges, footer links).
Scoped under .aurora-dashboard so they can't collide with WordPress or
leak elsewhere, while still reaching slotted content in child cards. */
.aurora-dashboard .dot {
display: inline-block;
width: 7px; height: 7px;
border-radius: 50%;
margin-right: 4px;
vertical-align: middle;
}
.aurora-dashboard .dot--accent { background: var(--aurora-accent); }
.aurora-dashboard .dot--green { background: #34d399; }
.aurora-dashboard .viewall {
font-size: 0.75rem;
color: var(--aurora-text-muted);
text-decoration: none;
}
.aurora-dashboard .viewall:hover { color: var(--aurora-accent); }
</style>
@@ -0,0 +1,35 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
defineProps({ count: { type: Number, default: 0 } });
</script>
<template>
<AuroraCard title="Active Users" subtitle="Visitors in the last 5 minutes">
<div class="au">
<span class="au__pulse" />
<div class="au__num">{{ count }}</div>
<div class="au__label">active now</div>
</div>
</AuroraCard>
</template>
<style scoped>
.au {
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 24px 0; height: 100%;
}
.au__pulse {
width: 10px; height: 10px; border-radius: 50%;
background: #34d399; margin-bottom: 14px;
box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.6);
animation: au-pulse 1.8s infinite;
}
@keyframes au-pulse {
0% { box-shadow: 0 0 0 0 rgba(52,211,153,0.5); }
70% { box-shadow: 0 0 0 12px rgba(52,211,153,0); }
100% { box-shadow: 0 0 0 0 rgba(52,211,153,0); }
}
.au__num { font-size: 2.5rem; font-weight: 700; color: var(--aurora-text); line-height: 1; }
.au__label { margin-top: 6px; font-size: 0.8125rem; color: var(--aurora-text-muted); }
</style>
@@ -0,0 +1,18 @@
<script setup>
import { computed } from 'vue';
import AuroraCard from '../components/AuroraCard.vue';
import BarList from '../components/BarList.vue';
const props = defineProps({ items: { type: Array, default: () => [] } });
const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
const list = computed(() =>
props.items.map((d) => ({ label: `${cap(d.device)} · ${d.pct}%`, count: d.count }))
);
</script>
<template>
<AuroraCard title="Device Usage" subtitle="How visitors browse">
<BarList :items="list" empty-text="No visits in this range yet." />
</AuroraCard>
</template>
@@ -0,0 +1,18 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import StatRow from '../components/StatRow.vue';
defineProps({ bounce: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="Engagement" subtitle="Bounce rate & time on site">
<StatRow
:stats="[
{ value: (bounce.rate || 0) + '%', label: 'Bounce Rate' },
{ value: bounce.avgTime || '0s', label: 'Avg. Time' },
{ value: bounce.sessions || 0, label: 'Sessions' },
]"
/>
</AuroraCard>
</template>
@@ -0,0 +1,61 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
defineProps({ data: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="Media Analytics" subtitle="Media library overview">
<div class="ma__top">
<div class="ma__big">
<div class="ma__big-num">{{ data.totalFiles || 0 }}</div>
<div class="ma__big-label">Total Files</div>
</div>
<div class="ma__big">
<div class="ma__big-num">{{ data.totalSize || '0 B' }}</div>
<div class="ma__big-label">Total Size</div>
</div>
</div>
<div class="ma__grid">
<div class="ma__box">
<div class="ma__box-num">{{ data.recent30d || 0 }}</div>
<div class="ma__box-label">Recent (30d)</div>
</div>
<div class="ma__box">
<div class="ma__box-num">{{ data.unused || 0 }}</div>
<div class="ma__box-label">Unused</div>
</div>
<div class="ma__box">
<div class="ma__box-num">{{ data.largeFiles || 0 }}</div>
<div class="ma__box-label">Large Files</div>
</div>
<div class="ma__box">
<div class="ma__box-num">{{ data.diskUsage || '0 B' }}</div>
<div class="ma__box-label">Disk Usage</div>
</div>
</div>
<template #footer>
<a :href="data.manageUrl" class="viewall">Manage Media ›</a>
</template>
</AuroraCard>
</template>
<style scoped>
.ma__top {
display: grid; grid-template-columns: 1fr 1fr; text-align: center;
margin-bottom: 18px;
}
.ma__big-num { font-size: 1.75rem; font-weight: 700; color: var(--aurora-text); }
.ma__big-label { margin-top: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); }
.ma__grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.ma__box {
background: var(--aurora-bg-subtle);
border-radius: 14px;
padding: 14px;
text-align: center;
}
.ma__box-num { font-size: 1.25rem; font-weight: 700; color: var(--aurora-text); }
.ma__box-label { margin-top: 2px; font-size: 0.6875rem; color: var(--aurora-text-muted); }
</style>
@@ -0,0 +1,26 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import StatRow from '../components/StatRow.vue';
import AreaChart from '../components/AreaChart.vue';
defineProps({ data: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="Page Views" subtitle="Traffic over the selected range">
<StatRow
:boxed="false"
:stats="[
{ value: data.total || 0, label: 'Total Views' },
{ value: data.unique || 0, label: 'Unique Visitors' },
]"
/>
<div class="pv__chart">
<AreaChart :series="data.series || []" :height="150" />
</div>
</AuroraCard>
</template>
<style scoped>
.pv__chart { margin-top: 20px; }
</style>
@@ -0,0 +1,43 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import StatRow from '../components/StatRow.vue';
defineProps({ data: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="Recent Comments" subtitle="Latest comments and discussions">
<template #head-right>
<span class="dot dot--accent" /> {{ data.total || 0 }} total
</template>
<StatRow
:stats="[
{ value: data.approved || 0, label: 'Approved' },
{ value: data.pending || 0, label: 'Pending' },
{ value: data.spam || 0, label: 'Spam' },
]"
/>
<ul class="cm__list">
<li v-for="c in data.latest" :key="c.id" class="cm__item">
<p class="cm__excerpt">{{ c.excerpt }}</p>
<div class="cm__meta">on <span class="cm__post">{{ c.postTitle }}</span> · {{ c.date }}</div>
</li>
<li v-if="!data.latest || !data.latest.length" class="cm__empty">No comments yet.</li>
</ul>
<template #footer>
<a :href="data.listUrl" class="viewall">View all ›</a>
</template>
</AuroraCard>
</template>
<style scoped>
.cm__list { list-style: none; margin: 16px 0 0; padding: 0; }
.cm__item { padding: 8px 0; }
.cm__excerpt { margin: 0; font-size: 0.8125rem; color: var(--aurora-text); }
.cm__meta { margin-top: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); }
.cm__post { color: var(--aurora-accent); }
.cm__empty { font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 12px 0; }
</style>
@@ -0,0 +1,72 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import StatRow from '../components/StatRow.vue';
const props = defineProps({ data: { type: Object, default: () => ({}) } });
const statusLabel = (s) =>
({ publish: 'Published', draft: 'Draft', future: 'Scheduled' }[s] || s);
</script>
<template>
<AuroraCard title="Recent content" subtitle="Latest content activity">
<template #head-right>
<span class="dot dot--accent" /> {{ data.total || 0 }} total
</template>
<StatRow
:stats="[
{ value: data.published || 0, label: 'Published' },
{ value: data.draft || 0, label: 'Draft' },
{ value: data.scheduled || 0, label: 'Scheduled' },
]"
/>
<ul class="rc__list">
<li v-for="p in data.latest" :key="p.id" class="rc__item">
<div class="rc__main">
<a :href="p.editUrl" class="rc__title">{{ p.title || '(no title)' }}</a>
<div class="rc__meta">{{ p.author }} · {{ p.date }}</div>
</div>
<span class="rc__badge" :class="`rc__badge--${p.status}`">
{{ statusLabel(p.status) }}
</span>
</li>
<li v-if="!data.latest || !data.latest.length" class="rc__empty">No content yet.</li>
</ul>
<template #footer>
<a :href="data.listUrl" class="viewall">View all ›</a>
</template>
</AuroraCard>
</template>
<style scoped>
.rc__list { list-style: none; margin: 16px 0 0; padding: 0; }
.rc__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.rc__title {
color: var(--aurora-text);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
}
.rc__title:hover { color: var(--aurora-accent); }
.rc__meta { font-size: 0.75rem; color: var(--aurora-text-muted); margin-top: 2px; }
.rc__badge {
font-size: 0.6875rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
flex-shrink: 0;
}
.rc__badge--publish { color: #34d399; }
.rc__badge--future { color: var(--aurora-accent); }
.rc__badge--draft { color: var(--aurora-text-muted); }
.rc__empty { font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 12px 0; }
</style>
@@ -0,0 +1,169 @@
<script setup>
import { ref, computed } from 'vue';
import AuroraCard from '../components/AuroraCard.vue';
const props = defineProps({ data: { type: Object, default: () => ({}) } });
const view = ref(new Date());
const monthLabel = computed(() =>
view.value.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
);
const weekdays = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
// Build a calendar grid (Mon-first) for the viewed month.
const grid = computed(() => {
const year = view.value.getFullYear();
const month = view.value.getMonth();
const first = new Date(year, month, 1);
const startOffset = (first.getDay() + 6) % 7; // Mon = 0
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells = [];
for (let i = 0; i < startOffset; i++) cells.push(null);
const today = new Date();
for (let d = 1; d <= daysInMonth; d++) {
const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const scheduled = props.data.days && props.data.days[key] ? props.data.days[key].length : 0;
const isToday =
d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
cells.push({ d, key, scheduled, isToday });
}
return cells;
});
const move = (delta) => {
const next = new Date(view.value);
next.setMonth(next.getMonth() + delta);
view.value = next;
};
// Clicking a day: with scheduled content, open the posts list filtered to
// that day; otherwise show a transient popup on the dashboard.
const toast = ref('');
let toastTimer;
const onDayClick = (cell) => {
if (!cell) return;
if (cell.scheduled > 0) {
// Relative to /wp-admin/ (the dashboard is at index.php).
window.location.href = `edit.php?post_status=future&aurora_day=${cell.key}`;
} else {
toast.value = 'No scheduled content for this day.';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast.value = ''), 3000);
}
};
</script>
<template>
<AuroraCard title="Scheduled Content" :subtitle="monthLabel">
<template #head-right>
<div class="sc__nav">
<button type="button" @click="move(-1)" aria-label="Previous month">‹</button>
<button type="button" @click="move(1)" aria-label="Next month">›</button>
</div>
</template>
<div class="sc__count">
<span class="sc__count-num">{{ data.count || 0 }}</span> Scheduled Content
</div>
<div class="sc__weekdays">
<span v-for="(w, i) in weekdays" :key="i">{{ w }}</span>
</div>
<div class="sc__grid">
<div v-for="(cell, i) in grid" :key="i" class="sc__cell">
<button
v-if="cell"
type="button"
class="sc__day"
:class="{ 'sc__day--has': cell.scheduled > 0, 'sc__day--today': cell.isToday }"
:title="cell.scheduled > 0 ? `${cell.scheduled} scheduled` : 'No scheduled content'"
@click="onDayClick(cell)"
>
<span class="sc__date">{{ cell.d }}</span>
<span v-if="cell.scheduled > 0" class="sc__count">{{ cell.scheduled }}</span>
</button>
</div>
</div>
<Teleport to="body">
<Transition name="sc-toast">
<div v-if="toast" class="sc__toast">{{ toast }}</div>
</Transition>
</Teleport>
</AuroraCard>
</template>
<style scoped>
.sc__nav { display: flex; gap: 4px; }
.sc__nav button {
width: 26px; height: 26px;
border: 1px solid var(--aurora-card-border);
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
border-radius: 8px;
cursor: pointer;
line-height: 1;
}
.sc__nav button:hover { color: var(--aurora-text); }
.sc__count { font-size: 0.875rem; color: var(--aurora-text-muted); margin-bottom: 16px; }
.sc__count-num { font-size: 1.5rem; font-weight: 700; color: var(--aurora-text); margin-right: 6px; }
.sc__weekdays {
display: grid; grid-template-columns: repeat(7, 1fr);
font-size: 0.6875rem; color: var(--aurora-text-muted); margin-bottom: 8px;
}
.sc__weekdays span { text-align: center; }
.sc__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; }
.sc__cell { aspect-ratio: 1; }
.sc__day {
position: relative;
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
border-radius: 8px;
border: 1px solid var(--aurora-card-border);
background: transparent;
padding: 0;
cursor: pointer;
font-family: inherit;
transition: border-color 0.12s ease, background-color 0.12s ease;
}
.sc__day:hover { border-color: var(--aurora-accent); }
.sc__day--has:hover { filter: brightness(1.08); }
.sc__date {
position: absolute; top: 5px; left: 6px;
font-size: 0.7rem; line-height: 1; color: var(--aurora-text-muted);
}
.sc__count {
font-size: 1.05rem; font-weight: 700; color: var(--aurora-text);
}
.sc__day--has { background: var(--aurora-accent); border-color: var(--aurora-accent); }
.sc__day--has .sc__date { color: rgba(255, 255, 255, 0.85); }
.sc__day--has .sc__count { color: #fff; }
.sc__day--today { border-color: var(--aurora-accent); }
.sc__day--today .sc__date { color: var(--aurora-accent); }
</style>
<style>
/* Toast is teleported to <body>, so these rules are global (not scoped).
They rely on the global --aurora-* theme variables. */
.sc__toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 100001;
background: var(--aurora-card-bg, #1f1f23);
color: var(--aurora-text, #f4f4f5);
border: 1px solid var(--aurora-border, #2e2e33);
border-radius: 10px;
padding: 12px 18px;
font-size: 0.875rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
}
.sc-toast-enter-active,
.sc-toast-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.sc-toast-enter-from,
.sc-toast-leave-to { opacity: 0; transform: translate(-50%, 10px); }
</style>
@@ -0,0 +1,71 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import StatRow from '../components/StatRow.vue';
import ProgressBar from '../components/ProgressBar.vue';
defineProps({ data: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="Server Health" subtitle="System performance and updates">
<template #head-right>
<span class="dot dot--green" /> {{ data.updates || 0 }} updates
</template>
<div class="sh__versions">
<div class="sh__ver">
<div class="sh__ver-label">‹› WordPress</div>
<div class="sh__ver-num">{{ data.wp || '—' }}</div>
</div>
<div class="sh__ver">
<div class="sh__ver-label">⚙ PHP</div>
<div class="sh__ver-num">{{ data.php || '—' }}</div>
</div>
</div>
<div class="sh__bars">
<ProgressBar
label="Memory Usage"
icon="◍"
:pct="data.memory ? data.memory.pct : 0"
:detail="data.memory ? `${data.memory.used} / ${data.memory.total}` : ''"
/>
<ProgressBar
label="Disk Space"
icon="▤"
gradient
:pct="data.disk ? data.disk.pct : 0"
:detail="data.disk ? `${data.disk.used} / ${data.disk.total}` : ''"
/>
</div>
<StatRow
:stats="[
{ value: data.plugins || 0, label: 'Plugins' },
{ value: data.themes || 0, label: 'Themes' },
{ value: data.core || 0, label: 'Core' },
]"
/>
<div class="sh__foot">
<span>{{ data.ssl ? '🔒 SSL enabled' : '⚠ SSL disabled' }}</span>
<span>🕓 Timezone: {{ data.timezone || 'UTC' }}</span>
</div>
</AuroraCard>
</template>
<style scoped>
.sh__versions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
.sh__ver {
background: var(--aurora-bg-subtle);
border-radius: 12px;
padding: 12px 14px;
}
.sh__ver-label { font-size: 0.6875rem; color: var(--aurora-text-muted); }
.sh__ver-num { font-size: 1rem; font-weight: 600; color: var(--aurora-text); margin-top: 2px; }
.sh__bars { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; }
.sh__foot {
display: flex; justify-content: space-between;
margin-top: 16px; font-size: 0.6875rem; color: var(--aurora-text-muted);
}
</style>
+17
View File
@@ -0,0 +1,17 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import BarList from '../components/BarList.vue';
defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: '' },
items: { type: Array, default: () => [] },
emptyText: { type: String, default: 'No data in this range yet.' },
});
</script>
<template>
<AuroraCard :title="title" :subtitle="subtitle">
<BarList :items="items" :empty-text="emptyText" />
</AuroraCard>
</template>
@@ -0,0 +1,27 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import StatRow from '../components/StatRow.vue';
import AreaChart from '../components/AreaChart.vue';
defineProps({ data: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="User Analytics" subtitle="User registration trends">
<StatRow
:boxed="false"
:stats="[
{ value: data.total || 0, label: 'Total Users' },
{ value: data.inRange || 0, label: 'In Range' },
{ value: data.recent7d || 0, label: 'Recent (7d)' },
]"
/>
<div class="ua__chart">
<AreaChart :series="data.series || []" :height="150" />
</div>
</AuroraCard>
</template>
<style scoped>
.ua__chart { margin-top: 20px; }
</style>
@@ -0,0 +1,71 @@
<script setup>
import { computed } from 'vue';
// series: [{ date, count }]
const props = defineProps({
series: { type: Array, default: () => [] },
height: { type: Number, default: 140 },
});
const W = 600;
const PAD = 10;
const view = computed(() => {
const s = props.series;
if (!s.length) return null;
const max = Math.max(1, ...s.map((d) => d.count));
const H = props.height;
const innerW = W - PAD * 2;
const innerH = H - PAD * 2;
const stepX = s.length > 1 ? innerW / (s.length - 1) : 0;
const points = s.map((d, i) => {
const x = PAD + i * stepX;
const y = PAD + innerH - (d.count / max) * innerH;
return [x, y];
});
const line = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
const area = `${line} L${points[points.length - 1][0].toFixed(1)},${(H - PAD).toFixed(1)} L${points[0][0].toFixed(1)},${(H - PAD).toFixed(1)} Z`;
return { points, line, area, H };
});
</script>
<template>
<div class="areachart" v-if="view">
<svg :viewBox="`0 0 ${W} ${view.H}`" preserveAspectRatio="none" class="areachart__svg">
<defs>
<linearGradient id="aurora-area-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--aurora-accent)" stop-opacity="0.35" />
<stop offset="100%" stop-color="var(--aurora-accent)" stop-opacity="0" />
</linearGradient>
</defs>
<path :d="view.area" fill="url(#aurora-area-grad)" />
<path :d="view.line" fill="none" stroke="var(--aurora-accent)" stroke-width="2"
vector-effect="non-scaling-stroke" />
<circle v-for="(p, i) in view.points" :key="i" :cx="p[0]" :cy="p[1]" r="3"
fill="var(--aurora-accent)" vector-effect="non-scaling-stroke" />
</svg>
<div class="areachart__labels">
<span v-for="(d, i) in series" :key="i">{{ d.date }}</span>
</div>
</div>
</template>
<style scoped>
.areachart__svg {
width: 100%;
display: block;
}
.areachart__labels {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 0.6875rem;
color: var(--aurora-text-muted);
}
.areachart__labels span {
flex: 1;
text-align: center;
}
.areachart__labels span:first-child { text-align: left; }
.areachart__labels span:last-child { text-align: right; }
</style>
@@ -0,0 +1,71 @@
<script setup>
defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: '' },
});
</script>
<template>
<section class="acard">
<header class="acard__head">
<div class="acard__titles">
<h2 class="acard__title">{{ title }}</h2>
<p v-if="subtitle" class="acard__subtitle">{{ subtitle }}</p>
</div>
<div class="acard__head-right">
<slot name="head-right" />
</div>
</header>
<div class="acard__body">
<slot />
</div>
<footer v-if="$slots.footer" class="acard__foot">
<slot name="footer" />
</footer>
</section>
</template>
<style scoped>
.acard {
display: flex;
flex-direction: column;
height: 100%;
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-card-border);
border-radius: 20px;
padding: 20px;
box-sizing: border-box;
}
.acard__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.acard__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 600;
color: var(--aurora-text);
}
.acard__subtitle {
margin: 2px 0 0;
font-size: 0.8125rem;
color: var(--aurora-text-muted);
}
.acard__head-right {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--aurora-text-muted);
}
.acard__body {
flex: 1;
}
.acard__foot {
margin-top: 16px;
padding-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,44 @@
<script setup>
import { computed } from 'vue';
// items: [{ label, count }]
const props = defineProps({
items: { type: Array, default: () => [] },
emptyText: { type: String, default: 'No data yet.' },
});
const max = computed(() => Math.max(1, ...props.items.map((i) => i.count)));
</script>
<template>
<div class="barlist">
<div v-for="(it, i) in items" :key="i" class="barlist__row">
<div class="barlist__head">
<span class="barlist__label" :title="it.label">{{ it.label }}</span>
<span class="barlist__count">{{ it.count }}</span>
</div>
<div class="barlist__track">
<div class="barlist__fill" :style="{ width: (it.count / max) * 100 + '%' }" />
</div>
</div>
<p v-if="!items.length" class="barlist__empty">{{ emptyText }}</p>
</div>
</template>
<style scoped>
.barlist__row { margin-bottom: 12px; }
.barlist__head {
display: flex; justify-content: space-between; gap: 12px; margin-bottom: 5px;
}
.barlist__label {
font-size: 0.8125rem; color: var(--aurora-text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.barlist__count { font-size: 0.8125rem; color: var(--aurora-text-muted); flex-shrink: 0; }
.barlist__track {
height: 6px; border-radius: 999px;
background: rgba(127,127,127,0.18); overflow: hidden;
}
.barlist__fill { height: 100%; border-radius: 999px; background: var(--aurora-accent); }
.barlist__empty { font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 8px 0; }
</style>
@@ -0,0 +1,75 @@
<script setup>
defineProps({
label: { type: String, required: true },
pct: { type: Number, default: 0 },
detail: { type: String, default: '' },
gradient: { type: Boolean, default: false },
icon: { type: String, default: '' },
});
</script>
<template>
<div class="pbar">
<div class="pbar__top">
<span class="pbar__label">
<span v-if="icon" class="pbar__icon">{{ icon }}</span>{{ label }}
</span>
<span class="pbar__pct">{{ pct }}%</span>
</div>
<div class="pbar__track">
<div
class="pbar__fill"
:class="{ 'pbar__fill--gradient': gradient }"
:style="{ width: Math.min(100, pct) + '%' }"
/>
</div>
<div v-if="detail" class="pbar__detail">{{ detail }}</div>
</div>
</template>
<style scoped>
.pbar {
background: var(--aurora-bg-subtle);
border-radius: 12px;
padding: 12px 14px;
}
.pbar__top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.pbar__label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
font-weight: 500;
color: var(--aurora-text);
}
.pbar__icon { color: var(--aurora-text-muted); }
.pbar__pct {
font-size: 0.75rem;
font-weight: 600;
color: #34d399;
}
.pbar__track {
height: 6px;
border-radius: 999px;
background: rgba(127, 127, 127, 0.18);
overflow: hidden;
}
.pbar__fill {
height: 100%;
border-radius: 999px;
background: var(--aurora-accent);
}
.pbar__fill--gradient {
background: linear-gradient(90deg, #4f7bf5 0%, #a855f7 55%, #ef4444 100%);
}
.pbar__detail {
margin-top: 6px;
font-size: 0.6875rem;
color: var(--aurora-text-muted);
}
</style>
@@ -0,0 +1,41 @@
<script setup>
// stats: [{ value, label }]
defineProps({
stats: { type: Array, required: true },
boxed: { type: Boolean, default: true },
});
</script>
<template>
<div class="statrow" :class="{ 'statrow--boxed': boxed }">
<div v-for="(s, i) in stats" :key="i" class="statrow__item">
<div class="statrow__value">{{ s.value }}</div>
<div class="statrow__label">{{ s.label }}</div>
</div>
</div>
</template>
<style scoped>
.statrow {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
text-align: center;
}
.statrow--boxed {
background: var(--aurora-bg-subtle);
border-radius: 14px;
padding: 16px 8px;
}
.statrow__value {
font-size: 1.5rem;
font-weight: 700;
color: var(--aurora-text);
line-height: 1.1;
}
.statrow__label {
margin-top: 4px;
font-size: 0.75rem;
color: var(--aurora-text-muted);
}
</style>
@@ -0,0 +1,34 @@
import { ref } from 'vue';
/** Fetches aggregated analytics from aurora-admin/v1/analytics for a range. */
export function useAnalyticsData(serverData) {
const data = ref(null);
const loading = ref(false);
const error = ref(false);
const fmt = (d) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
const load = async (from, to) => {
loading.value = true;
error.value = false;
try {
const url = new URL(`${serverData.restUrl}aurora-admin/v1/analytics`);
url.searchParams.set('from', fmt(from));
url.searchParams.set('to', fmt(to));
const res = await fetch(url, { headers: { 'X-WP-Nonce': serverData.restNonce } });
if (!res.ok) throw new Error('Request failed');
data.value = await res.json();
} catch {
error.value = true;
} finally {
loading.value = false;
}
};
return { data, loading, error, load };
}
@@ -0,0 +1,39 @@
import { ref } from 'vue';
/**
* Fetches all dashboard card data from aurora-admin/v1/dashboard for a
* given date range. Re-callable when the range changes.
*/
export function useDashboardData(serverData) {
const data = ref(null);
const loading = ref(false);
const error = ref(false);
const fmt = (d) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
const load = async (from, to) => {
loading.value = true;
error.value = false;
try {
const url = new URL(`${serverData.restUrl}aurora-admin/v1/dashboard`);
url.searchParams.set('from', fmt(from));
url.searchParams.set('to', fmt(to));
const res = await fetch(url, {
headers: { 'X-WP-Nonce': serverData.restNonce },
});
if (!res.ok) throw new Error('Request failed');
data.value = await res.json();
} catch {
error.value = true;
} finally {
loading.value = false;
}
};
return { data, loading, error, load };
}
@@ -0,0 +1,202 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import AppListShell from '../_shared/AppListShell.vue';
import AppListItem from '../_shared/AppListItem.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const restBase = `${props.serverData.restUrl}aurora-admin/v1`;
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce };
const api = (path, opts = {}) =>
fetch(`${restBase}${path}`, { ...opts, headers }).then(async (r) => {
const body = await r.json();
if (!r.ok) throw new Error(body?.message || 'Request failed');
return body;
});
const tables = ref([]);
const search = ref('');
const selectedTable = ref(null);
const tableData = ref(null);
const tablePage = ref(1);
const loadingTable = ref(false);
const sqlMode = ref(false);
const sqlQuery = ref('SELECT * FROM ' + (props.serverData.tablePrefix || 'wp_') + 'options LIMIT 10');
const sqlResult = ref(null);
const sqlError = ref('');
const sqlRunning = ref(false);
onMounted(async () => {
tables.value = await api('/db-tables');
});
const filtered = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return tables.value;
return tables.value.filter((t) => t.name.toLowerCase().includes(q));
});
const formatSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
};
const loadTable = async (name, page = 1) => {
sqlMode.value = false;
selectedTable.value = name;
tablePage.value = page;
loadingTable.value = true;
try {
tableData.value = await api(`/db-tables/${encodeURIComponent(name)}?page=${page}`);
} finally {
loadingTable.value = false;
}
};
watch(tablePage, (p) => {
if (selectedTable.value) loadTable(selectedTable.value, p);
});
const openSqlEditor = () => {
sqlMode.value = true;
selectedTable.value = null;
};
const runQuery = async () => {
sqlRunning.value = true;
sqlError.value = '';
sqlResult.value = null;
try {
sqlResult.value = await api('/db-query', { method: 'POST', body: JSON.stringify({ query: sqlQuery.value }) });
} catch (e) {
sqlError.value = e.message;
} finally {
sqlRunning.value = false;
}
};
const hasSelection = computed(() => !!selectedTable.value || sqlMode.value);
</script>
<template>
<AppListShell
v-model:search="search"
title="Database Explorer"
search-placeholder="Search tables…"
:count-label="`${filtered.length} TABLES`"
detail-icon="dashicons-database"
detail-title="Table Details"
detail-text="Select a table from the list to view its structure and data."
:has-selection="hasSelection"
>
<template #header-actions>
<button type="button" @click="openSqlEditor">
<span class="dashicons dashicons-editor-code" /> SQL
</button>
</template>
<template #detail-empty-actions>
<button type="button" class="is-primary" style="margin-top: 14px;" @click="openSqlEditor">
Open SQL Editor
</button>
</template>
<template #list>
<AppListItem
v-for="t in filtered"
:key="t.name"
icon="dashicons-database"
:title="t.name"
:subtitle="`${t.rows} rows · ${formatSize(t.sizeBytes)}`"
:active="t.name === selectedTable"
@click="loadTable(t.name)"
/>
</template>
<template #detail>
<div v-if="sqlMode" class="db-sql">
<h2>SQL Editor</h2>
<p class="db-sql__hint">Only SELECT / SHOW / DESCRIBE / EXPLAIN queries are allowed.</p>
<textarea v-model="sqlQuery" rows="4" spellcheck="false" />
<div class="db-sql__actions">
<button type="button" class="is-primary" :disabled="sqlRunning" @click="runQuery">
{{ sqlRunning ? 'Running…' : 'Run query' }}
</button>
</div>
<p v-if="sqlError" class="db-sql__error">{{ sqlError }}</p>
<div v-if="sqlResult" class="db-table-wrap">
<p class="db-table__count">{{ sqlResult.count }} rows</p>
<table v-if="sqlResult.rows.length" class="db-table">
<thead>
<tr><th v-for="k in Object.keys(sqlResult.rows[0])" :key="k">{{ k }}</th></tr>
</thead>
<tbody>
<tr v-for="(row, i) in sqlResult.rows" :key="i">
<td v-for="(v, k) in row" :key="k">{{ v }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="selectedTable" class="db-table-detail">
<h2>{{ selectedTable }}</h2>
<div v-if="loadingTable">Loading…</div>
<template v-else-if="tableData">
<div class="db-table-wrap">
<table class="db-table">
<thead>
<tr><th v-for="c in tableData.columns" :key="c.name">{{ c.name }}</th></tr>
</thead>
<tbody>
<tr v-for="(row, i) in tableData.rows" :key="i">
<td v-for="c in tableData.columns" :key="c.name">{{ row[c.name] }}</td>
</tr>
</tbody>
</table>
</div>
<div class="db-table__pager">
<span>Page {{ tableData.page }} of {{ tableData.totalPages || 1 }} · {{ tableData.total }} rows</span>
<button type="button" :disabled="tablePage <= 1" @click="tablePage--">‹</button>
<button type="button" :disabled="tablePage >= tableData.totalPages" @click="tablePage++">›</button>
</div>
</template>
</div>
</template>
</AppListShell>
</template>
<style scoped>
.db-sql h2, .db-table-detail h2 { margin: 0 0 10px; font-size: 1.1rem; color: var(--aurora-text); }
.db-sql__hint { font-size: 0.75rem; color: var(--aurora-text-muted); margin: 0 0 10px; }
.db-sql textarea {
width: 100%; box-sizing: border-box; font-family: monospace; font-size: 0.8125rem;
padding: 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.db-sql__actions { display: flex; justify-content: flex-end; margin: 10px 0; }
.db-sql__actions button, .db-table__pager button {
border: none; border-radius: 8px; padding: 8px 16px; font-size: 0.8125rem; cursor: pointer;
background: var(--aurora-accent); color: #fff;
}
.db-sql__actions button:disabled { opacity: 0.5; cursor: default; }
.db-sql__error { color: #ef4444; font-size: 0.8125rem; }
.db-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; border: 1px solid var(--aurora-frame-border); border-radius: 8px; }
.db-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; }
.db-table th, .db-table td {
padding: 6px 10px; text-align: left; white-space: nowrap;
border-bottom: 1px solid var(--aurora-frame-border); color: var(--aurora-text);
}
.db-table th { background: var(--aurora-bg-subtle); position: sticky; top: 0; }
.db-table__count { font-size: 0.75rem; color: var(--aurora-text-muted); margin: 8px 0; }
.db-table__pager {
display: flex; align-items: center; gap: 8px; margin-top: 12px;
font-size: 0.75rem; color: var(--aurora-text-muted);
}
.db-table__pager button { background: var(--aurora-bg-subtle); color: var(--aurora-text); padding: 4px 10px; }
</style>
+259
View File
@@ -0,0 +1,259 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import AppListShell from '../_shared/AppListShell.vue';
import AppListItem from '../_shared/AppListItem.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path, opts = {}) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(opts.headers || {}),
},
}).then((r) => {
if (!r.ok) throw new Error('Request failed');
return r.json();
});
const menus = ref([]);
const nativeItems = ref([]);
const loading = ref(true);
const search = ref('');
const statusFilter = ref('all');
const selectedId = ref(null);
const roleOptions = ref([]);
const load = async () => {
loading.value = true;
try {
menus.value = await api('/menus');
} finally {
loading.value = false;
}
};
onMounted(async () => {
load();
api('/menus/native-items').then((items) => (nativeItems.value = items));
roleOptions.value = props.serverData.roles || [];
});
const filteredMenus = computed(() => {
let list = menus.value;
if (statusFilter.value !== 'all') {
list = list.filter((m) => m.status === statusFilter.value);
}
const q = search.value.trim().toLowerCase();
if (q) list = list.filter((m) => m.name.toLowerCase().includes(q));
return list;
});
const selected = computed(() => menus.value.find((m) => m.id === selectedId.value) || null);
const draft = reactive({ name: '', status: 'draft', roles: [], items: [] });
const selectMenu = (menu) => {
selectedId.value = menu.id;
Object.assign(draft, {
name: menu.name,
status: menu.status,
roles: [...menu.roles],
items: menu.items.map((i) => ({ ...i })),
});
};
const isCreating = ref(false);
const newMenu = () => {
selectedId.value = null;
isCreating.value = true;
Object.assign(draft, { name: 'New menu', status: 'draft', roles: [], items: [] });
};
const itemState = (nativeId) => draft.items.find((i) => i.native_id === nativeId);
const toggleHidden = (nativeId) => {
let item = itemState(nativeId);
if (!item) {
item = { native_id: nativeId, label: '', hidden: false, order: 0 };
draft.items.push(item);
}
item.hidden = !item.hidden;
};
const setLabel = (nativeId, label) => {
let item = itemState(nativeId);
if (!item) {
item = { native_id: nativeId, label: '', hidden: false, order: 0 };
draft.items.push(item);
}
item.label = label;
};
const saving = ref(false);
const save = async () => {
saving.value = true;
try {
const payload = { name: draft.name, status: draft.status, roles: draft.roles, items: draft.items };
if (isCreating.value) {
const created = await api('/menus', { method: 'POST', body: JSON.stringify(payload) });
await load();
selectMenu(created);
isCreating.value = false;
} else if (selectedId.value) {
await api(`/menus/${selectedId.value}`, { method: 'PUT', body: JSON.stringify(payload) });
await load();
}
} finally {
saving.value = false;
}
};
const remove = async () => {
if (!selectedId.value) return;
await api(`/menus/${selectedId.value}`, { method: 'DELETE' });
selectedId.value = null;
await load();
};
const toggleRole = (slug) => {
const idx = draft.roles.indexOf(slug);
if (idx === -1) draft.roles.push(slug);
else draft.roles.splice(idx, 1);
};
</script>
<template>
<AppListShell
v-model:search="search"
title="Menu Creator"
search-placeholder="Search menus…"
:count-label="`${filteredMenus.length} MENUS`"
detail-icon="dashicons-menu-alt"
detail-title="Menu Editor"
detail-text="Select a menu from the list to edit its structure and settings."
:has-selection="!!selected || isCreating"
>
<template #header-actions>
<button type="button" @click="newMenu">+</button>
</template>
<template #filters>
<button
v-for="f in ['all', 'published', 'draft']"
:key="f"
type="button"
:class="{ 'is-active': statusFilter === f }"
@click="statusFilter = f"
>
{{ f === 'all' ? 'All' : f[0].toUpperCase() + f.slice(1) }}
</button>
</template>
<template #list>
<p v-if="!loading && !filteredMenus.length" style="font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 20px 4px; text-align: center;">
No menus yet
</p>
<AppListItem
v-for="m in filteredMenus"
:key="m.id"
icon="dashicons-menu-alt"
:title="m.name"
:subtitle="m.status"
:active="m.id === selectedId"
@click="selectMenu(m)"
/>
</template>
<template #detail>
<div class="menu-editor">
<div class="menu-editor__row">
<input v-model="draft.name" type="text" class="menu-editor__name" placeholder="Menu name" />
<select v-model="draft.status" class="menu-editor__status">
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</div>
<div class="menu-editor__section">
<h3>Target roles</h3>
<div class="menu-editor__roles">
<label v-for="r in roleOptions" :key="r.value" class="menu-editor__role">
<input type="checkbox" :checked="draft.roles.includes(r.value)" @change="toggleRole(r.value)" />
{{ r.label }}
</label>
</div>
</div>
<div class="menu-editor__section">
<h3>Menu items</h3>
<div class="menu-editor__items">
<div v-for="ni in nativeItems" :key="ni.native_id" class="menu-editor__item">
<span class="dashicons" :class="ni.icon?.value || 'dashicons-admin-generic'" />
<input
type="text"
:value="itemState(ni.native_id)?.label || ni.title"
@input="setLabel(ni.native_id, $event.target.value)"
/>
<label class="menu-editor__hide">
<input
type="checkbox"
:checked="!!itemState(ni.native_id)?.hidden"
@change="toggleHidden(ni.native_id)"
/>
Hide
</label>
</div>
</div>
</div>
<div class="menu-editor__actions">
<button type="button" class="is-danger" :disabled="!selectedId" @click="remove">Delete</button>
<button type="button" class="is-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</template>
</AppListShell>
</template>
<style scoped>
.menu-editor { display: flex; flex-direction: column; gap: 22px; }
.menu-editor__row { display: flex; gap: 10px; }
.menu-editor__name {
flex: 1; font-size: 1.1rem; font-weight: 700; padding: 8px 10px;
border: 1px solid var(--aurora-border); border-radius: 8px;
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.menu-editor__status {
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.menu-editor__section h3 {
font-size: 0.8125rem; text-transform: uppercase; letter-spacing: 0.03em;
color: var(--aurora-text-muted); margin: 0 0 10px;
}
.menu-editor__roles { display: flex; flex-wrap: wrap; gap: 12px; }
.menu-editor__role { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; color: var(--aurora-text); }
.menu-editor__items { display: flex; flex-direction: column; gap: 6px; max-height: 360px; overflow-y: auto; }
.menu-editor__item {
display: flex; align-items: center; gap: 10px; padding: 6px 4px;
border-bottom: 1px solid var(--aurora-frame-border);
}
.menu-editor__item input[type='text'] {
flex: 1; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.8125rem;
}
.menu-editor__hide { display: flex; align-items: center; gap: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); }
.menu-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
.menu-editor__actions button {
border: none; border-radius: 8px; padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.menu-editor__actions .is-primary { background: var(--aurora-accent); color: #fff; }
.menu-editor__actions .is-danger { color: #ef4444; }
.menu-editor__actions button:disabled { opacity: 0.5; cursor: default; }
</style>
+201
View File
@@ -0,0 +1,201 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import AppListShell from '../_shared/AppListShell.vue';
import AppListItem from '../_shared/AppListItem.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path, opts = {}) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(opts.headers || {}),
},
}).then((r) => {
if (!r.ok) throw new Error('Request failed');
return r.json();
});
const roles = ref([]);
const allCapabilities = ref([]);
const search = ref('');
const selectedSlug = ref(null);
const isCreating = ref(false);
const errorMsg = ref('');
const load = async () => {
const data = await api('/roles');
roles.value = data.roles;
allCapabilities.value = data.allCapabilities;
};
onMounted(load);
const filtered = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return roles.value;
return roles.value.filter((r) => r.name.toLowerCase().includes(q));
});
const selected = computed(() => roles.value.find((r) => r.slug === selectedSlug.value) || null);
const draft = reactive({ slug: '', name: '', capabilities: [] });
const selectRole = (r) => {
selectedSlug.value = r.slug;
isCreating.value = false;
errorMsg.value = '';
Object.assign(draft, { slug: r.slug, name: r.name, capabilities: [...r.capabilities] });
};
const newRole = () => {
selectedSlug.value = null;
isCreating.value = true;
errorMsg.value = '';
Object.assign(draft, { slug: '', name: '', capabilities: ['read'] });
};
const toggleCap = (cap) => {
const idx = draft.capabilities.indexOf(cap);
if (idx === -1) draft.capabilities.push(cap);
else draft.capabilities.splice(idx, 1);
};
const saving = ref(false);
const save = async () => {
saving.value = true;
errorMsg.value = '';
try {
if (isCreating.value) {
await api('/roles', {
method: 'POST',
body: JSON.stringify({ slug: draft.slug, displayName: draft.name, capabilities: draft.capabilities }),
});
await load();
isCreating.value = false;
selectRole(roles.value.find((r) => r.slug === draft.slug));
} else if (selectedSlug.value) {
await api(`/roles/${selectedSlug.value}`, {
method: 'PUT',
body: JSON.stringify({ displayName: draft.name, capabilities: draft.capabilities }),
});
await load();
}
} catch (e) {
errorMsg.value = 'Could not save — check the role slug is unique.';
} finally {
saving.value = false;
}
};
const remove = async () => {
if (!selectedSlug.value || selected.value?.isDefault) return;
await api(`/roles/${selectedSlug.value}`, { method: 'DELETE' });
selectedSlug.value = null;
await load();
};
</script>
<template>
<AppListShell
v-model:search="search"
title="Role Editor"
search-placeholder="Search roles…"
:count-label="`${filtered.length} ROLES`"
detail-icon="dashicons-groups"
detail-title="Role Details"
detail-text="Select a role from the list to view and edit its capabilities."
:has-selection="!!selected || isCreating"
>
<template #header-actions>
<button type="button" class="is-primary" @click="newRole">+ New Role</button>
</template>
<template #list>
<AppListItem
v-for="r in filtered"
:key="r.slug"
icon="dashicons-groups"
:title="r.name"
:subtitle="`${r.userCount} user${r.userCount === 1 ? '' : 's'}`"
:active="r.slug === selectedSlug"
@click="selectRole(r)"
/>
</template>
<template #detail>
<div class="role-editor">
<div class="role-editor__row">
<input
v-if="isCreating"
v-model="draft.slug"
type="text"
placeholder="role_slug"
class="role-editor__slug"
/>
<input v-model="draft.name" type="text" placeholder="Display name" class="role-editor__name" />
</div>
<p v-if="errorMsg" class="role-editor__error">{{ errorMsg }}</p>
<div class="role-editor__section">
<h3>Capabilities</h3>
<div class="role-editor__caps">
<label v-for="cap in allCapabilities" :key="cap" class="role-editor__cap">
<input type="checkbox" :checked="draft.capabilities.includes(cap)" @change="toggleCap(cap)" />
{{ cap }}
</label>
</div>
</div>
<div class="role-editor__actions">
<button
type="button"
class="is-danger"
:disabled="!selectedSlug || selected?.isDefault"
:title="selected?.isDefault ? 'Default WordPress roles cannot be deleted' : ''"
@click="remove"
>
Delete
</button>
<button type="button" class="is-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</template>
</AppListShell>
</template>
<style scoped>
.role-editor { display: flex; flex-direction: column; gap: 18px; }
.role-editor__row { display: flex; gap: 10px; }
.role-editor__slug, .role-editor__name {
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.875rem;
}
.role-editor__name { flex: 1; font-weight: 700; }
.role-editor__slug { width: 160px; }
.role-editor__error { color: #ef4444; font-size: 0.8125rem; margin: 0; }
.role-editor__section h3 {
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em;
color: var(--aurora-text-muted); margin: 0 0 8px;
}
.role-editor__caps {
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px 16px; max-height: 380px; overflow-y: auto;
}
.role-editor__cap { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; color: var(--aurora-text); }
.role-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
.role-editor__actions button {
border: none; border-radius: 8px; padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.role-editor__actions .is-primary { background: var(--aurora-accent); color: #fff; }
.role-editor__actions .is-danger { color: #ef4444; }
.role-editor__actions button:disabled { opacity: 0.5; cursor: default; }
</style>
+286
View File
@@ -0,0 +1,286 @@
<script setup>
import { ref, reactive, computed, watch } from 'vue';
import { settingsCategories } from './settingsConfig.js';
import SettingsField from './components/SettingsField.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
// Working copy of the settings, seeded from the server + field defaults.
const settings = reactive({ ...(props.serverData.settings || {}) });
settingsCategories.forEach((cat) =>
cat.fields.forEach((f) => {
if (settings[f.key] === undefined && f.default !== undefined) {
settings[f.key] = f.default;
}
})
);
// Active category, synced with the URL ?category= param.
const initialCat = new URLSearchParams(window.location.search).get('category');
const activeId = ref(
settingsCategories.some((c) => c.id === initialCat) ? initialCat : settingsCategories[0].id
);
const activeCategory = computed(
() => settingsCategories.find((c) => c.id === activeId.value) || settingsCategories[0]
);
const selectCategory = (id) => {
activeId.value = id;
const url = new URL(window.location.href);
url.searchParams.set('category', id);
window.history.replaceState({}, '', url);
};
// Search filter over the nav.
const query = ref('');
const filteredCategories = computed(() => {
const q = query.value.trim().toLowerCase();
if (!q) return settingsCategories;
return settingsCategories.filter(
(c) =>
c.title.toLowerCase().includes(q) || c.subtitle.toLowerCase().includes(q)
);
});
// Options for multiselect fields, resolved from server-provided lists.
const optionsFor = (field) => {
if (field.optionsSource === 'roles') return props.serverData.roles || [];
if (field.optionsSource === 'postTypes') return props.serverData.postTypes || [];
return field.options || [];
};
// Live dark-mode preview.
watch(
() => settings.dark_mode,
(v) => document.documentElement.classList.toggle('aurora-dark', !!v)
);
// Save -------------------------------------------------------------------
const saving = ref(false);
const savedMsg = ref('');
const save = async () => {
saving.value = true;
savedMsg.value = '';
try {
const res = await fetch(`${props.serverData.restUrl}wp/v2/settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce },
body: JSON.stringify({ aurora_admin_settings: { ...settings } }),
});
if (!res.ok) throw new Error();
savedMsg.value = 'Saved';
setTimeout(() => (savedMsg.value = ''), 2500);
} catch {
savedMsg.value = 'Could not save';
} finally {
saving.value = false;
}
};
// Import / export --------------------------------------------------------
const exportSettings = () => {
const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'aurora-admin-settings.json';
a.click();
URL.revokeObjectURL(a.href);
};
const importInput = ref(null);
const importSettings = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const obj = JSON.parse(reader.result);
Object.assign(settings, obj);
} catch {
savedMsg.value = 'Invalid file';
}
};
reader.readAsText(file);
e.target.value = '';
};
</script>
<template>
<div class="settings">
<!-- Left nav panel -->
<aside class="settings__nav">
<div class="settings__nav-head">
<h1 class="settings__title">Settings</h1>
<div class="settings__actions">
<button type="button" title="Export settings" @click="exportSettings">
<span class="dashicons dashicons-download" />
</button>
<button type="button" title="Import settings" @click="importInput.click()">
<span class="dashicons dashicons-upload" />
</button>
<input
ref="importInput"
type="file"
accept="application/json"
class="settings__file"
@change="importSettings"
/>
</div>
</div>
<div class="settings__search">
<span class="dashicons dashicons-search" />
<input v-model="query" type="text" placeholder="Search settings…" />
</div>
<ul class="settings__list">
<li v-for="cat in filteredCategories" :key="cat.id">
<button
type="button"
class="settings__cat"
:class="{ 'settings__cat--active': cat.id === activeId }"
@click="selectCategory(cat.id)"
>
<span class="settings__cat-icon"><span class="dashicons" :class="cat.icon" /></span>
<span class="settings__cat-text">
<span class="settings__cat-title">{{ cat.title }}</span>
<span class="settings__cat-sub">{{ cat.subtitle }}</span>
</span>
</button>
</li>
</ul>
</aside>
<!-- Right content panel -->
<section class="settings__content">
<header class="settings__content-head">
<div>
<h2 class="settings__content-title">{{ activeCategory.title }}</h2>
<p class="settings__content-sub">{{ activeCategory.subtitle }}</p>
</div>
<div class="settings__save-wrap">
<span v-if="savedMsg" class="settings__saved">{{ savedMsg }}</span>
<button type="button" class="settings__save" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</header>
<div class="settings__fields">
<SettingsField
v-for="f in activeCategory.fields"
:key="f.key"
:field="f"
:options="optionsFor(f)"
v-model="settings[f.key]"
/>
</div>
</section>
</div>
</template>
<style scoped>
.settings {
display: flex;
align-items: flex-start;
gap: 20px;
min-height: calc(100vh - var(--aurora-toolbar-h) - 60px);
margin: -32px; /* cancel the frame padding */
padding: 22px; /* inset the two boxes; the frame's right gutter adds the rest */
box-sizing: border-box;
/* Darker backdrop so the two boxes read as distinct panels. */
background: var(--aurora-app-bg);
}
/* Left nav — its own box */
.settings__nav {
width: 340px;
flex-shrink: 0;
background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border);
border-radius: 16px;
padding: 24px 20px;
box-sizing: border-box;
}
.settings__nav-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 18px;
}
.settings__title { font-size: 1.35rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
.settings__actions { display: flex; gap: 4px; }
.settings__actions button {
width: 30px; height: 30px; border: none; background: none;
color: var(--aurora-text-muted); cursor: pointer; border-radius: 7px;
display: inline-flex; align-items: center; justify-content: center;
}
.settings__actions button:hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
.settings__file { display: none; }
.settings__search {
display: flex; align-items: center; gap: 8px;
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
border-radius: 10px; padding: 8px 12px; margin-bottom: 16px;
color: var(--aurora-text-muted);
}
.settings__search input {
border: none; background: none; outline: none; color: var(--aurora-text);
font-size: 0.8125rem; width: 100%;
}
.settings__list { list-style: none; margin: 0; padding: 0; }
.settings__cat {
display: flex; align-items: center; gap: 12px; width: 100%;
padding: 10px; border: none; background: none; cursor: pointer;
border-radius: 12px; text-align: left; margin-bottom: 2px;
}
.settings__cat:hover { background: var(--aurora-bg-subtle); }
.settings__cat--active { background: var(--aurora-accent-soft); }
.settings__cat-icon {
width: 34px; height: 34px; flex-shrink: 0;
border-radius: 9px; background: var(--aurora-bg-subtle);
display: flex; align-items: center; justify-content: center;
color: var(--aurora-text-muted);
}
.settings__cat--active .settings__cat-icon { color: var(--aurora-accent); }
.settings__cat-text { display: flex; flex-direction: column; min-width: 0; }
.settings__cat-title { font-size: 0.875rem; font-weight: 600; color: var(--aurora-text); }
.settings__cat--active .settings__cat-title { color: var(--aurora-accent); }
.settings__cat-sub {
font-size: 0.75rem; color: var(--aurora-text-muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Right content — its own box */
.settings__content {
flex: 1; min-width: 0;
background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border);
border-radius: 16px;
padding: 28px 32px;
}
.settings__content-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 24px;
padding-bottom: 22px;
margin-bottom: 4px;
border-bottom: 1px solid var(--aurora-frame-border);
}
.settings__content-title { font-size: 1.6rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
.settings__content-sub { margin: 6px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
.settings__save-wrap { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.settings__saved { font-size: 0.8125rem; color: var(--aurora-text-muted); }
.settings__save {
background: var(--aurora-accent); color: #fff; border: none;
border-radius: 9px; padding: 9px 20px; font-size: 0.875rem; font-weight: 500; cursor: pointer;
}
.settings__save:disabled { opacity: 0.6; cursor: default; }
.settings__fields > :last-child { border-bottom: none; }
@media (max-width: 1000px) {
.settings { flex-direction: column; }
.settings__nav { width: auto; }
}
</style>
@@ -0,0 +1,315 @@
<script setup>
import { computed, reactive } from 'vue';
import Toggle from '@/components/Toggle.vue';
const props = defineProps({
field: { type: Object, required: true },
modelValue: { default: undefined },
// Options for multiselect fields (resolved by the page from optionsSource).
options: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:modelValue']);
const val = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
});
// Repeater helpers ---------------------------------------------------------
const rows = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []));
const addRow = () => emit('update:modelValue', [...rows.value, { find: '', replace: '' }]);
const updateRow = (i, key, v) => {
const next = rows.value.map((r, idx) => (idx === i ? { ...r, [key]: v } : r));
emit('update:modelValue', next);
};
const removeRow = (i) => emit('update:modelValue', rows.value.filter((_, idx) => idx !== i));
// Multiselect helpers -------------------------------------------------------
const selectedValues = computed(() =>
Array.isArray(props.modelValue) ? props.modelValue : []
);
const toggleValue = (value) => {
const next = selectedValues.value.includes(value)
? selectedValues.value.filter((v) => v !== value)
: [...selectedValues.value, value];
emit('update:modelValue', next);
};
// Remote sites helpers -------------------------------------------------------
const sites = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []));
const newSite = reactive({ url: '', username: '', app_password: '' });
const addSite = () => {
if (!newSite.url.trim()) return;
emit('update:modelValue', [
...sites.value,
{ url: newSite.url.trim(), username: newSite.username.trim(), app_password: newSite.app_password },
]);
newSite.url = '';
newSite.username = '';
newSite.app_password = '';
};
const removeSite = (i) => emit('update:modelValue', sites.value.filter((_, idx) => idx !== i));
// Media picker (image fields) ---------------------------------------------
const pickImage = () => {
const wp = window.wp;
if (!wp || !wp.media) {
// Fallback: prompt for a URL if the media library isn't available.
const url = window.prompt('Image URL');
if (url) emit('update:modelValue', url);
return;
}
const frame = wp.media({ title: props.field.label, multiple: false });
frame.on('select', () => {
const att = frame.state().get('selection').first().toJSON();
emit('update:modelValue', att.url);
});
frame.open();
};
</script>
<template>
<div class="field">
<div class="field__info">
<div class="field__label">{{ field.label }}</div>
<p v-if="field.description" class="field__desc">{{ field.description }}</p>
</div>
<div class="field__control">
<!-- Text -->
<input
v-if="field.type === 'text'"
type="text"
class="field__input"
:placeholder="field.placeholder || ''"
:value="val || ''"
@input="val = $event.target.value"
/>
<!-- Toggle -->
<Toggle
v-else-if="field.type === 'toggle'"
:model-value="!!val"
@update:model-value="val = $event"
/>
<!-- Color -->
<div v-else-if="field.type === 'color'" class="field__color">
<input
type="color"
:value="val || field.default || '#4772fa'"
@input="val = $event.target.value"
/>
<span class="field__color-hex">{{ val || field.default || '#4772fa' }}</span>
</div>
<!-- Image / favicon -->
<button
v-else-if="field.type === 'image'"
type="button"
class="field__image"
@click="pickImage"
>
<img v-if="val" :src="val" alt="" />
<span v-else class="dashicons dashicons-format-image field__image-ph" />
<span class="field__image-label">{{ val ? 'Change' : (field.label + ' url') }}</span>
</button>
<!-- Multiselect (checkbox list) -->
<div v-else-if="field.type === 'multiselect'" class="field__multi">
<label v-for="opt in options" :key="opt.value" class="field__multi-item">
<input
type="checkbox"
:checked="selectedValues.includes(opt.value)"
@change="toggleValue(opt.value)"
/>
<span>{{ opt.label }}</span>
</label>
<p v-if="!options.length" class="field__multi-empty">No options available.</p>
</div>
<!-- Segmented No/Yes -->
<div v-else-if="field.type === 'segmented'" class="field__seg">
<button
type="button"
:class="{ 'field__seg--active': !val }"
@click="val = false"
>
No
</button>
<button
type="button"
:class="{ 'field__seg--active': !!val }"
@click="val = true"
>
Yes
</button>
</div>
<!-- Remote sites -->
<div v-else-if="field.type === 'remote-sites'" class="field__sites">
<div class="field__sites-add">
<input v-model="newSite.url" type="text" class="field__input" placeholder="Site URL (e.g., https://example.com)" />
<div class="field__sites-row">
<input v-model="newSite.username" type="text" class="field__input" placeholder="Username" />
<input v-model="newSite.app_password" type="password" class="field__input" placeholder="Application Password" />
<button type="button" class="field__newitem" @click="addSite">Add Site</button>
</div>
</div>
<div class="field__sites-list">
<div v-if="!sites.length" class="field__sites-empty">No remote sites configured yet</div>
<div v-for="(site, i) in sites" :key="i" class="field__sites-item">
<span class="field__sites-url">{{ site.url }}</span>
<span class="field__sites-user">{{ site.username }}</span>
<button type="button" class="field__repeater-remove" @click="removeSite(i)">×</button>
</div>
</div>
<p class="field__sites-help">
Application passwords can be created in WordPress under Users &gt; Profile &gt; Application Passwords
</p>
</div>
<!-- Repeater -->
<div v-else-if="field.type === 'repeater'" class="field__repeater">
<div v-for="(row, i) in rows" :key="i" class="field__repeater-row">
<input
type="text"
class="field__input"
:placeholder="field.columns ? field.columns[0] : 'Find'"
:value="row.find"
@input="updateRow(i, 'find', $event.target.value)"
/>
<input
type="text"
class="field__input"
:placeholder="field.columns ? field.columns[1] : 'Replace'"
:value="row.replace"
@input="updateRow(i, 'replace', $event.target.value)"
/>
<button type="button" class="field__repeater-remove" @click="removeRow(i)">×</button>
</div>
<button type="button" class="field__newitem" @click="addRow">New item</button>
</div>
</div>
</div>
</template>
<style scoped>
.field {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 32px;
padding: 28px 0;
border-bottom: 1px solid var(--aurora-border);
align-items: start;
}
.field__label { font-size: 0.95rem; font-weight: 600; color: var(--aurora-text); }
.field__desc { margin: 6px 0 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.5; }
.field__control { display: flex; justify-content: flex-start; }
.field__input {
width: 100%;
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
color: var(--aurora-text);
border-radius: 8px;
padding: 9px 12px;
font-size: 0.875rem;
}
.field__input:focus { outline: none; border-color: var(--aurora-accent); }
.field__color { display: flex; align-items: center; gap: 10px; }
.field__color input[type='color'] {
width: 40px; height: 32px; border: 1px solid var(--aurora-border);
border-radius: 8px; background: none; cursor: pointer; padding: 2px;
}
.field__color-hex { font-size: 0.8125rem; color: var(--aurora-text-muted); }
.field__image {
width: 160px; height: 120px;
border: 1px dashed var(--aurora-border);
border-radius: 12px;
background: var(--aurora-bg-subtle);
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 8px; cursor: pointer; color: var(--aurora-text-muted);
position: relative; overflow: hidden;
}
.field__image img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.field__image-ph { font-size: 28px; width: 28px; height: 28px; }
.field__image-label {
position: absolute; bottom: 0; left: 0; right: 0;
background: var(--aurora-card-bg); padding: 6px; font-size: 0.75rem;
border-top: 1px solid var(--aurora-border);
}
.field__repeater { width: 100%; }
.field__repeater-row { display: flex; gap: 8px; margin-bottom: 8px; }
.field__repeater-remove {
flex-shrink: 0; width: 34px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
border-radius: 8px; cursor: pointer; font-size: 1.1rem;
}
.field__newitem {
border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
border-radius: 8px; padding: 8px 16px; font-size: 0.8125rem; cursor: pointer;
}
.field__newitem:hover { border-color: var(--aurora-accent); color: var(--aurora-accent); }
/* Multiselect */
.field__multi { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.field__multi-item {
display: flex; align-items: center; gap: 8px;
font-size: 0.8125rem; color: var(--aurora-text); cursor: pointer;
}
.field__multi-item input { accent-color: var(--aurora-accent); }
.field__multi-empty { font-size: 0.8125rem; color: var(--aurora-text-muted); margin: 0; }
/* Segmented No/Yes */
.field__seg {
display: inline-flex;
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
border-radius: 9px;
padding: 3px;
gap: 2px;
}
.field__seg button {
border: none; background: none; cursor: pointer;
padding: 6px 26px; border-radius: 7px;
font-size: 0.8125rem; color: var(--aurora-text-muted);
}
.field__seg--active { background: var(--aurora-accent); color: #fff !important; }
/* Remote sites */
.field__sites { width: 100%; }
.field__sites-add .field__input { margin-bottom: 8px; }
.field__sites-row { display: flex; gap: 8px; }
.field__sites-row .field__input { margin-bottom: 0; }
.field__sites-row .field__newitem { flex-shrink: 0; white-space: nowrap; }
.field__sites-list {
margin-top: 12px;
border: 1px solid var(--aurora-border);
border-radius: 10px;
overflow: hidden;
}
.field__sites-empty {
padding: 18px; text-align: center;
font-size: 0.8125rem; color: var(--aurora-text-muted);
}
.field__sites-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--aurora-border);
font-size: 0.8125rem;
}
.field__sites-item:last-child { border-bottom: none; }
.field__sites-url { color: var(--aurora-text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.field__sites-user { color: var(--aurora-text-muted); }
.field__sites-help { margin: 10px 0 0; font-size: 0.75rem; color: var(--aurora-text-muted); }
@media (max-width: 782px) {
.field { grid-template-columns: 1fr; gap: 12px; }
}
</style>
+261
View File
@@ -0,0 +1,261 @@
/**
* Declarative settings schema for the Aurora Admin settings page.
* Each category renders as an item in the left nav; its fields render in
* the right panel. Field `key`s map directly into the aurora_admin_settings
* option, saved via the wp/v2/settings REST route.
*/
export const settingsCategories = [
{
id: 'general',
title: 'General',
subtitle: 'Core plugin settings and license management',
icon: 'dashicons-admin-settings',
fields: [
{
key: 'license_key',
type: 'text',
label: 'License key',
description: 'Enter your license key to enable automatic updates.',
placeholder: 'AA-XXXX-XXXX-XXXX',
},
{
key: 'logo',
type: 'image',
label: 'Logo',
description: 'Optional logo to be displayed in the menu.',
},
{
key: 'dark_logo',
type: 'image',
label: 'Dark mode logo',
description:
"Set an alternative logo to use in dark mode. If one isn't set it will fall back to the standard logo.",
},
{
key: 'remote_sites',
type: 'remote-sites',
label: 'Remote Sites',
description: 'Configure remote WordPress sites for cross-site operations.',
},
{
key: 'remote_site_switcher_capability',
type: 'text',
label: 'Remote Site Switcher Capability',
description:
'WordPress capability required to see the remote site switcher in the toolbar. Defaults to "manage_options" (admin users). Users with this capability or below will see the switcher.',
placeholder: 'manage_options',
},
{
key: 'disable_theme',
type: 'multiselect',
label: 'Disable theme',
description: 'Choose roles to disable the theme for.',
optionsSource: 'roles',
},
{
key: 'search_post_types',
type: 'multiselect',
label: 'Search post types',
description: 'Choose which post types are available in the global search.',
optionsSource: 'postTypes',
},
{
key: 'disable_global_search',
type: 'segmented',
label: 'Disable global search',
description: 'Disable global search for all users.',
},
],
},
{
id: 'login',
title: 'Login',
subtitle: 'Customize login page appearance and security',
icon: 'dashicons-lock',
fields: [
{
key: 'style_login',
type: 'toggle',
label: 'Style login page',
description: 'Apply the Aurora theme to the WordPress login screen.',
},
{
key: 'login_logo',
type: 'image',
label: 'Login logo',
description: 'Replace the WordPress logo on the login screen.',
},
],
},
{
id: 'theme',
title: 'Theme',
subtitle: 'Colors, styling, and visual customization',
icon: 'dashicons-admin-appearance',
fields: [
{
key: 'dark_mode',
type: 'toggle',
label: 'Dark mode',
description: 'Use the dark interface theme across the admin.',
},
{
key: 'accent_color',
type: 'color',
label: 'Accent color',
description: 'Primary accent color used throughout the interface.',
default: '#4772fa',
},
],
},
{
id: 'whitelabel',
title: 'White Label',
subtitle: 'Rebrand and customize interface text and icons',
icon: 'dashicons-tag',
fields: [
{
key: 'admin_favicon',
type: 'image',
label: 'Admin favicon',
description: 'Optional favicon to replace the WordPress favicon in the admin.',
},
{
key: 'plugin_name',
type: 'text',
label: 'Rename Aurora Admin',
description:
"This will rename Aurora Admin in the plugins list as well as renaming its settings link.",
placeholder: 'Aurora Admin',
},
{
key: 'text_replacements',
type: 'repeater',
label: 'Text Replacement',
description:
'Customize the text displayed in the WordPress admin area. Enter original words or phrases and their replacements to white-label the interface.',
columns: ['Find', 'Replace with'],
},
],
},
{
id: 'menu',
title: 'Menu',
subtitle: 'Admin menu behavior and interaction settings',
icon: 'dashicons-menu',
fields: [
{
key: 'menu_search',
type: 'toggle',
label: 'Menu search',
description: 'Show a search box at the top of the admin menu.',
},
],
},
{
id: 'posts',
title: 'Posts',
subtitle: 'Post management and display preferences',
icon: 'dashicons-admin-post',
fields: [
{
key: 'modern_posts',
type: 'toggle',
label: 'Modern posts table',
description: 'Use the Aurora posts management interface.',
},
],
},
{
id: 'plugins',
title: 'Plugins',
subtitle: 'Plugin management interface options',
icon: 'dashicons-admin-plugins',
fields: [
{
key: 'modern_plugins',
type: 'toggle',
label: 'Modern plugins page',
description: 'Use the Aurora plugins management interface.',
},
],
},
{
id: 'dashboard',
title: 'Dashboard',
subtitle: 'Custom dashboard and admin interface settings',
icon: 'dashicons-dashboard',
fields: [
{
key: 'use_custom_dashboard',
type: 'toggle',
label: 'Custom dashboard',
description: "Replace the WordPress dashboard with Aurora's dashboard.",
default: true,
},
],
},
{
id: 'analytics',
title: 'Analytics',
subtitle: 'Built-in analytics and tracking settings',
icon: 'dashicons-chart-bar',
fields: [
{
key: 'enable_analytics',
type: 'toggle',
label: 'Enable analytics',
description: 'Track first-party visitor analytics for the dashboard.',
default: true,
},
{
key: 'track_admins',
type: 'toggle',
label: 'Track logged-in admins',
description: 'Include administrator visits in analytics (off by default).',
},
],
},
{
id: 'media',
title: 'Media',
subtitle: 'Media library and upload management settings',
icon: 'dashicons-admin-media',
fields: [
{
key: 'enable_svg',
type: 'toggle',
label: 'Allow SVG uploads',
description: 'Permit SVG files to be uploaded to the media library.',
},
],
},
{
id: 'users',
title: 'Users',
subtitle: 'User management and administration settings',
icon: 'dashicons-admin-users',
fields: [
{
key: 'modern_users',
type: 'toggle',
label: 'Modern users page',
description: 'Use the Aurora users management interface.',
},
],
},
{
id: 'comments',
title: 'Comments',
subtitle: 'Comment management and administration',
icon: 'dashicons-admin-comments',
fields: [
{
key: 'modern_comments',
type: 'toggle',
label: 'Modern comments page',
description: 'Use the Aurora comments management interface.',
},
],
},
];