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
+5
View File
@@ -0,0 +1,5 @@
app/node_modules/
app/dist/
*.log
.DS_Store
Thumbs.db
+148
View File
@@ -0,0 +1,148 @@
# Aurora Admin v2 — Project Status
_Last updated: 2026-07-03_
## What this project is
A ground-up rebuild of the "uiXpress" WordPress admin-theme plugin, relaunched
as **Aurora Admin**. The original plugin's look/feel and feature set are the
target, but **no original code is reused** — all Vue components, PHP classes,
Tailwind config, and build tooling are freshly written. This lives in
`aurora-admin-v2/` (the earlier in-place rebrand attempt, `aurora-admin-working/`,
is superseded and no longer the active codebase).
## Workflow rules established
- **Always verify live in Chrome** before declaring a fix done — the user was
burned by blind-iteration guesses costing time/money. Use the Chrome MCP
tools (`tabs_context_mcp`, `navigate`, `computer`/screenshot, `javascript_exec`)
to inspect real DOM/computed styles rather than guessing from source.
- **Zip packaging**: Windows `Compress-Archive` corrupts the WP upload (backslash
path separators break PHP's unzip). Always build the zip via PowerShell
`System.IO.Compression.ZipFile`, normalizing every entry path with
`.Replace('\', '/')`. Output goes to
`aurora-admin-v2-dist\aurora-admin\aurora-admin-v2.zip`.
- **Old build caching gotcha**: WordPress serves whatever plugin files were last
installed — if the user says "didn't fix it," suspect a stale zip still
installed before suspecting the code. Verify the live script hash via
`document.querySelector('script[src*="dashboard"]')` when in doubt.
- Site under test: `http://admin.local/wp-admin/`.
## Architecture
- **PHP** (`admin/src/...`, namespaced `AuroraAdmin\...`), bootstrapped from
`aurora-admin.php`, instantiated in `admin/src/App/AuroraAdmin.php`.
- **Vue 3** app (`app/src/...`), Vite multi-entry build → `manifest.json`,
read by `admin/src/Utility/Assets.php::enqueue()` which also walks the
manifest's `imports` chain to pull in shared-chunk CSS.
- **Single global stylesheet**: `app/src/shell/shell-frame.css` — defines all
`--aurora-*` CSS variables (incl. `.aurora-dark` overrides) and the only
rules that touch WP structural IDs (`#wpcontent`, `#wpbody-content`). No
element resets/utility classes leak onto native WP pages (this was the
root cause of the original rebrand's Tailwind-leakage bug — avoided here
by design).
- **Immersive shell** (`admin/src/Shell/Shell.php` + `app/src/shell/SiteShell.vue`
+ `Sidebar.vue` + `Toolbar.vue`): hides native WP chrome, mounts a Vue
sidebar/toolbar on every admin page. `MenuSerializer.php` converts WP's
`$menu`/`$submenu` globals into JSON for the sidebar.
- **Dashboard takeover** (`admin/src/Pages/DashboardTakeover.php` +
`app/src/pages/dashboard/Dashboard.vue`): hijacks native `index.php` via
`in_admin_header`, hides the native `.wrap`, renders the Aurora dashboard
in its place. 12-col grid; cards are single `<section class="aurora-card">`
elements with inline `grid-column: span N` (NOT wrapped — component
attribute fallthrough for styles was unreliable).
- **Settings page** (`admin/src/Pages/SettingsPage.php` + `app/src/pages/
settings/Settings.vue`): owns the top-level "Aurora Admin" admin menu
directly (no submenu). Two-box layout: `.settings__nav` (category list) +
`.settings__content` (fields), both boxed/bordered on a darker backdrop.
Field schema is declarative in `app/src/pages/settings/settingsConfig.js`,
rendered by `app/src/pages/settings/components/SettingsField.vue` (types:
text, toggle, color, image, repeater, multiselect, segmented, remote-sites).
- **Settings persistence**: `admin/src/Options/Settings.php` — partial-payload
save via `merge_on_save()` sanitize callback (merges into existing option,
doesn't overwrite). `Settings::get_safe()` strips `license_key` and remote
site passwords for use on non-admin-gated pages; `Settings::get()` (full)
only used on the manage_options-gated settings page itself.
- **Global search**: `app/src/shell/components/SearchModal.vue`, Ctrl+K,
debounced `wp/v2/search` REST calls, gated on `disable_global_search` setting.
- **Remote site switcher**: toolbar dropdown, gated on `canSwitchSites` (from
`remote_site_switcher_capability` setting) + non-empty `remote_sites`.
- **Scheduled content**: `app/src/pages/dashboard/cards/ScheduledContent.vue`
calendar cells (squares, date top-left, count centered) are clickable —
navigate to `edit.php?post_status=future&aurora_day=YYYY-MM-DD` if that day
has scheduled posts, else show a toast ("no scheduled content"). Server side:
`admin/src/Posts/ScheduledDayFilter.php` filters the `edit.php` query via
`pre_get_posts` and prints an info notice.
- **Analytics**: `admin/src/Analytics/Analytics.php` (first-party pageview
tracker, own DB table `wp_aurora_admin_pageviews`, bot detection, gated on
`enable_analytics`/`track_admins` settings) + `admin/src/Rest/AnalyticsData.php`
(aggregation REST endpoint: totals, series, device/referrer/country
breakdowns, bounce rate, active users).
- **White label**: `admin/src/WhiteLabel/WhiteLabel.php` — custom favicon,
rename plugin in Plugins list, rename top-level menu item, gettext
find/replace via `text_replacements` setting.
- **Native page dark styling**: `admin/assets/native-dark.css` (large static
sheet, all rules gated `html.aurora-dark.aurora-ready`, scoped under
`#wpbody-content`) + `admin/assets/native-enhance.js` (progressive JS
enhancement — currently only `enhanceUpdates()`, which groups the flat
Updates page sections into `.aurora-native-card` wrapper divs on
`body.update-core-php`).
## Key bugs fixed (root causes, in case they resurface)
- **`.card` global class collision**: original Dashboard cards used
`class="card"`, colliding with WP core's `.card { max-width: 520px }`,
capping card width regardless of grid span. Fixed by renaming to
`.aurora-card*` everywhere. Lesson: never use unprefixed class names on
Aurora components — always prefix `aurora-`.
- **Grid-column not applying on wrapped cards**: settled on unwrapped
`<section class="aurora-card" :style="{gridColumn: ...}">` — component
boundaries ate the style binding in earlier attempts.
- **Two-box settings layout**: took two wrong iterations (splitting
header/fields instead of nav/content) before matching the user's reference
image. If revisiting settings layout, re-confirm against a screenshot
before assuming intent.
- **Redundant outer frame border on settings page**: `#wpbody-content`'s
frame border/radius doubled up with the new two-box layout; fixed with a
`body.toplevel_page_aurora-admin` override in `shell-frame.css` that drops
the outer frame there only.
## Current / in-progress task
**User's last request: "now the updates page doesn't match the style as the
dashboard page."**
Status: diagnosis started but not finished. I navigated to
`update-core.php` and confirmed the page renders with the shell (minify
button, View Website/Dashboard icons, search, dark toggle) and that
`native-enhance.js` is already grouping the page into three
`.aurora-native-card` boxes (Current version / Plugins / Themes). I had
**not yet** done a side-by-side computed-style comparison against
`.aurora-card` (used on the Dashboard, e.g. "Recent content" / "Site Health"
cards) to find the actual mismatch — could be background shade, border
radius, padding, title typography, or hover state. No code changes made yet
for this specific request.
### Next step (concrete)
Via Chrome `javascript_exec`, pull `getComputedStyle()` for a live
`.aurora-card` element on the Dashboard and a live `.aurora-native-card`
element on the Updates page — compare `background-color`, `border-color`,
`border-radius`, `padding`, `box-shadow`, and heading font-size/weight. Then
patch `.aurora-native-card` rules in `admin/assets/native-dark.css` (and/or
structure in `native-enhance.js` if the mismatch is structural, e.g. missing
a title wrapper element) to match `.aurora-card` in `Dashboard.vue`. Verify
live, then rebuild/sync to dist, re-zip, and have the user reinstall.
## Not-yet-done (deferred, no active request)
- Full field sets for settings categories beyond General/Analytics/White
Label (original plugin had more).
- Extending `native-enhance.js` boxing to other native screens (Tools, etc.)
beyond Updates.
- Porting the real license-key activation flow (built previously in the old
`aurora-admin-working` project, not yet ported to v2 — currently just a
text field).
- Light-mode boxing for native pages (`native-dark.css`/`native-enhance.js`
currently only styled for dark mode; light mode native pages are untouched
WP default).
+282
View File
@@ -0,0 +1,282 @@
/*
* Native WordPress admin dark theme.
*
* Loaded ONLY on native (non-Aurora) admin pages, and every rule is gated
* on html.aurora-dark so it is completely inert in light mode. It restyles
* the common WordPress admin elements to match Aurora's dark palette using
* the --aurora-* variables defined in the shell stylesheet.
*
* Scope is kept under #wpcontent / #wpbody-content so it never reaches the
* Aurora sidebar/toolbar (which live in their own root) and cannot leak to
* the front end. This is the one intentionally-large styling surface, kept
* isolated in a single file on purpose.
*/
html.aurora-dark.aurora-ready #wpbody-content,
html.aurora-dark.aurora-ready #wpbody-content .wrap {
color: var(--aurora-text);
}
/* Headings & text -------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content h1,
html.aurora-dark.aurora-ready #wpbody-content h2,
html.aurora-dark.aurora-ready #wpbody-content h3,
html.aurora-dark.aurora-ready #wpbody-content h4,
html.aurora-dark.aurora-ready #wpbody-content h5,
html.aurora-dark.aurora-ready #wpbody-content h6,
html.aurora-dark.aurora-ready #wpbody-content p,
html.aurora-dark.aurora-ready #wpbody-content li,
html.aurora-dark.aurora-ready #wpbody-content dt,
html.aurora-dark.aurora-ready #wpbody-content dd,
html.aurora-dark.aurora-ready #wpbody-content label,
html.aurora-dark.aurora-ready #wpbody-content th,
html.aurora-dark.aurora-ready #wpbody-content td,
html.aurora-dark.aurora-ready #wpbody-content strong {
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .description,
html.aurora-dark.aurora-ready #wpbody-content .howto,
html.aurora-dark.aurora-ready #wpbody-content .subtitle,
html.aurora-dark.aurora-ready #wpbody-content .wp-heading-inline + .subtitle {
color: var(--aurora-text-muted);
}
html.aurora-dark.aurora-ready #wpbody-content a {
color: var(--aurora-accent);
}
/* Postboxes, cards, metaboxes ------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .postbox,
html.aurora-dark.aurora-ready #wpbody-content .card,
html.aurora-dark.aurora-ready #wpbody-content .welcome-panel,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table,
html.aurora-dark.aurora-ready #wpbody-content .stuffbox {
background: var(--aurora-card-bg);
border-color: var(--aurora-card-border);
color: var(--aurora-text);
box-shadow: none;
}
html.aurora-dark.aurora-ready #wpbody-content .postbox-header,
html.aurora-dark.aurora-ready #wpbody-content .postbox .hndle,
html.aurora-dark.aurora-ready #wpbody-content .postbox h2,
html.aurora-dark.aurora-ready #wpbody-content .postbox h3 {
border-color: var(--aurora-card-border);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .inside,
html.aurora-dark.aurora-ready #wpbody-content .welcome-panel-content {
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .handlediv,
html.aurora-dark.aurora-ready #wpbody-content .postbox .handle-order-higher,
html.aurora-dark.aurora-ready #wpbody-content .postbox .handle-order-lower {
color: var(--aurora-text-muted);
}
/* Tables ---------------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table th,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table td {
border-color: var(--aurora-border);
}
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table thead th,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table tfoot th,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table thead th a,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table tfoot th a {
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table.striped > tbody > :nth-child(odd),
html.aurora-dark.aurora-ready #wpbody-content .striped > tbody > :nth-child(odd) {
background: var(--aurora-bg-subtle);
}
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table tbody tr:hover {
background: var(--aurora-accent-soft);
}
html.aurora-dark.aurora-ready #wpbody-content .row-actions {
color: var(--aurora-text-muted);
}
/* Form fields ----------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content input[type='text'],
html.aurora-dark.aurora-ready #wpbody-content input[type='search'],
html.aurora-dark.aurora-ready #wpbody-content input[type='email'],
html.aurora-dark.aurora-ready #wpbody-content input[type='url'],
html.aurora-dark.aurora-ready #wpbody-content input[type='password'],
html.aurora-dark.aurora-ready #wpbody-content input[type='number'],
html.aurora-dark.aurora-ready #wpbody-content input[type='tel'],
html.aurora-dark.aurora-ready #wpbody-content input[type='date'],
html.aurora-dark.aurora-ready #wpbody-content textarea,
html.aurora-dark.aurora-ready #wpbody-content select {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content input::placeholder,
html.aurora-dark.aurora-ready #wpbody-content textarea::placeholder {
color: var(--aurora-text-muted);
}
html.aurora-dark.aurora-ready #wpbody-content input[type='text']:focus,
html.aurora-dark.aurora-ready #wpbody-content input[type='search']:focus,
html.aurora-dark.aurora-ready #wpbody-content textarea:focus,
html.aurora-dark.aurora-ready #wpbody-content select:focus {
border-color: var(--aurora-accent);
box-shadow: 0 0 0 1px var(--aurora-accent);
outline: none;
}
/* Buttons --------------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .button,
html.aurora-dark.aurora-ready #wpbody-content .button-secondary {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .button:hover,
html.aurora-dark.aurora-ready #wpbody-content .button-secondary:hover {
background: var(--aurora-border);
border-color: var(--aurora-text-muted);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .button-primary {
background: var(--aurora-accent);
border-color: var(--aurora-accent);
color: #fff;
text-shadow: none;
box-shadow: none;
}
html.aurora-dark.aurora-ready #wpbody-content .button-primary:hover {
filter: brightness(1.1);
}
/* Nav tabs -------------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .nav-tab-wrapper,
html.aurora-dark.aurora-ready #wpbody-content .wp-filter {
border-color: var(--aurora-border);
}
html.aurora-dark.aurora-ready #wpbody-content .nav-tab {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
color: var(--aurora-text-muted);
}
html.aurora-dark.aurora-ready #wpbody-content .nav-tab-active {
background: var(--aurora-card-bg);
border-bottom-color: var(--aurora-card-bg);
color: var(--aurora-text);
}
/* Notices --------------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .notice,
html.aurora-dark.aurora-ready #wpbody-content div.updated,
html.aurora-dark.aurora-ready #wpbody-content div.error {
background: var(--aurora-card-bg);
color: var(--aurora-text);
}
/* Code ------------------------------------------------------------------ */
html.aurora-dark.aurora-ready #wpbody-content code,
html.aurora-dark.aurora-ready #wpbody-content kbd,
html.aurora-dark.aurora-ready #wpbody-content pre {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
/* Tablenav / pagination / filters --------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .tablenav,
html.aurora-dark.aurora-ready #wpbody-content .subsubsub,
html.aurora-dark.aurora-ready #wpbody-content .tablenav .displaying-num {
color: var(--aurora-text-muted);
}
html.aurora-dark.aurora-ready #wpbody-content .tablenav-pages a,
html.aurora-dark.aurora-ready #wpbody-content .pagination-links a {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
color: var(--aurora-text);
}
/* Screen meta (Help / Screen Options pull-down tabs) -------------------- */
html.aurora-dark.aurora-ready #screen-meta,
html.aurora-dark.aurora-ready #screen-meta-links .show-settings,
html.aurora-dark.aurora-ready #contextual-help-wrap,
html.aurora-dark.aurora-ready #screen-options-wrap {
background: var(--aurora-card-bg);
border-color: var(--aurora-border);
color: var(--aurora-text);
}
/* Form tables (settings pages) ----------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .form-table th,
html.aurora-dark.aurora-ready #wpbody-content .form-table td {
border-color: var(--aurora-border);
color: var(--aurora-text);
}
/* Horizontal rules & misc dividers ------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content hr,
html.aurora-dark.aurora-ready #wpbody-content .wp-header-end {
border-color: var(--aurora-border);
}
/* ---------------------------------------------------------------------------
* WordPress Updates page (update-core.php) — boxed "card" layout.
* native-enhance.js wraps each section (Current version, Plugins, Themes)
* into a .aurora-native-card div; these rules give those cards the same
* surface look as the dashboard cards, plus a clean title and accent button.
* Scoped to the updates page body class so it can't affect other screens.
* ------------------------------------------------------------------------ */
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .wrap > h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 6px;
}
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .wrap > p {
color: var(--aurora-text-muted);
font-size: 0.875rem;
margin: 0 0 20px;
}
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-native-card {
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-card-border);
border-radius: 16px;
padding: 20px 24px;
margin-bottom: 16px;
}
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-native-card h2 {
font-size: 1.05rem;
font-weight: 600;
margin: 4px 0 8px;
color: var(--aurora-text);
border: 0;
padding-top: 0;
}
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-native-card h2.response {
font-size: 1.15rem;
}
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-native-card .button {
background: transparent;
border: 1px solid var(--aurora-accent);
color: var(--aurora-accent);
border-radius: 8px;
padding: 7px 18px;
font-weight: 500;
height: auto;
box-shadow: none;
text-shadow: none;
}
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-native-card .button:hover {
background: var(--aurora-accent-soft);
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Progressive enhancement for native WordPress admin pages that benefit from
* Aurora's boxed "card" layout but ship as flat sibling markup.
*
* Currently targets the Updates page (update-core.php): it groups each
* section (Current version, Plugins, Themes) into a .aurora-native-card div
* so the dark stylesheet can give them the same surface as the dashboard
* cards. Purely visual and reversible — no native behaviour is altered.
*/
(function () {
'use strict';
function enhanceUpdates() {
var wrap = document.querySelector('#wpbody-content .wrap');
if (!wrap || wrap.querySelector('.aurora-native-card')) {
return;
}
var children = Array.prototype.slice.call(wrap.children);
// A new card starts at each major section heading. The "response" h2
// ("You have the latest version") belongs inside the version section,
// so it is not treated as a boundary.
var isBoundary = function (el) {
return el.tagName === 'H2' && !el.classList.contains('response');
};
var groups = [];
var current = null;
children.forEach(function (el) {
if (el.tagName === 'H1') {
return; // page title stays outside the cards
}
if (!current && !isBoundary(el)) {
return; // intro paragraph(s) before the first section stay in place
}
if (isBoundary(el)) {
current = [];
groups.push(current);
}
if (current) {
current.push(el);
}
});
groups.forEach(function (group) {
if (!group.length) {
return;
}
var card = document.createElement('div');
card.className = 'aurora-native-card';
group[0].parentNode.insertBefore(card, group[0]);
group.forEach(function (el) {
card.appendChild(el);
});
});
}
function run() {
if (document.body.classList.contains('update-core-php')) {
enhanceUpdates();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
})();
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace AuroraAdmin\ActivityLog;
defined("ABSPATH") || exit();
/**
* Records a real event log across core WP actions. Storage + the hook
* listeners live here; REST list/filter lives in ActivityLogData.php.
*/
class ActivityLog
{
const TABLE = "aurora_admin_activity_log";
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_activity_log_db_version";
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
add_action("save_post", [self::class, "on_save_post"], 10, 3);
add_action("before_delete_post", [self::class, "on_delete_post"]);
add_action("user_register", [self::class, "on_user_register"]);
add_action("profile_update", [self::class, "on_profile_update"]);
add_action("wp_login", [self::class, "on_login"], 10, 2);
add_action("activated_plugin", [self::class, "on_plugin_activated"]);
add_action("deactivated_plugin", [self::class, "on_plugin_deactivated"]);
add_action("switch_theme", [self::class, "on_theme_switch"]);
add_action("update_option_aurora_admin_settings", [self::class, "on_settings_updated"]);
}
public static function table_name()
{
global $wpdb;
return $wpdb->prefix . self::TABLE;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
action_type VARCHAR(60) NOT NULL DEFAULT '',
object_type VARCHAR(60) NOT NULL DEFAULT '',
object_id BIGINT UNSIGNED NULL,
object_label VARCHAR(191) NOT NULL DEFAULT '',
user_id BIGINT UNSIGNED NULL,
user_name VARCHAR(191) NOT NULL DEFAULT '',
details LONGTEXT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_action (action_type),
KEY idx_object (object_type),
KEY idx_created (created_at)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
private static function record($action_type, $object_type, $object_id, $object_label, $details = [])
{
global $wpdb;
$user = wp_get_current_user();
$wpdb->insert(self::table_name(), [
"action_type" => $action_type,
"object_type" => $object_type,
"object_id" => $object_id,
"object_label" => substr((string) $object_label, 0, 191),
"user_id" => $user && $user->exists() ? $user->ID : null,
"user_name" => $user && $user->exists() ? $user->display_name : "",
"details" => $details ? wp_json_encode($details) : null,
"created_at" => current_time("mysql"),
]);
}
public static function on_save_post($post_id, $post, $update)
{
// save_post fires on autosaves and revisions too — neither represents a
// real user-visible change, so both are skipped here.
if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
return;
}
if (!in_array($post->post_status, ["publish", "draft", "pending", "future", "private"], true)) {
return;
}
self::record($update ? "post_updated" : "post_created", "post", $post_id, $post->post_title ?: "(no title)");
}
public static function on_delete_post($post_id)
{
$post = get_post($post_id);
if (!$post || $post->post_type === "revision") {
return;
}
self::record("post_deleted", "post", $post_id, $post->post_title ?: "(no title)");
}
public static function on_user_register($user_id)
{
$user = get_userdata($user_id);
self::record("user_registered", "user", $user_id, $user ? $user->user_login : "");
}
public static function on_profile_update($user_id)
{
$user = get_userdata($user_id);
self::record("user_updated", "user", $user_id, $user ? $user->user_login : "");
}
public static function on_login($user_login, $user)
{
self::record("login", "user", $user->ID, $user_login);
}
public static function on_plugin_activated($plugin)
{
self::record("plugin_activated", "plugin", null, $plugin);
}
public static function on_plugin_deactivated($plugin)
{
self::record("plugin_deactivated", "plugin", null, $plugin);
}
public static function on_theme_switch($new_name)
{
self::record("theme_switched", "theme", null, $new_name);
}
public static function on_settings_updated()
{
self::record("settings_updated", "settings", null, "Aurora Admin settings");
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace AuroraAdmin\ActivityLog;
defined("ABSPATH") || exit();
/**
* REST list for the activity log. GET /activity-log?action_type=&object_type=&page=&per_page=
*/
class ActivityLogData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/activity-log", [
"methods" => "GET",
"callback" => [self::class, "handle"],
"permission_callback" => function () {
return current_user_can("manage_options");
},
"args" => [
"action_type" => ["type" => "string", "required" => false],
"object_type" => ["type" => "string", "required" => false],
"search" => ["type" => "string", "required" => false],
"page" => ["type" => "integer", "required" => false, "default" => 1],
"per_page" => ["type" => "integer", "required" => false, "default" => 20],
],
]);
}
public static function handle($request)
{
global $wpdb;
$table = ActivityLog::table_name();
$where = ["1=1"];
$params = [];
if ($request->get_param("action_type")) {
$where[] = "action_type = %s";
$params[] = sanitize_text_field($request->get_param("action_type"));
}
if ($request->get_param("object_type")) {
$where[] = "object_type = %s";
$params[] = sanitize_text_field($request->get_param("object_type"));
}
if ($request->get_param("search")) {
$where[] = "(object_label LIKE %s OR user_name LIKE %s)";
$like = "%" . $wpdb->esc_like(sanitize_text_field($request->get_param("search"))) . "%";
$params[] = $like;
$params[] = $like;
}
$where_sql = implode(" AND ", $where);
$per_page = max(1, min(100, (int) $request->get_param("per_page") ?: 20));
$page = max(1, (int) $request->get_param("page") ?: 1);
$offset = ($page - 1) * $per_page;
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql));
$sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, $offset])));
$items = array_map(function ($row) {
return [
"id" => (int) $row->id,
"actionType" => $row->action_type,
"objectType" => $row->object_type,
"objectId" => $row->object_id ? (int) $row->object_id : null,
"objectLabel" => $row->object_label,
"userId" => $row->user_id ? (int) $row->user_id : null,
"userName" => $row->user_name,
"details" => $row->details ? json_decode($row->details, true) : null,
"createdAt" => $row->created_at,
];
}, $rows);
return new \WP_REST_Response([
"items" => $items,
"total" => $total,
"page" => $page,
"perPage" => $per_page,
"totalPages" => (int) ceil($total / $per_page),
], 200);
}
}
+191
View File
@@ -0,0 +1,191 @@
<?php
namespace AuroraAdmin\Analytics;
defined("ABSPATH") || exit();
/**
* Lightweight first-party visitor analytics.
*
* Records one row per front-end pageview into a custom table, server-side
* (no client script, so it isn't blocked by ad blockers). All aggregation
* happens later in the REST endpoint. Written fresh — does not reuse the
* original plugin's analytics code.
*/
class Analytics
{
const TABLE = "aurora_admin_pageviews";
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_analytics_db_version";
const COOKIE = "aurora_admin_sid";
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
// Record on the front end only, after the main query is resolved.
add_action("template_redirect", [self::class, "record"], 999);
}
public static function table_name()
{
global $wpdb;
return $wpdb->prefix . self::TABLE;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
session_id VARCHAR(32) NOT NULL DEFAULT '',
url VARCHAR(255) NOT NULL DEFAULT '',
referrer VARCHAR(255) NOT NULL DEFAULT '',
device VARCHAR(20) NOT NULL DEFAULT 'desktop',
browser VARCHAR(40) NOT NULL DEFAULT '',
country VARCHAR(2) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_created (created_at),
KEY idx_session (session_id)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
/**
* Records the current front-end request as a pageview, skipping admin,
* AJAX/REST/cron, bots, and logged-in administrators.
*/
public static function record()
{
// Tracking is on unless explicitly disabled in Settings > General.
$enabled = \AuroraAdmin\Options\Settings::get("enable_analytics", true);
if ($enabled === false || $enabled === 0 || $enabled === "0") {
return;
}
if (is_admin() || wp_doing_ajax() || wp_doing_cron() || is_robots() || is_feed()) {
return;
}
if (defined("REST_REQUEST") && REST_REQUEST) {
return;
}
// Admins browsing their own site are skipped unless Settings >
// Analytics > "Track logged-in admins" is enabled.
$track_admins = \AuroraAdmin\Options\Settings::get("track_admins", false);
if (!$track_admins && current_user_can("manage_options")) {
return;
}
$ua = isset($_SERVER["HTTP_USER_AGENT"]) ? $_SERVER["HTTP_USER_AGENT"] : "";
if (self::is_bot($ua)) {
return;
}
global $wpdb;
$wpdb->insert(self::table_name(), [
"session_id" => self::session_id(),
"url" => substr(self::current_path(), 0, 255),
"referrer" => substr(self::referrer(), 0, 255),
"device" => self::device($ua),
"browser" => self::browser($ua),
"country" => self::country(),
"created_at" => current_time("mysql"),
]);
}
private static function session_id()
{
if (!empty($_COOKIE[self::COOKIE])) {
return preg_replace("/[^a-f0-9]/", "", substr($_COOKIE[self::COOKIE], 0, 32));
}
$sid = md5(uniqid("", true));
// 30-minute rolling session window
if (!headers_sent()) {
setcookie(self::COOKIE, $sid, time() + 1800, COOKIEPATH ?: "/", COOKIE_DOMAIN);
}
$_COOKIE[self::COOKIE] = $sid;
return $sid;
}
private static function current_path()
{
$uri = isset($_SERVER["REQUEST_URI"]) ? $_SERVER["REQUEST_URI"] : "/";
return wp_parse_url($uri, PHP_URL_PATH) ?: "/";
}
private static function referrer()
{
$ref = isset($_SERVER["HTTP_REFERER"]) ? $_SERVER["HTTP_REFERER"] : "";
if (!$ref) {
return "";
}
$host = wp_parse_url($ref, PHP_URL_HOST);
// Ignore self-referrals
if ($host && $host === wp_parse_url(home_url(), PHP_URL_HOST)) {
return "";
}
return $host ?: "";
}
private static function device($ua)
{
$ua = strtolower($ua);
if (preg_match("/(ipad|tablet|playbook|silk)|(android(?!.*mobile))/", $ua)) {
return "tablet";
}
if (preg_match("/(mobile|iphone|ipod|android|blackberry|opera mini|iemobile)/", $ua)) {
return "mobile";
}
return "desktop";
}
private static function browser($ua)
{
$map = [
"Edg" => "Edge",
"OPR" => "Opera",
"Chrome" => "Chrome",
"Firefox" => "Firefox",
"Safari" => "Safari",
];
foreach ($map as $needle => $name) {
if (stripos($ua, $needle) !== false) {
return $name;
}
}
return "Other";
}
private static function country()
{
// Use a CDN-provided country header if available; otherwise leave blank.
foreach (["HTTP_CF_IPCOUNTRY", "GEOIP_COUNTRY_CODE", "HTTP_X_COUNTRY_CODE"] as $h) {
if (!empty($_SERVER[$h]) && strlen($_SERVER[$h]) === 2) {
return strtoupper(sanitize_text_field($_SERVER[$h]));
}
}
return "";
}
private static function is_bot($ua)
{
if (!$ua) {
return true;
}
return (bool) preg_match(
"/bot|crawl|slurp|spider|mediapartners|facebookexternalhit|embedly|quora|pingdom|monitor|headless|lighthouse|gtmetrix/i",
$ua
);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace AuroraAdmin\App;
use AuroraAdmin\Options\Settings;
use AuroraAdmin\Shell\Shell;
use AuroraAdmin\Pages\DashboardTakeover;
use AuroraAdmin\Pages\SettingsPage;
use AuroraAdmin\Rest\DashboardData;
use AuroraAdmin\Analytics\Analytics;
use AuroraAdmin\Rest\AnalyticsData;
use AuroraAdmin\Posts\ScheduledDayFilter;
use AuroraAdmin\WhiteLabel\WhiteLabel;
use AuroraAdmin\Pages\SubApps;
use AuroraAdmin\MenuCreator\MenuCreator;
use AuroraAdmin\MenuCreator\MenuCreatorData;
use AuroraAdmin\Notices\Notices;
use AuroraAdmin\Notices\NoticesData;
use AuroraAdmin\Roles\RolesData;
use AuroraAdmin\ActivityLog\ActivityLog;
use AuroraAdmin\ActivityLog\ActivityLogData;
use AuroraAdmin\DatabaseExplorer\DatabaseExplorerData;
defined("ABSPATH") || exit();
/**
* Plugin bootstrap. Deliberately minimal: each piece of functionality
* registers its own hooks in its own constructor, instantiated here.
* No global state, no manifest-parsing gymnastics beyond Assets::enqueue,
* no body-hider/temporary-style tricks.
*/
class AuroraAdmin
{
public function __construct()
{
new Settings();
new Shell();
new DashboardTakeover();
new SettingsPage();
new DashboardData();
new Analytics();
new AnalyticsData();
new ScheduledDayFilter();
new WhiteLabel();
new SubApps();
new MenuCreator();
new MenuCreatorData();
new Notices();
new NoticesData();
new RolesData();
new ActivityLog();
new ActivityLogData();
new DatabaseExplorerData();
}
}
@@ -0,0 +1,149 @@
<?php
namespace AuroraAdmin\DatabaseExplorer;
defined("ABSPATH") || exit();
/**
* Read-heavy admin dev tool for browsing this install's real database —
* table list with row counts/sizes, paginated row browsing, and a raw
* SELECT-only query runner. manage_options-gated throughout.
*
* Table/column identifiers can't go through $wpdb->prepare() (it only
* binds values, not identifiers), so the actual safety boundary here is
* validating every identifier against $wpdb's own known table list before
* use, then backtick-escaping it — never interpolating a client-supplied
* identifier unchecked.
*/
class DatabaseExplorerData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("manage_options");
};
register_rest_route("aurora-admin/v1", "/db-tables", [
"methods" => "GET",
"callback" => [self::class, "list_tables"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/db-tables/(?P<table>[a-zA-Z0-9_]+)", [
"methods" => "GET",
"callback" => [self::class, "browse_table"],
"permission_callback" => $permission,
"args" => [
"page" => ["type" => "integer", "required" => false, "default" => 1],
"per_page" => ["type" => "integer", "required" => false, "default" => 25],
],
]);
register_rest_route("aurora-admin/v1", "/db-query", [
"methods" => "POST",
"callback" => [self::class, "run_query"],
"permission_callback" => $permission,
"args" => [
"query" => ["type" => "string", "required" => true],
],
]);
}
private static function known_tables()
{
global $wpdb;
return $wpdb->get_col("SHOW TABLES");
}
public static function list_tables()
{
global $wpdb;
$rows = $wpdb->get_results("SHOW TABLE STATUS", ARRAY_A);
$tables = array_map(function ($row) {
return [
"name" => $row["Name"],
"rows" => (int) $row["Rows"],
"sizeBytes" => (int) $row["Data_length"] + (int) $row["Index_length"],
];
}, $rows ?: []);
return new \WP_REST_Response($tables, 200);
}
public static function browse_table($request)
{
global $wpdb;
$table = self::validate_table($request->get_param("table"));
if (!$table) {
return new \WP_Error("invalid_table", "Unknown table", ["status" => 404]);
}
$escaped = self::escape_identifier($table);
$per_page = max(1, min(100, (int) $request->get_param("per_page") ?: 25));
$page = max(1, (int) $request->get_param("page") ?: 1);
$offset = ($page - 1) * $per_page;
$columns = $wpdb->get_results("SHOW COLUMNS FROM {$escaped}", ARRAY_A);
$total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$escaped}");
$rows = $wpdb->get_results(
$wpdb->prepare("SELECT * FROM {$escaped} LIMIT %d OFFSET %d", $per_page, $offset),
ARRAY_A
);
return new \WP_REST_Response([
"columns" => array_map(function ($c) {
return ["name" => $c["Field"], "type" => $c["Type"]];
}, $columns),
"rows" => $rows,
"total" => $total,
"page" => $page,
"perPage" => $per_page,
"totalPages" => (int) ceil($total / $per_page),
], 200);
}
public static function run_query($request)
{
global $wpdb;
$query = trim((string) $request->get_param("query"));
if (!preg_match('/^(SELECT|SHOW|DESCRIBE|EXPLAIN)\b/i', $query)) {
return new \WP_Error(
"unsafe_query",
"Only SELECT / SHOW / DESCRIBE / EXPLAIN queries can be run here.",
["status" => 400]
);
}
// Guard against stacked statements — a single semicolon at the very
// end is fine, anything after it is not.
if (preg_match('/;.*\S/s', $query)) {
return new \WP_Error("unsafe_query", "Multiple statements aren't allowed.", ["status" => 400]);
}
if (!preg_match('/\bLIMIT\s+\d+/i', $query)) {
$query = rtrim(rtrim($query), ";") . " LIMIT 200";
}
$rows = $wpdb->get_results($query, ARRAY_A);
if ($rows === null) {
return new \WP_Error("query_error", $wpdb->last_error ?: "Query failed", ["status" => 400]);
}
return new \WP_REST_Response(["rows" => $rows, "count" => count($rows)], 200);
}
private static function validate_table($table)
{
$table = preg_replace("/[^a-zA-Z0-9_]/", "", (string) $table);
return in_array($table, self::known_tables(), true) ? $table : null;
}
private static function escape_identifier($identifier)
{
return "`" . str_replace("`", "``", $identifier) . "`";
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace AuroraAdmin\MenuCreator;
defined("ABSPATH") || exit();
/**
* Custom admin-menu configurations: hide/rename/reorder WP's native menu
* items per role. Storage + the runtime hook that actually applies a
* published config live here; REST CRUD lives in MenuCreatorData.php.
*/
class MenuCreator
{
const TABLE = "aurora_admin_menus";
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_menus_db_version";
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
// Late priority so it runs after all other plugins have registered
// their own menu items — there's nothing left to reorder/hide otherwise.
add_action("admin_menu", [self::class, "apply"], PHP_INT_MAX);
}
public static function table_name()
{
global $wpdb;
return $wpdb->prefix . self::TABLE;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(191) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'draft',
roles TEXT NULL,
items LONGTEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_status (status)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
/**
* Applies every published config whose target roles match the current
* user: hides items marked hidden and relabels items with a custom label.
*/
public static function apply()
{
global $menu, $wpdb;
if (!is_admin() || !function_exists("wp_get_current_user")) {
return;
}
$user = wp_get_current_user();
if (!$user || !$user->exists()) {
return;
}
$table = self::table_name();
$configs = $wpdb->get_results("SELECT roles, items FROM {$table} WHERE status = 'published'");
if (!$configs) {
return;
}
foreach ($configs as $config) {
$roles = json_decode($config->roles ?: "[]", true);
if (!is_array($roles) || !$roles) {
continue;
}
if (!array_intersect((array) $user->roles, $roles)) {
continue;
}
$items = json_decode($config->items ?: "[]", true);
if (!is_array($items)) {
continue;
}
foreach ($items as $item) {
$native_id = isset($item["native_id"]) ? $item["native_id"] : "";
if ($native_id === "") {
continue;
}
foreach ($menu as $key => $entry) {
if (!isset($entry[2]) || $entry[2] !== $native_id) {
continue;
}
if (!empty($item["hidden"])) {
unset($menu[$key]);
continue 2;
}
if (!empty($item["label"])) {
$menu[$key][0] = sanitize_text_field($item["label"]);
}
}
}
}
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace AuroraAdmin\MenuCreator;
defined("ABSPATH") || exit();
/**
* REST CRUD for saved menu configurations. GET/POST /menus,
* GET/PUT/DELETE /menus/:id. All manage_options-gated.
*/
class MenuCreatorData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("manage_options");
};
register_rest_route("aurora-admin/v1", "/menus", [
[
"methods" => "GET",
"callback" => [self::class, "list_menus"],
"permission_callback" => $permission,
"args" => [
"status" => ["type" => "string", "required" => false],
],
],
[
"methods" => "POST",
"callback" => [self::class, "create"],
"permission_callback" => $permission,
"args" => [
"name" => ["type" => "string", "required" => true],
"status" => ["type" => "string", "required" => false],
"roles" => ["type" => "array", "required" => false],
"items" => ["type" => "array", "required" => false],
],
],
]);
register_rest_route("aurora-admin/v1", "/menus/native-items", [
"methods" => "GET",
"callback" => [self::class, "native_items"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/menus/(?P<id>\d+)", [
[
"methods" => "GET",
"callback" => [self::class, "get_one"],
"permission_callback" => $permission,
],
[
"methods" => "PUT",
"callback" => [self::class, "update"],
"permission_callback" => $permission,
"args" => [
"name" => ["type" => "string", "required" => false],
"status" => ["type" => "string", "required" => false],
"roles" => ["type" => "array", "required" => false],
"items" => ["type" => "array", "required" => false],
],
],
[
"methods" => "DELETE",
"callback" => [self::class, "delete"],
"permission_callback" => $permission,
],
]);
}
/**
* The real, current WordPress menu (same source MenuSerializer feeds to
* the sidebar) flattened so the menu editor has something concrete to
* toggle hide/rename on — top-level items plus their submenu items.
*/
public static function native_items()
{
$menu = \AuroraAdmin\Shell\MenuSerializer::serialize();
$flat = [];
foreach ($menu as $item) {
if (!empty($item["separator"]) || empty($item["id"])) {
continue;
}
$flat[] = ["native_id" => $item["id"], "title" => $item["title"], "icon" => $item["icon"]];
foreach ($item["submenu"] ?? [] as $sub) {
if (empty($sub["id"])) {
continue;
}
$flat[] = ["native_id" => $sub["id"], "title" => $item["title"] . " › " . $sub["title"], "icon" => null];
}
}
return new \WP_REST_Response($flat, 200);
}
public static function list_menus($request)
{
global $wpdb;
$table = MenuCreator::table_name();
$status = $request->get_param("status");
if ($status) {
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$table} WHERE status = %s ORDER BY updated_at DESC",
$status
));
} else {
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY updated_at DESC");
}
return new \WP_REST_Response(array_map([self::class, "format"], $rows), 200);
}
public static function get_one($request)
{
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM " . MenuCreator::table_name() . " WHERE id = %d",
(int) $request->get_param("id")
));
if (!$row) {
return new \WP_Error("not_found", "Menu not found", ["status" => 404]);
}
return new \WP_REST_Response(self::format($row), 200);
}
public static function create($request)
{
global $wpdb;
$now = current_time("mysql");
$wpdb->insert(MenuCreator::table_name(), [
"name" => sanitize_text_field($request->get_param("name")),
"status" => self::sanitize_status($request->get_param("status")),
"roles" => wp_json_encode(self::sanitize_roles($request->get_param("roles"))),
"items" => wp_json_encode(self::sanitize_items($request->get_param("items"))),
"created_at" => $now,
"updated_at" => $now,
]);
return self::get_one(new \WP_REST_Request("GET", "", ["id" => $wpdb->insert_id]));
}
public static function update($request)
{
global $wpdb;
$id = (int) $request->get_param("id");
$fields = ["updated_at" => current_time("mysql")];
if ($request->get_param("name") !== null) {
$fields["name"] = sanitize_text_field($request->get_param("name"));
}
if ($request->get_param("status") !== null) {
$fields["status"] = self::sanitize_status($request->get_param("status"));
}
if ($request->get_param("roles") !== null) {
$fields["roles"] = wp_json_encode(self::sanitize_roles($request->get_param("roles")));
}
if ($request->get_param("items") !== null) {
$fields["items"] = wp_json_encode(self::sanitize_items($request->get_param("items")));
}
$wpdb->update(MenuCreator::table_name(), $fields, ["id" => $id]);
return self::get_one($request);
}
public static function delete($request)
{
global $wpdb;
$wpdb->delete(MenuCreator::table_name(), ["id" => (int) $request->get_param("id")]);
return new \WP_REST_Response(["deleted" => true], 200);
}
private static function format($row)
{
return [
"id" => (int) $row->id,
"name" => $row->name,
"status" => $row->status,
"roles" => json_decode($row->roles ?: "[]", true) ?: [],
"items" => json_decode($row->items ?: "[]", true) ?: [],
"createdAt" => $row->created_at,
"updatedAt" => $row->updated_at,
];
}
private static function sanitize_status($status)
{
return $status === "published" ? "published" : "draft";
}
private static function sanitize_roles($roles)
{
if (!is_array($roles)) {
return [];
}
$valid = array_keys(wp_roles()->roles);
return array_values(array_intersect(array_map("sanitize_key", $roles), $valid));
}
private static function sanitize_items($items)
{
if (!is_array($items)) {
return [];
}
$out = [];
foreach ($items as $item) {
if (!is_array($item) || empty($item["native_id"])) {
continue;
}
$out[] = [
"native_id" => sanitize_text_field($item["native_id"]),
"label" => isset($item["label"]) ? sanitize_text_field($item["label"]) : "",
"hidden" => !empty($item["hidden"]),
"order" => isset($item["order"]) ? (int) $item["order"] : 0,
];
}
return $out;
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace AuroraAdmin\Notices;
defined("ABSPATH") || exit();
/**
* Custom admin notices: storage + the runtime hook that actually renders
* active notices matching the current user/page. REST CRUD lives in
* NoticesData.php.
*/
class Notices
{
const TABLE = "aurora_admin_notices";
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_notices_db_version";
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
add_action("admin_notices", [self::class, "render"]);
}
public static function table_name()
{
global $wpdb;
return $wpdb->prefix . self::TABLE;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(191) NOT NULL DEFAULT '',
message LONGTEXT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'info',
roles TEXT NULL,
pages TEXT NULL,
dismissible TINYINT(1) NOT NULL DEFAULT 1,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_active (active)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
public static function render()
{
global $wpdb;
$user = wp_get_current_user();
if (!$user || !$user->exists()) {
return;
}
$current_page = isset($GLOBALS["pagenow"]) ? $GLOBALS["pagenow"] : "";
$rows = $wpdb->get_results(
"SELECT * FROM " . self::table_name() . " WHERE active = 1"
);
foreach ($rows as $row) {
$roles = json_decode($row->roles ?: "[]", true);
if (is_array($roles) && $roles && !array_intersect((array) $user->roles, $roles)) {
continue;
}
$pages = json_decode($row->pages ?: "[]", true);
if (is_array($pages) && $pages && !in_array($current_page, $pages, true)) {
continue;
}
$type = in_array($row->type, ["success", "warning", "error", "info"], true) ? $row->type : "info";
$classes = "notice notice-{$type} aurora-native-notice";
if ($row->dismissible) {
$classes .= " is-dismissible";
}
printf(
'<div class="%1$s"><p><strong>%2$s</strong>%3$s</p></div>',
esc_attr($classes),
esc_html($row->title),
$row->message ? ": " . esc_html($row->message) : ""
);
}
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
namespace AuroraAdmin\Notices;
defined("ABSPATH") || exit();
/**
* REST CRUD for admin notices. GET/POST /notices, PUT/DELETE /notices/:id.
*/
class NoticesData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("manage_options");
};
register_rest_route("aurora-admin/v1", "/notices", [
[
"methods" => "GET",
"callback" => [self::class, "list_notices"],
"permission_callback" => $permission,
],
[
"methods" => "POST",
"callback" => [self::class, "create"],
"permission_callback" => $permission,
"args" => [
"title" => ["type" => "string", "required" => true],
],
],
]);
register_rest_route("aurora-admin/v1", "/notices/(?P<id>\d+)", [
[
"methods" => "PUT",
"callback" => [self::class, "update"],
"permission_callback" => $permission,
],
[
"methods" => "DELETE",
"callback" => [self::class, "delete"],
"permission_callback" => $permission,
],
]);
}
public static function list_notices()
{
global $wpdb;
$rows = $wpdb->get_results("SELECT * FROM " . Notices::table_name() . " ORDER BY updated_at DESC");
return new \WP_REST_Response(array_map([self::class, "format"], $rows), 200);
}
public static function create($request)
{
global $wpdb;
$now = current_time("mysql");
$wpdb->insert(Notices::table_name(), [
"title" => sanitize_text_field($request->get_param("title")),
"message" => sanitize_textarea_field((string) $request->get_param("message")),
"type" => self::sanitize_type($request->get_param("type")),
"roles" => wp_json_encode(self::sanitize_list($request->get_param("roles"))),
"pages" => wp_json_encode(self::sanitize_list($request->get_param("pages"))),
"dismissible" => $request->get_param("dismissible") === false ? 0 : 1,
"active" => $request->get_param("active") === false ? 0 : 1,
"created_at" => $now,
"updated_at" => $now,
]);
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM " . Notices::table_name() . " WHERE id = %d",
$wpdb->insert_id
));
return new \WP_REST_Response(self::format($row), 200);
}
public static function update($request)
{
global $wpdb;
$id = (int) $request->get_param("id");
$fields = ["updated_at" => current_time("mysql")];
foreach (["title" => "sanitize_text_field", "message" => "sanitize_textarea_field"] as $key => $fn) {
if ($request->get_param($key) !== null) {
$fields[$key] = call_user_func($fn, (string) $request->get_param($key));
}
}
if ($request->get_param("type") !== null) {
$fields["type"] = self::sanitize_type($request->get_param("type"));
}
if ($request->get_param("roles") !== null) {
$fields["roles"] = wp_json_encode(self::sanitize_list($request->get_param("roles")));
}
if ($request->get_param("pages") !== null) {
$fields["pages"] = wp_json_encode(self::sanitize_list($request->get_param("pages")));
}
if ($request->get_param("dismissible") !== null) {
$fields["dismissible"] = $request->get_param("dismissible") ? 1 : 0;
}
if ($request->get_param("active") !== null) {
$fields["active"] = $request->get_param("active") ? 1 : 0;
}
$wpdb->update(Notices::table_name(), $fields, ["id" => $id]);
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM " . Notices::table_name() . " WHERE id = %d", $id));
return new \WP_REST_Response(self::format($row), 200);
}
public static function delete($request)
{
global $wpdb;
$wpdb->delete(Notices::table_name(), ["id" => (int) $request->get_param("id")]);
return new \WP_REST_Response(["deleted" => true], 200);
}
private static function format($row)
{
return [
"id" => (int) $row->id,
"title" => $row->title,
"message" => $row->message,
"type" => $row->type,
"roles" => json_decode($row->roles ?: "[]", true) ?: [],
"pages" => json_decode($row->pages ?: "[]", true) ?: [],
"dismissible" => (bool) $row->dismissible,
"active" => (bool) $row->active,
"createdAt" => $row->created_at,
"updatedAt" => $row->updated_at,
];
}
private static function sanitize_type($type)
{
return in_array($type, ["success", "warning", "error", "info"], true) ? $type : "info";
}
private static function sanitize_list($list)
{
if (!is_array($list)) {
return [];
}
return array_values(array_map("sanitize_text_field", $list));
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace AuroraAdmin\Options;
defined("ABSPATH") || exit();
/**
* Single source of truth for the aurora_admin_settings option.
* Deliberately simple — no request-lifetime caching layer, no schema
* ceremony beyond what register_setting requires. Add fields as pages
* actually need them.
*/
class Settings
{
const OPTION_NAME = "aurora_admin_settings";
public function __construct()
{
add_action("init", [self::class, "register"]);
}
public static function register()
{
register_setting("aurora-admin", self::OPTION_NAME, [
"type" => "object",
"default" => [],
"capability" => "manage_options",
// Merge incoming payloads into the stored value instead of replacing
// it wholesale. This lets pages save just the keys they own (e.g. the
// dashboard sends only dashboard_card_order, the shell only dark_mode)
// without wiping settings they never received — which matters because
// most pages get a sanitized copy with secrets stripped (see
// get_safe()) and must never write that stripped copy back.
"sanitize_callback" => [self::class, "merge_on_save"],
"show_in_rest" => [
"schema" => [
"type" => "object",
"properties" => [
"dark_mode" => ["type" => "string"],
"dashboard_card_order" => ["type" => "object"],
],
"additionalProperties" => true,
],
],
]);
}
public static function merge_on_save($value)
{
$existing = get_option(self::OPTION_NAME, []);
if (!is_array($existing)) {
$existing = [];
}
if (!is_array($value)) {
return $existing;
}
return array_merge($existing, $value);
}
public static function get($key = null, $default = null)
{
$settings = get_option(self::OPTION_NAME, []);
if (!is_array($settings)) {
$settings = [];
}
if ($key === null) {
return $settings;
}
return isset($settings[$key]) ? $settings[$key] : $default;
}
/**
* Settings safe to expose on every admin page (the shell serializes this
* for all logged-in users). Strips secrets: the license key and remote
* site application passwords. The settings page itself (manage_options
* only) uses get() and receives the full value.
*/
public static function get_safe()
{
$settings = self::get();
unset($settings["license_key"]);
if (isset($settings["remote_sites"]) && is_array($settings["remote_sites"])) {
$settings["remote_sites"] = array_values(array_map(function ($site) {
return [
"url" => isset($site["url"]) ? $site["url"] : "",
"username" => isset($site["username"]) ? $site["username"] : "",
];
}, $settings["remote_sites"]));
}
return $settings;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
class Dashboard
{
private static $hook_suffix = null;
public function __construct()
{
add_action("admin_menu", [self::class, "register_menu"]);
// Runs late (after submenus are registered) to drop the duplicate
// first submenu item WordPress auto-creates for a top-level menu.
add_action("admin_menu", [self::class, "remove_duplicate_submenu"], 100);
add_action("admin_enqueue_scripts", [self::class, "maybe_enqueue"]);
}
public static function register_menu()
{
self::$hook_suffix = add_menu_page(
__("Aurora Admin", "aurora-admin"),
__("Aurora Admin", "aurora-admin"),
"read",
"aurora-admin",
[self::class, "render"],
"dashicons-admin-generic",
3
);
}
/**
* WordPress auto-adds a submenu item duplicating the top-level menu
* (same "Aurora Admin" label + slug). Remove it so the submenu only
* shows the real child pages (e.g. Settings).
*/
public static function remove_duplicate_submenu()
{
remove_submenu_page("aurora-admin", "aurora-admin");
}
public static function maybe_enqueue($hook_suffix)
{
if ($hook_suffix !== self::$hook_suffix) {
return;
}
Assets::enqueue("aurora-admin-dashboard", "entries/dashboard.js");
}
public static function render()
{
$current_user = wp_get_current_user();
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"userName" => $current_user->display_name,
"settings" => Settings::get(),
];
?>
<div
id="aurora-admin-dashboard-root"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Replaces the native WordPress dashboard (index.php) with Aurora's own
* dashboard, the way the original plugin did.
*
* Rather than removing/moving WordPress's dashboard DOM, we render the
* Aurora mount point at the very top of the content area (the
* `in_admin_header` hook fires there, before the native .wrap) and simply
* hide the native dashboard's .wrap with CSS. Clean and reversible — if
* this plugin is disabled, the native dashboard returns untouched.
*/
class DashboardTakeover
{
public function __construct()
{
add_action("admin_enqueue_scripts", [self::class, "maybe_enqueue"]);
add_action("admin_head", [self::class, "hide_native"]);
add_action("in_admin_header", [self::class, "render_mount"]);
}
public static function is_dashboard()
{
return isset($GLOBALS["pagenow"]) && $GLOBALS["pagenow"] === "index.php";
}
public static function maybe_enqueue()
{
if (!self::is_dashboard()) {
return;
}
Assets::enqueue("aurora-admin-dashboard", "entries/dashboard.js");
}
public static function hide_native()
{
if (!self::is_dashboard()) {
return;
}
?>
<style id="aurora-admin-dashboard-takeover">
/* The Aurora dashboard is rendered in #wpcontent (above #wpbody) via
in_admin_header, so the entire native #wpbody — heading, welcome
panel, widgets, and the Screen Options / Help tabs inside it — is
hidden. This leaves just the Aurora dashboard with no empty native
frame below it. Printed only on the dashboard screen. */
html.aurora-active #wpbody {
display: none !important;
}
</style>
<?php
}
public static function render_mount()
{
if (!self::is_dashboard()) {
return;
}
$current_user = wp_get_current_user();
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"userName" => $current_user->display_name,
"settings" => Settings::get_safe(),
];
?>
<div
id="aurora-admin-dashboard-root"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
class SettingsPage
{
private static $hook_suffix = null;
public function __construct()
{
add_action("admin_menu", [self::class, "register_menu"]);
add_action("admin_enqueue_scripts", [self::class, "maybe_enqueue"]);
}
public static function register_menu()
{
// Top-level "Aurora Admin" menu opens the Settings page directly. The
// Aurora dashboard is reached through WordPress's own Dashboard menu
// (taken over on index.php), so this menu has no submenu of its own.
self::$hook_suffix = add_menu_page(
__("Aurora Admin", "aurora-admin"),
__("Aurora Admin", "aurora-admin"),
"manage_options",
"aurora-admin",
[self::class, "render"],
"dashicons-admin-generic",
3
);
}
public static function maybe_enqueue($hook_suffix)
{
if ($hook_suffix !== self::$hook_suffix) {
return;
}
Assets::enqueue("aurora-admin-settings", "entries/settings.js");
}
public static function render()
{
// Option lists for multiselect fields (roles for "Disable theme",
// public post types for "Search post types").
$roles = [];
foreach (wp_roles()->roles as $slug => $info) {
$roles[] = ["value" => $slug, "label" => translate_user_role($info["name"])];
}
$post_types = [];
foreach (get_post_types(["public" => true], "objects") as $pt) {
if ($pt->name === "attachment") {
continue;
}
$post_types[] = ["value" => $pt->name, "label" => $pt->labels->name];
}
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"userName" => wp_get_current_user()->display_name,
// Full settings (including secrets) — this page is manage_options-only
// and needs to display/edit them.
"settings" => Settings::get(),
"roles" => $roles,
"postTypes" => $post_types,
];
?>
<div
id="aurora-admin-settings-root"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
defined("ABSPATH") || exit();
/**
* Registers Aurora's 5 secondary full-page apps (Menu Creator, Admin
* Notices, Role Editor, Activity Log, Database Explorer). Each is a real WP
* admin page (proper capability check, working URL, its own hook_suffix for
* asset gating) registered as a submenu of "aurora-admin" — then
* immediately hidden from WordPress's own submenu via remove_submenu_page(),
* the standard register-but-hide trick. This keeps them out of the
* WP-native menu list (MenuSerializer never sees them, nothing to filter)
* while still being reachable, permission-checked pages; they're presented
* instead in the sidebar's dedicated Aurora nav section (see Shell::aurora_nav()).
*/
class SubApps
{
const PAGES = [
"aurora-admin-menu-creator" => ["title" => "Menu Creator", "entry" => "menu-creator"],
"aurora-admin-notices" => ["title" => "Admin Notices", "entry" => "admin-notices"],
"aurora-admin-roles" => ["title" => "Role Editor", "entry" => "role-editor"],
"aurora-admin-activity-log" => ["title" => "Activity Log", "entry" => "activity-log"],
"aurora-admin-database" => ["title" => "Database Explorer", "entry" => "database-explorer"],
];
private static $hook_suffixes = [];
public function __construct()
{
add_action("admin_menu", [self::class, "register_menus"]);
add_action("admin_menu", [self::class, "hide_from_native_submenu"], 999);
add_action("admin_enqueue_scripts", [self::class, "maybe_enqueue"]);
}
public static function register_menus()
{
foreach (self::PAGES as $slug => $page) {
$hook = add_submenu_page(
"aurora-admin",
__($page["title"], "aurora-admin"),
__($page["title"], "aurora-admin"),
"manage_options",
$slug,
[self::class, "render"]
);
self::$hook_suffixes[$slug] = $hook;
}
}
public static function hide_from_native_submenu()
{
foreach (array_keys(self::PAGES) as $slug) {
remove_submenu_page("aurora-admin", $slug);
}
}
public static function maybe_enqueue($hook_suffix)
{
$slug = array_search($hook_suffix, self::$hook_suffixes, true);
if ($slug === false || !isset(self::PAGES[$slug])) {
return;
}
Assets::enqueue("aurora-admin-" . self::PAGES[$slug]["entry"], "entries/" . self::PAGES[$slug]["entry"] . ".js");
}
public static function render()
{
$page = isset($_GET["page"]) ? sanitize_text_field(wp_unslash($_GET["page"])) : "";
if (!isset(self::PAGES[$page])) {
return;
}
$roles = [];
foreach (wp_roles()->roles as $slug => $info) {
$roles[] = ["value" => $slug, "label" => translate_user_role($info["name"])];
}
global $wpdb;
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"userName" => wp_get_current_user()->display_name,
"roles" => $roles,
"tablePrefix" => $wpdb->prefix,
];
$root_id = "aurora-admin-" . self::PAGES[$page]["entry"] . "-root";
?>
<div
id="<?php echo esc_attr($root_id); ?>"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace AuroraAdmin\Posts;
defined("ABSPATH") || exit();
/**
* When the posts list (edit.php) is opened with ?aurora_day=YYYY-MM-DD
* (from the dashboard's Scheduled Content calendar), constrain the main
* query to scheduled posts on that exact day.
*/
class ScheduledDayFilter
{
public function __construct()
{
add_action("pre_get_posts", [self::class, "filter"]);
add_action("in_admin_header", [self::class, "notice"]);
}
private static function day()
{
$day = isset($_GET["aurora_day"]) ? sanitize_text_field(wp_unslash($_GET["aurora_day"])) : "";
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $day) ? $day : "";
}
public static function filter($query)
{
if (!is_admin() || !$query->is_main_query()) {
return;
}
if (($GLOBALS["pagenow"] ?? "") !== "edit.php") {
return;
}
$day = self::day();
if (!$day) {
return;
}
[$y, $m, $d] = array_map("intval", explode("-", $day));
$query->set("post_status", "future");
$query->set("date_query", [["year" => $y, "month" => $m, "day" => $d]]);
}
/**
* Small heading on the filtered posts list so it's clear you're viewing a
* single day's scheduled content (with a link back to all scheduled).
*/
public static function notice()
{
if (($GLOBALS["pagenow"] ?? "") !== "edit.php") {
return;
}
$day = self::day();
if (!$day) {
return;
}
$label = date_i18n(get_option("date_format"), strtotime($day));
$all = admin_url("edit.php?post_status=future");
?>
<div class="notice notice-info" style="margin-top:12px;">
<p>
<?php printf(
/* translators: %s: formatted date */
esc_html__("Showing scheduled content for %s.", "aurora-admin"),
"<strong>" . esc_html($label) . "</strong>"
); ?>
&nbsp;<a href="<?php echo esc_url($all); ?>"><?php esc_html_e("View all scheduled", "aurora-admin"); ?></a>
</p>
</div>
<?php
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace AuroraAdmin\Rest;
use AuroraAdmin\Analytics\Analytics;
defined("ABSPATH") || exit();
/**
* Aggregates the first-party pageview table into the figures the Analytics
* tab cards need. GET aurora-admin/v1/analytics?from=YYYY-MM-DD&to=YYYY-MM-DD
*/
class AnalyticsData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/analytics", [
"methods" => "GET",
"callback" => [self::class, "handle"],
"permission_callback" => function () {
return current_user_can("read");
},
]);
}
public static function handle($request)
{
global $wpdb;
$table = Analytics::table_name();
$from = $request->get_param("from") ?: gmdate("Y-m-d", strtotime("-7 days"));
$to = $request->get_param("to") ?: gmdate("Y-m-d");
$from_dt = $from . " 00:00:00";
$to_dt = $to . " 23:59:59";
$where = $wpdb->prepare("created_at BETWEEN %s AND %s", $from_dt, $to_dt);
// --- Totals ---
$total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE {$where}");
$unique = (int) $wpdb->get_var("SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE {$where}");
// --- Daily series ---
$rows = $wpdb->get_results(
"SELECT DATE(created_at) AS d, COUNT(*) AS c FROM {$table} WHERE {$where} GROUP BY DATE(created_at)",
OBJECT_K
);
$series = [];
$cursor = strtotime($from);
$end = strtotime($to);
while ($cursor <= $end) {
$key = gmdate("Y-m-d", $cursor);
$series[] = [
"date" => gmdate("M j", $cursor),
"count" => isset($rows[$key]) ? (int) $rows[$key]->c : 0,
];
$cursor = strtotime("+1 day", $cursor);
}
// --- Device breakdown ---
$devices = $wpdb->get_results(
"SELECT device, COUNT(*) AS c FROM {$table} WHERE {$where} GROUP BY device ORDER BY c DESC"
);
$deviceUsage = [];
foreach ($devices as $d) {
$deviceUsage[] = [
"device" => $d->device,
"count" => (int) $d->c,
"pct" => $total ? round(($d->c / $total) * 100) : 0,
];
}
// --- Top pages / referrers / countries ---
$topPages = self::top($wpdb, $table, $where, "url", 6);
$topReferrers = self::top($wpdb, $table, $where, "referrer", 6, true);
$topCountries = self::top($wpdb, $table, $where, "country", 6, true);
// --- Bounce rate & avg session depth ---
$sessionCounts = $wpdb->get_results(
"SELECT session_id, COUNT(*) AS c,
TIMESTAMPDIFF(SECOND, MIN(created_at), MAX(created_at)) AS dur
FROM {$table} WHERE {$where} GROUP BY session_id"
);
$sessions = count($sessionCounts);
$bounces = 0;
$totalDur = 0;
foreach ($sessionCounts as $s) {
if ((int) $s->c <= 1) {
$bounces++;
}
$totalDur += (int) $s->dur;
}
$bounceRate = $sessions ? round(($bounces / $sessions) * 100) : 0;
$avgSeconds = $sessions ? round($totalDur / $sessions) : 0;
// --- Active users (last 5 minutes) ---
$activeCutoff = gmdate("Y-m-d H:i:s", current_time("timestamp") - 300);
$active = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE created_at >= %s",
$activeCutoff
)
);
return new \WP_REST_Response([
"pageViews" => ["total" => $total, "unique" => $unique, "series" => $series],
"deviceUsage" => $deviceUsage,
"topPages" => $topPages,
"topReferrers" => $topReferrers,
"topCountries" => $topCountries,
"bounce" => [
"rate" => $bounceRate,
"avgTime" => self::format_duration($avgSeconds),
"sessions" => $sessions,
],
"activeUsers" => $active,
], 200);
}
private static function top($wpdb, $table, $where, $col, $limit, $skip_empty = false)
{
$extra = $skip_empty ? " AND {$col} <> ''" : "";
$results = $wpdb->get_results(
"SELECT {$col} AS label, COUNT(*) AS c FROM {$table}
WHERE {$where}{$extra} GROUP BY {$col} ORDER BY c DESC LIMIT {$limit}"
);
$out = [];
foreach ($results as $r) {
$out[] = ["label" => $r->label !== "" ? $r->label : "Direct", "count" => (int) $r->c];
}
return $out;
}
private static function format_duration($seconds)
{
if ($seconds <= 0) {
return "0s";
}
$m = floor($seconds / 60);
$s = $seconds % 60;
return $m > 0 ? "{$m}m {$s}s" : "{$s}s";
}
}
+292
View File
@@ -0,0 +1,292 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoint that supplies all data for the Aurora dashboard cards.
* GET aurora-admin/v1/dashboard?from=YYYY-MM-DD&to=YYYY-MM-DD
*
* Everything is computed fresh here from core WordPress APIs — no reuse of
* the original plugin's data layer.
*/
class DashboardData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/dashboard", [
"methods" => "GET",
"callback" => [self::class, "handle"],
"permission_callback" => function () {
return current_user_can("read");
},
"args" => [
"from" => ["type" => "string", "required" => false],
"to" => ["type" => "string", "required" => false],
],
]);
}
public static function handle($request)
{
$to = self::parse_date($request->get_param("to"), "now");
$from = self::parse_date($request->get_param("from"), "-7 days");
return new \WP_REST_Response([
"recentContent" => self::recent_content(),
"scheduledContent" => self::scheduled_content(),
"recentComments" => self::recent_comments(),
"userAnalytics" => self::user_analytics($from, $to),
"mediaAnalytics" => self::media_analytics(),
"serverHealth" => self::server_health(),
], 200);
}
private static function parse_date($value, $fallback)
{
$ts = $value ? strtotime($value) : false;
if ($ts === false) {
$ts = strtotime($fallback);
}
return $ts;
}
private static function recent_content()
{
$counts = wp_count_posts("post");
$recent = get_posts([
"post_type" => "post",
"post_status" => ["publish", "draft", "future"],
"numberposts" => 5,
"orderby" => "date",
"order" => "DESC",
]);
$latest = [];
foreach ($recent as $p) {
$latest[] = [
"id" => $p->ID,
"title" => get_the_title($p),
"author" => get_the_author_meta("display_name", $p->post_author),
"date" => get_the_date("M j, Y", $p),
"status" => $p->post_status,
"editUrl" => get_edit_post_link($p->ID, "raw"),
];
}
return [
"published" => (int) $counts->publish,
"draft" => (int) $counts->draft,
"scheduled" => (int) $counts->future,
"total" => (int) $counts->publish + (int) $counts->draft + (int) $counts->future,
"latest" => $latest,
"listUrl" => admin_url("edit.php"),
];
}
private static function scheduled_content()
{
$future = get_posts([
"post_type" => "post",
"post_status" => "future",
"numberposts" => 100,
"orderby" => "date",
"order" => "ASC",
]);
$days = [];
foreach ($future as $p) {
$days[get_the_date("Y-m-d", $p)][] = [
"id" => $p->ID,
"title" => get_the_title($p),
"time" => get_the_time("g:i a", $p),
];
}
return [
"count" => count($future),
"days" => $days,
];
}
private static function recent_comments()
{
$counts = wp_count_comments();
$recent = get_comments([
"number" => 5,
"status" => "approve",
"orderby" => "comment_date_gmt",
"order" => "DESC",
]);
$latest = [];
foreach ($recent as $c) {
$latest[] = [
"id" => $c->comment_ID,
"author" => $c->comment_author,
"excerpt" => wp_trim_words($c->comment_content, 18),
"postTitle" => get_the_title($c->comment_post_ID),
"date" => mysql2date("M j, Y", $c->comment_date),
];
}
return [
"approved" => (int) $counts->approved,
"pending" => (int) $counts->moderated,
"spam" => (int) $counts->spam,
"total" => (int) $counts->approved,
"latest" => $latest,
"listUrl" => admin_url("edit-comments.php"),
];
}
private static function user_analytics($from, $to)
{
$total = count_users();
$total_users = (int) $total["total_users"];
$from_date = gmdate("Y-m-d 00:00:00", $from);
$to_date = gmdate("Y-m-d 23:59:59", $to);
$in_range = get_users([
"date_query" => [["after" => $from_date, "before" => $to_date, "inclusive" => true]],
"fields" => "ID",
]);
$recent7 = get_users([
"date_query" => [["after" => "-7 days", "inclusive" => true]],
"fields" => "ID",
]);
// Daily registration series across the range
$series = [];
$cursor = strtotime(gmdate("Y-m-d", $from));
$end = strtotime(gmdate("Y-m-d", $to));
$by_day = [];
foreach ($in_range as $uid) {
$reg = get_userdata($uid)->user_registered;
$by_day[mysql2date("Y-m-d", $reg)] = (isset($by_day[mysql2date("Y-m-d", $reg)]) ? $by_day[mysql2date("Y-m-d", $reg)] : 0) + 1;
}
while ($cursor <= $end) {
$key = gmdate("Y-m-d", $cursor);
$series[] = [
"date" => gmdate("M j", $cursor),
"count" => isset($by_day[$key]) ? $by_day[$key] : 0,
];
$cursor = strtotime("+1 day", $cursor);
}
return [
"total" => $total_users,
"inRange" => count($in_range),
"recent7d" => count($recent7),
"series" => $series,
];
}
private static function media_analytics()
{
$attachments = get_posts([
"post_type" => "attachment",
"post_status" => "inherit",
"numberposts" => -1,
"fields" => "ids",
]);
$total_size = 0;
$large = 0;
$recent30 = 0;
$thirty_ago = strtotime("-30 days");
foreach ($attachments as $id) {
$file = get_attached_file($id);
$size = $file && file_exists($file) ? filesize($file) : 0;
$total_size += $size;
if ($size > 5 * 1024 * 1024) {
$large++;
}
if (strtotime(get_post_field("post_date", $id)) >= $thirty_ago) {
$recent30++;
}
}
return [
"totalFiles" => count($attachments),
"totalSize" => self::format_bytes($total_size),
"recent30d" => $recent30,
"unused" => 0,
"largeFiles" => $large,
"diskUsage" => self::format_bytes($total_size),
"manageUrl" => admin_url("upload.php"),
];
}
private static function server_health()
{
$mem_limit = self::to_bytes(ini_get("memory_limit"));
$mem_used = memory_get_usage(true);
$disk_total = @disk_total_space(ABSPATH) ?: 0;
$disk_free = @disk_free_space(ABSPATH) ?: 0;
$disk_used = $disk_total - $disk_free;
$updates = function_exists("wp_get_update_data") ? wp_get_update_data() : ["counts" => []];
$counts = isset($updates["counts"]) ? $updates["counts"] : [];
return [
"wp" => get_bloginfo("version"),
"php" => PHP_VERSION,
"memory" => [
"used" => self::format_bytes($mem_used),
"total" => self::format_bytes($mem_limit),
"pct" => $mem_limit > 0 ? round(($mem_used / $mem_limit) * 100, 1) : 0,
],
"disk" => [
"used" => self::format_bytes($disk_used),
"total" => self::format_bytes($disk_total),
"pct" => $disk_total > 0 ? round(($disk_used / $disk_total) * 100, 1) : 0,
],
"plugins" => isset($counts["plugins"]) ? (int) $counts["plugins"] : 0,
"themes" => isset($counts["themes"]) ? (int) $counts["themes"] : 0,
"core" => isset($counts["wordpress"]) ? (int) $counts["wordpress"] : 0,
"updates" => isset($counts["total"]) ? (int) $counts["total"] : 0,
"ssl" => is_ssl(),
"timezone" => wp_timezone_string(),
];
}
private static function format_bytes($bytes)
{
$bytes = (float) $bytes;
$units = ["B", "KB", "MB", "GB", "TB"];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . " " . $units[$i];
}
private static function to_bytes($val)
{
$val = trim($val);
if ($val === "" || $val === "-1") {
return 0;
}
$last = strtolower($val[strlen($val) - 1]);
$num = (float) $val;
switch ($last) {
case "g":
$num *= 1024;
case "m":
$num *= 1024;
case "k":
$num *= 1024;
}
return $num;
}
}
+166
View File
@@ -0,0 +1,166 @@
<?php
namespace AuroraAdmin\Roles;
defined("ABSPATH") || exit();
/**
* REST CRUD wrapping WordPress's own roles/capabilities API — no custom
* table, this reads and writes the real wp_user_roles option via WP_Roles.
*/
class RolesData
{
// WordPress has no built-in "is this a default role" check — these are
// the five core roles, hard-coded here as delete-protected.
const PROTECTED_ROLES = ["administrator", "editor", "author", "contributor", "subscriber"];
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("manage_options");
};
register_rest_route("aurora-admin/v1", "/roles", [
[
"methods" => "GET",
"callback" => [self::class, "list_roles"],
"permission_callback" => $permission,
],
[
"methods" => "POST",
"callback" => [self::class, "create"],
"permission_callback" => $permission,
"args" => [
"slug" => ["type" => "string", "required" => true],
"displayName" => ["type" => "string", "required" => true],
],
],
]);
register_rest_route("aurora-admin/v1", "/roles/(?P<slug>[a-z0-9_\-]+)", [
[
"methods" => "PUT",
"callback" => [self::class, "update"],
"permission_callback" => $permission,
],
[
"methods" => "DELETE",
"callback" => [self::class, "delete"],
"permission_callback" => $permission,
],
]);
}
public static function list_roles()
{
$editable = get_editable_roles();
$all_caps = self::all_known_capabilities();
$out = [];
foreach ($editable as $slug => $role) {
$user_count = count(get_users(["role" => $slug, "fields" => "ID"]));
$out[] = [
"slug" => $slug,
"name" => translate_user_role($role["name"]),
"capabilities" => array_keys(array_filter($role["capabilities"])),
"userCount" => $user_count,
"isDefault" => in_array($slug, self::PROTECTED_ROLES, true),
];
}
return new \WP_REST_Response(["roles" => $out, "allCapabilities" => $all_caps], 200);
}
public static function create($request)
{
$slug = sanitize_key($request->get_param("slug"));
$display_name = sanitize_text_field($request->get_param("displayName"));
if (!$slug || !$display_name) {
return new \WP_Error("invalid_role", "A role slug and name are required", ["status" => 400]);
}
if (get_role($slug)) {
return new \WP_Error("role_exists", "A role with this slug already exists", ["status" => 409]);
}
$caps = self::sanitize_caps($request->get_param("capabilities"));
add_role($slug, $display_name, array_fill_keys($caps, true));
return new \WP_REST_Response(["slug" => $slug, "name" => $display_name, "capabilities" => $caps], 200);
}
public static function update($request)
{
$slug = $request->get_param("slug");
$role = get_role($slug);
if (!$role) {
return new \WP_Error("not_found", "Role not found", ["status" => 404]);
}
if ($request->get_param("capabilities") !== null) {
$desired = self::sanitize_caps($request->get_param("capabilities"));
foreach (array_keys($role->capabilities) as $existing_cap) {
if (!in_array($existing_cap, $desired, true)) {
$role->remove_cap($existing_cap);
}
}
foreach ($desired as $cap) {
$role->add_cap($cap);
}
}
if ($request->get_param("displayName")) {
global $wp_roles;
$wp_roles->roles[$slug]["name"] = sanitize_text_field($request->get_param("displayName"));
$wp_roles->role_names[$slug] = $wp_roles->roles[$slug]["name"];
update_option($wp_roles->role_key, $wp_roles->roles);
}
return new \WP_REST_Response(["updated" => true], 200);
}
public static function delete($request)
{
$slug = $request->get_param("slug");
if (in_array($slug, self::PROTECTED_ROLES, true)) {
return new \WP_Error("protected_role", "Default WordPress roles can't be deleted", ["status" => 403]);
}
if (!get_role($slug)) {
return new \WP_Error("not_found", "Role not found", ["status" => 404]);
}
remove_role($slug);
return new \WP_REST_Response(["deleted" => true], 200);
}
private static function sanitize_caps($caps)
{
if (!is_array($caps)) {
return [];
}
return array_values(array_unique(array_map("sanitize_key", $caps)));
}
/**
* WordPress has no single "list all capabilities" API — build one from
* every capability already assigned across existing roles, which is what
* every native role-editor plugin does in practice.
*/
private static function all_known_capabilities()
{
$caps = [];
foreach (wp_roles()->roles as $role) {
foreach (array_keys($role["capabilities"]) as $cap) {
$caps[$cap] = true;
}
}
$caps = array_keys($caps);
sort($caps);
return $caps;
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace AuroraAdmin\Shell;
defined("ABSPATH") || exit();
/**
* Turns WordPress's global $menu / $submenu structures into a clean,
* JSON-friendly array the Vue sidebar can render.
*
* This is the deliberate, readable replacement for the original plugin's
* reverse-engineered menu reconstruction. It reads the same source of
* truth WordPress itself uses to paint the admin menu, so third-party
* plugin menu items appear automatically.
*/
class MenuSerializer
{
/**
* @return array List of top-level menu items, each:
* [ id, title, url, icon, count, separator(bool), submenu[] ]
*/
public static function serialize()
{
global $menu, $submenu;
if (!is_array($menu)) {
return [];
}
$items = [];
foreach ($menu as $item) {
// $item indexes (WordPress core):
// 0 => menu title (may contain HTML), 1 => capability,
// 2 => menu slug, 4 => classes, 6 => icon
$raw_title = isset($item[0]) ? $item[0] : "";
$capability = isset($item[1]) ? $item[1] : "";
$slug = isset($item[2]) ? $item[2] : "";
$classes = isset($item[4]) ? $item[4] : "";
$icon = isset($item[6]) ? $item[6] : "";
// Separators have a wp-menu-separator class and no real title.
if (strpos($classes, "wp-menu-separator") !== false) {
$items[] = ["separator" => true];
continue;
}
if (empty($capability) || !current_user_can($capability)) {
continue;
}
// Aurora's own top-level page is represented in the sidebar's
// dedicated branded nav section (see Shell::print_mount()'s
// "auroraNav"), not in this WP-native-menu list — skip it here so it
// isn't shown twice.
if ($slug === "aurora-admin") {
continue;
}
$title_data = self::clean_title($raw_title);
$entry = [
"id" => $slug,
"title" => $title_data["title"],
"url" => self::resolve_url($slug),
"icon" => self::resolve_icon($icon),
"count" => $title_data["count"],
"separator" => false,
"submenu" => [],
];
if (isset($submenu[$slug]) && is_array($submenu[$slug])) {
foreach ($submenu[$slug] as $sub) {
$sub_cap = isset($sub[1]) ? $sub[1] : "";
if (empty($sub_cap) || !current_user_can($sub_cap)) {
continue;
}
$sub_title = self::clean_title(isset($sub[0]) ? $sub[0] : "");
$sub_slug = isset($sub[2]) ? $sub[2] : "";
$entry["submenu"][] = [
"id" => $sub_slug,
"title" => $sub_title["title"],
"url" => self::resolve_url($sub_slug, $slug),
"count" => $sub_title["count"],
];
}
}
$items[] = $entry;
}
return $items;
}
/**
* Strips the markup WordPress packs into menu titles (update/count
* bubbles) and pulls the numeric count out separately.
*
* @return array{title:string,count:int|null}
*/
private static function clean_title($raw)
{
$count = null;
if (preg_match('/<span[^>]*>(\d+)/', $raw, $matches)) {
$count = (int) $matches[1];
}
// WordPress menu titles are "Label <span ...>count bubble</span>" — the
// human label is always the plain text before the first tag, so take
// that rather than stripping tags (which would leave the count digits
// dangling in the title).
$label = $raw;
$first_tag = strpos($raw, "<");
if ($first_tag !== false) {
$label = substr($raw, 0, $first_tag);
}
$title = trim(wp_strip_all_tags($label));
return ["title" => $title, "count" => $count];
}
/**
* Builds the admin URL for a menu slug, mirroring how WordPress decides
* between a direct file (foo.php) and a registered page (admin.php?page=).
*/
private static function resolve_url($slug, $parent_slug = "")
{
if (empty($slug)) {
return "";
}
// Already an absolute URL.
if (preg_match('#^https?://#', $slug)) {
return $slug;
}
// A slug pointing at a real admin file, possibly with a query string.
if (strpos($slug, ".php") !== false) {
return admin_url($slug);
}
// Submenu of a file-based parent (e.g. edit.php) — the child slug is a
// file too, handled above; otherwise it's a page registered under that
// parent's screen, reached via the parent file with ?page=.
if (!empty($parent_slug) && strpos($parent_slug, ".php") !== false) {
return admin_url(
$parent_slug . (strpos($parent_slug, "?") !== false ? "&" : "?") . "page=" . $slug
);
}
return admin_url("admin.php?page=" . $slug);
}
/**
* Normalizes the menu icon into something the Vue side can render:
* a dashicon class name, an image URL, or null for "use a default".
*/
private static function resolve_icon($icon)
{
if (empty($icon) || $icon === "none" || $icon === "div") {
return null;
}
if (strpos($icon, "dashicons-") === 0) {
return ["type" => "dashicon", "value" => $icon];
}
if (strpos($icon, "data:image") === 0 || preg_match('#^https?://#', $icon)) {
return ["type" => "image", "value" => $icon];
}
return null;
}
}
+251
View File
@@ -0,0 +1,251 @@
<?php
namespace AuroraAdmin\Shell;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* The site-wide immersive admin shell.
*
* Renders a custom Vue sidebar + toolbar on every admin screen and frames
* WordPress's own content area inside a styled container. Crucially it does
* NOT relocate or re-implement WP's content — native pages render normally
* inside the frame, so they keep working without any reskinning.
*
* Native chrome (admin menu, toolbar, footer) is hidden via a small set of
* explicit ID selectors gated on an html.aurora-loading class. A failsafe
* reveals native admin again if the Vue app never signals ready, so a
* broken build can never lock the user out of wp-admin.
*/
class Shell
{
public function __construct()
{
// Print the chrome-hiding CSS + loading class as early as possible so
// the native menu/toolbar never flash before the shell takes over.
add_action("admin_head", [self::class, "print_early_styles"], 0);
add_action("admin_enqueue_scripts", [self::class, "enqueue"]);
// Mount point + serialized data, output at the end of the admin body.
add_action("admin_footer", [self::class, "print_mount"]);
}
/**
* Whether the shell should run on the current request. Skipped on a few
* full-screen native experiences that own the whole viewport.
*/
private static function is_enabled()
{
if (!is_admin()) {
return false;
}
$screen = function_exists("get_current_screen") ? get_current_screen() : null;
if ($screen && in_array($screen->id, ["site-editor", "customize"], true)) {
return false;
}
// Settings > General > "Disable theme": roles the shell is turned off
// for — those users get the native WordPress admin.
$disabled_roles = Settings::get("disable_theme", []);
if (is_array($disabled_roles) && $disabled_roles) {
$user = wp_get_current_user();
if ($user && $user->exists() && array_intersect((array) $user->roles, $disabled_roles)) {
return false;
}
}
return apply_filters("aurora_admin_shell_enabled", true);
}
/**
* Whether the current screen is one of Aurora's own pages (vs a native
* WordPress admin page). Used to decide whether dark mode themes the
* content area or leaves it in WP's native light theme.
*/
private static function is_own_page()
{
$page = isset($_GET["page"]) ? sanitize_text_field(wp_unslash($_GET["page"])) : "";
if (strpos($page, "aurora-admin") === 0) {
return true;
}
// The dashboard (index.php) is taken over by Aurora's own dashboard, so
// treat it as one of our pages: dark frame, and the native-dark theme
// sheet is skipped there.
return isset($GLOBALS["pagenow"]) && $GLOBALS["pagenow"] === "index.php";
}
public static function print_early_styles()
{
if (!self::is_enabled()) {
return;
}
$native_class = self::is_own_page() ? "" : "aurora-native-page";
?>
<script>
// Synchronous: marks the document active before first paint so the
// hide-native-chrome CSS below applies with no flash. This class
// persists for the whole session (chrome stays hidden); aurora-ready
// is added later by the Vue shell only to reveal the framed content.
document.documentElement.classList.add("aurora-active");
<?php if ($native_class): ?>
document.documentElement.classList.add("<?php echo esc_js($native_class); ?>");
<?php endif; ?>
// Failsafe: if the Vue shell never signals ready, drop aurora-active
// so native admin returns and a failed build can't lock the user out.
window.setTimeout(function () {
if (!document.documentElement.classList.contains("aurora-ready")) {
document.documentElement.classList.remove("aurora-active");
}
}, 8000);
</script>
<style id="aurora-admin-early">
/* Hide native chrome for the whole active session (not just loading),
so it stays hidden once the shell is ready. */
html.aurora-active #adminmenumain,
html.aurora-active #wpadminbar,
html.aurora-active #wpfooter {
display: none !important;
}
html.aurora-active.wp-toolbar {
padding-top: 0 !important;
}
/* Keep the framed content hidden only until the shell mounts, to avoid
a flash of unframed/unpositioned content. No !important and no
margin rules here, so the framing rules in shell-frame.css win
cleanly once aurora-ready is set. */
html.aurora-active:not(.aurora-ready) #wpbody {
opacity: 0;
}
html.aurora-ready #wpbody {
opacity: 1;
transition: opacity 0.15s ease;
}
</style>
<?php
}
public static function enqueue()
{
if (!self::is_enabled()) {
return;
}
// The sidebar renders WordPress's own dashicon menu glyphs, so make
// sure the dashicons font is present (core registers it under this
// handle; it's normally enqueued already, this just guarantees it).
wp_enqueue_style("dashicons");
Assets::enqueue("aurora-admin-shell", "entries/shell.js");
// Full dark theme for native WP pages. Loaded only on native pages
// (Aurora's own pages style themselves) and inert unless dark mode is
// active (every rule is gated on html.aurora-dark in the file itself).
if (!self::is_own_page()) {
// Version static assets by file mtime so edits bust the browser cache
// even when the plugin version hasn't changed.
$native_dark = AURORA_ADMIN_PATH . "admin/assets/native-dark.css";
$css_ver = file_exists($native_dark) ? (string) filemtime($native_dark) : AURORA_ADMIN_VERSION;
wp_enqueue_style(
"aurora-admin-native-dark",
AURORA_ADMIN_URL . "admin/assets/native-dark.css",
["aurora-admin-shell-0"],
$css_ver
);
// Progressive enhancement (boxed sections on the Updates page, etc.)
$native_js = AURORA_ADMIN_PATH . "admin/assets/native-enhance.js";
$js_ver = file_exists($native_js) ? (string) filemtime($native_js) : AURORA_ADMIN_VERSION;
wp_enqueue_script(
"aurora-admin-native-enhance",
AURORA_ADMIN_URL . "admin/assets/native-enhance.js",
[],
$js_ver,
true
);
}
}
public static function print_mount()
{
if (!self::is_enabled()) {
return;
}
$current_user = wp_get_current_user();
// Who may see the remote-site switcher in the toolbar (Settings >
// General > Remote Site Switcher Capability, default manage_options).
$switcher_cap = Settings::get("remote_site_switcher_capability", "");
if (!is_string($switcher_cap) || trim($switcher_cap) === "") {
$switcher_cap = "manage_options";
}
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"adminUrl" => esc_url_raw(admin_url()),
"siteUrl" => esc_url_raw(home_url("/")),
"logoutUrl" => esc_url_raw(wp_logout_url()),
"userName" => $current_user->display_name,
"userEmail" => $current_user->user_email,
"avatar" => get_avatar_url($current_user->ID, ["size" => 64]),
"currentUrl" => esc_url_raw(self::current_admin_url()),
// Secrets stripped — this is serialized for every admin user.
"settings" => Settings::get_safe(),
"canSwitchSites" => current_user_can($switcher_cap),
"menu" => MenuSerializer::serialize(),
"auroraNav" => self::aurora_nav(),
];
?>
<div
id="aurora-admin-shell-root"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
private static function current_admin_url()
{
$request = isset($_SERVER["REQUEST_URI"]) ? $_SERVER["REQUEST_URI"] : "";
return home_url($request);
}
/**
* Aurora's own branded nav section, rendered in a dedicated block at the
* bottom of the sidebar (separate from the WP-native menu list above it).
* A small static list rather than menu-scraped, since these are our own
* pages, not WordPress's.
*/
private static function aurora_nav()
{
$items = [
["id" => "aurora-admin", "title" => __("Settings", "aurora-admin"), "icon" => "dashicons-admin-generic"],
["id" => "aurora-admin-menu-creator", "title" => __("Menu Creator", "aurora-admin"), "icon" => "dashicons-menu-alt"],
["id" => "aurora-admin-notices", "title" => __("Admin Notices", "aurora-admin"), "icon" => "dashicons-megaphone"],
["id" => "aurora-admin-roles", "title" => __("Role Editor", "aurora-admin"), "icon" => "dashicons-groups"],
["id" => "aurora-admin-activity-log", "title" => __("Activity Log", "aurora-admin"), "icon" => "dashicons-clock"],
["id" => "aurora-admin-database", "title" => __("Database Explorer", "aurora-admin"), "icon" => "dashicons-database"],
];
$nav = [];
foreach ($items as $item) {
if (!current_user_can("manage_options")) {
continue;
}
$nav[] = [
"id" => $item["id"],
"title" => $item["title"],
"url" => esc_url_raw(admin_url("admin.php?page=" . $item["id"])),
"icon" => ["type" => "dashicon", "value" => $item["icon"]],
];
}
return $nav;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace AuroraAdmin\Utility;
defined("ABSPATH") || exit();
/**
* Reads the Vite build manifest and enqueues a single entry's JS (as an
* ES module) and CSS. Kept deliberately simple — one entry in, manifest
* lookup, two enqueue calls out. No scoping/theme tricks live here.
*/
class Assets
{
private static $manifest = null;
private static function get_manifest()
{
if (self::$manifest !== null) {
return self::$manifest;
}
$path = AURORA_ADMIN_PATH . "app/dist/.vite/manifest.json";
if (!file_exists($path)) {
self::$manifest = [];
return self::$manifest;
}
$contents = file_get_contents($path);
$decoded = json_decode($contents, true);
self::$manifest = is_array($decoded) ? $decoded : [];
return self::$manifest;
}
/**
* Enqueues a built entry's JS as a module script plus its CSS, if any.
*
* @param string $handle WP script/style handle prefix, e.g. "aurora-admin-dashboard"
* @param string $entry Manifest key, e.g. "src/entries/dashboard.js"
*/
public static function enqueue($handle, $entry)
{
$manifest = self::get_manifest();
if (!isset($manifest[$entry])) {
return;
}
$entry_data = $manifest[$entry];
$base_url = AURORA_ADMIN_URL . "app/dist/";
if (isset($entry_data["file"])) {
wp_enqueue_script($handle, $base_url . $entry_data["file"], [], AURORA_ADMIN_VERSION, true);
add_filter("script_loader_tag", function ($tag, $tag_handle) use ($handle) {
if ($tag_handle !== $handle) {
return $tag;
}
return str_replace(" src", " type=\"module\" src", $tag);
}, 10, 2);
}
// Vite splits shared code (e.g. the AppShell used by every page) into
// its own chunk with its own CSS file. The entry's own "css" key only
// lists CSS pulled in directly by that entry — CSS from anything it
// imports has to be collected by walking "imports" too, otherwise
// shared-component styles silently never get linked on the page.
$css_files = [];
self::collect_css($manifest, $entry, $css_files);
$index = 0;
foreach (array_unique($css_files) as $css_file) {
wp_enqueue_style($handle . "-" . $index, $base_url . $css_file, [], AURORA_ADMIN_VERSION);
$index++;
}
}
private static function collect_css($manifest, $entry, &$css_files)
{
if (!isset($manifest[$entry])) {
return;
}
$entry_data = $manifest[$entry];
if (!empty($entry_data["css"]) && is_array($entry_data["css"])) {
foreach ($entry_data["css"] as $css_file) {
$css_files[] = $css_file;
}
}
if (!empty($entry_data["imports"]) && is_array($entry_data["imports"])) {
foreach ($entry_data["imports"] as $imported_key) {
self::collect_css($manifest, $imported_key, $css_files);
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace AuroraAdmin\WhiteLabel;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Applies the White Label settings:
* - admin_favicon: replaces the favicon across wp-admin
* - plugin_name: renames the plugin in the plugins list and the admin menu
* - text_replacements: find/replace pairs applied to translated admin text
*/
class WhiteLabel
{
/** @var array<string,string>|null Cached find=>replace map for gettext. */
private static $replacements = null;
public function __construct()
{
add_action("admin_head", [self::class, "favicon"]);
add_filter("all_plugins", [self::class, "rename_plugin"]);
add_action("admin_menu", [self::class, "rename_menu"], 999);
add_filter("gettext", [self::class, "replace_text"], 20, 3);
}
public static function favicon()
{
$url = Settings::get("admin_favicon", "");
if (!$url) {
return;
}
printf('<link rel="icon" href="%s" />' . "\n", esc_url($url));
}
public static function rename_plugin($plugins)
{
$name = trim((string) Settings::get("plugin_name", ""));
if ($name === "") {
return $plugins;
}
$basename = "aurora-admin/aurora-admin.php";
if (isset($plugins[$basename])) {
$plugins[$basename]["Name"] = $name;
$plugins[$basename]["Title"] = $name;
}
return $plugins;
}
public static function rename_menu()
{
$name = trim((string) Settings::get("plugin_name", ""));
if ($name === "") {
return;
}
global $menu;
if (!is_array($menu)) {
return;
}
foreach ($menu as $index => $item) {
if (isset($item[2]) && $item[2] === "aurora-admin") {
$menu[$index][0] = $name;
break;
}
}
}
public static function replace_text($translation, $text, $domain)
{
// Front end and login screens stay untouched.
if (!is_admin()) {
return $translation;
}
if (self::$replacements === null) {
self::$replacements = [];
$pairs = Settings::get("text_replacements", []);
if (is_array($pairs)) {
foreach ($pairs as $pair) {
if (
is_array($pair) &&
isset($pair["find"], $pair["replace"]) &&
$pair["find"] !== ""
) {
self::$replacements[$pair["find"]] = (string) $pair["replace"];
}
}
}
}
if (!self::$replacements) {
return $translation;
}
return strtr($translation, self::$replacements);
}
}
+1363
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "aurora-admin-app",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"vue": "^3.5.26"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7"
}
}
+76
View File
@@ -0,0 +1,76 @@
<script setup>
defineProps({
modelValue: {
type: Boolean,
default: false,
},
label: {
type: String,
default: '',
},
});
defineEmits(['update:modelValue']);
</script>
<template>
<label class="toggle">
<span v-if="label" class="toggle__label">{{ label }}</span>
<button
type="button"
class="toggle__switch"
:class="{ 'toggle__switch--on': modelValue }"
role="switch"
:aria-checked="modelValue"
@click="$emit('update:modelValue', !modelValue)"
>
<span class="toggle__thumb" />
</button>
</label>
</template>
<style scoped>
.toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
cursor: pointer;
}
.toggle__label {
font-size: 0.875rem;
}
.toggle__switch {
position: relative;
width: 2.5rem;
height: 1.4rem;
border-radius: 999px;
border: none;
background: var(--aurora-border);
cursor: pointer;
padding: 0;
transition: background-color 0.15s ease;
flex-shrink: 0;
}
.toggle__switch--on {
background: var(--aurora-accent);
}
.toggle__thumb {
position: absolute;
top: 0.15rem;
left: 0.15rem;
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: #ffffff;
transition: transform 0.15s ease;
}
.toggle__switch--on .toggle__thumb {
transform: translateX(1.1rem);
}
</style>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import ActivityLog from '@/pages/activity-log/ActivityLog.vue';
const mountEl = document.getElementById('aurora-admin-activity-log-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(ActivityLog, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import AdminNotices from '@/pages/admin-notices/AdminNotices.vue';
const mountEl = document.getElementById('aurora-admin-admin-notices-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(AdminNotices, { serverData: data }).mount(mountEl);
}
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue';
// Pulls in the shared --aurora-* theme variables. Harmless if the site-wide
// shell already loaded them (identical values); keeps this page self-sufficient.
import '@/shell/shell-frame.css';
import DashboardPage from '@/pages/dashboard/Dashboard.vue';
const mountEl = document.getElementById('aurora-admin-dashboard-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(DashboardPage, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import DatabaseExplorer from '@/pages/database-explorer/DatabaseExplorer.vue';
const mountEl = document.getElementById('aurora-admin-database-explorer-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(DatabaseExplorer, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import MenuCreator from '@/pages/menu-creator/MenuCreator.vue';
const mountEl = document.getElementById('aurora-admin-menu-creator-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(MenuCreator, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import RoleEditor from '@/pages/role-editor/RoleEditor.vue';
const mountEl = document.getElementById('aurora-admin-role-editor-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(RoleEditor, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import SettingsPage from '@/pages/settings/Settings.vue';
const mountEl = document.getElementById('aurora-admin-settings-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(SettingsPage, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import SiteShell from '@/shell/SiteShell.vue';
const mountEl = document.getElementById('aurora-admin-shell-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(SiteShell, { serverData: data }).mount(mountEl);
}
+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.',
},
],
},
];
+95
View File
@@ -0,0 +1,95 @@
<script setup>
import { ref, onMounted } from 'vue';
import Sidebar from './components/Sidebar.vue';
import Toolbar from './components/Toolbar.vue';
const props = defineProps({
serverData: {
type: Object,
required: true,
},
});
const isDark = ref(props.serverData?.settings?.dark_mode === 'on');
const applyTheme = () => {
document.documentElement.classList.toggle('aurora-dark', isDark.value);
};
const persistDark = async () => {
try {
await fetch(`${props.serverData.restUrl}wp/v2/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
},
// Partial payload: the option's sanitize callback merges this into
// the stored settings, so we never write back the (secret-stripped)
// settings copy this page received.
body: JSON.stringify({
aurora_admin_settings: {
dark_mode: isDark.value ? 'on' : 'off',
},
}),
});
} catch {
// Non-critical: theme still applied for this session.
}
};
const toggleDark = () => {
isDark.value = !isDark.value;
applyTheme();
persistDark();
};
// Minified (icon-only) menu — a per-browser preference kept in
// localStorage; the html class drives all layout via --aurora-sidebar-w.
const collapsed = ref(localStorage.getItem('aurora-menu-collapsed') === '1');
const applyCollapsed = () => {
document.documentElement.classList.toggle('aurora-collapsed', collapsed.value);
};
const toggleMenu = () => {
collapsed.value = !collapsed.value;
applyCollapsed();
localStorage.setItem('aurora-menu-collapsed', collapsed.value ? '1' : '0');
};
onMounted(() => {
applyTheme();
applyCollapsed();
// Signal the shell is live: reveals the framed content and commits to
// keeping native chrome hidden (the PHP failsafe stops watching once
// aurora-ready is present).
document.documentElement.classList.add('aurora-ready');
});
</script>
<template>
<div class="site-shell">
<Sidebar
:menu="serverData.menu"
:aurora-nav="serverData.auroraNav"
:current-url="serverData.currentUrl"
:logo="serverData.settings?.logo || ''"
:dark-logo="serverData.settings?.dark_logo || ''"
:is-dark="isDark"
:collapsed="collapsed"
/>
<Toolbar
:server-data="serverData"
:is-dark="isDark"
@toggle-dark="toggleDark"
@toggle-menu="toggleMenu"
/>
</div>
</template>
<style scoped>
/* Sidebar and Toolbar are both position:fixed, so this wrapper has no
layout role — it just groups them under one mounted root. */
.site-shell {
display: contents;
}
</style>
+219
View File
@@ -0,0 +1,219 @@
<script setup>
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
open: { type: Boolean, default: false },
});
const emit = defineEmits(['close']);
const input = ref(null);
const query = ref('');
const results = ref([]);
const loading = ref(false);
const highlighted = ref(0);
let debounceTimer;
let requestSeq = 0;
const search = async () => {
const q = query.value.trim();
if (q.length < 2) {
results.value = [];
return;
}
const seq = ++requestSeq;
loading.value = true;
try {
const url = new URL(`${props.serverData.restUrl}wp/v2/search`);
url.searchParams.set('search', q);
url.searchParams.set('per_page', '10');
// Settings > General > "Search post types" limits which post types the
// global search covers; empty means all searchable types.
const types = props.serverData.settings?.search_post_types;
if (Array.isArray(types) && types.length) {
url.searchParams.set('subtype', types.join(','));
}
const res = await fetch(url, {
headers: { 'X-WP-Nonce': props.serverData.restNonce },
});
if (!res.ok) throw new Error();
const items = await res.json();
if (seq === requestSeq) {
results.value = items;
highlighted.value = 0;
}
} catch {
if (seq === requestSeq) results.value = [];
} finally {
if (seq === requestSeq) loading.value = false;
}
};
watch(query, () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(search, 250);
});
watch(
() => props.open,
(open) => {
if (open) {
query.value = '';
results.value = [];
nextTick(() => input.value?.focus());
}
}
);
const openResult = (item) => {
if (!item) return;
window.location.href = `${props.serverData.adminUrl}post.php?post=${item.id}&action=edit`;
};
const onKeydown = (e) => {
if (!props.open) return;
if (e.key === 'Escape') {
emit('close');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
highlighted.value = Math.min(highlighted.value + 1, results.value.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlighted.value = Math.max(highlighted.value - 1, 0);
} else if (e.key === 'Enter') {
openResult(results.value[highlighted.value]);
}
};
onMounted(() => document.addEventListener('keydown', onKeydown));
onUnmounted(() => document.removeEventListener('keydown', onKeydown));
</script>
<template>
<Teleport to="body">
<div v-if="open" class="smodal__overlay" @click.self="emit('close')">
<div class="smodal">
<div class="smodal__inputwrap">
<span class="dashicons dashicons-search smodal__icon" />
<input
ref="input"
v-model="query"
type="text"
class="smodal__input"
placeholder="Search posts, pages…"
/>
<span class="smodal__esc">Esc</span>
</div>
<div v-if="loading" class="smodal__state">Searching…</div>
<div v-else-if="query.trim().length >= 2 && !results.length" class="smodal__state">
No results for “{{ query.trim() }}”
</div>
<ul v-if="results.length" class="smodal__results">
<li v-for="(item, i) in results" :key="item.id">
<button
type="button"
class="smodal__result"
:class="{ 'smodal__result--active': i === highlighted }"
@mouseenter="highlighted = i"
@click="openResult(item)"
>
<span class="smodal__result-title" v-html="item.title" />
<span class="smodal__result-type">{{ item.subtype }}</span>
</button>
</li>
</ul>
</div>
</div>
</Teleport>
</template>
<style scoped>
.smodal__overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 100000;
display: flex;
justify-content: center;
padding-top: 12vh;
}
.smodal {
width: min(620px, calc(100vw - 40px));
align-self: flex-start;
background: var(--aurora-frame-bg, #1c1c20);
border: 1px solid var(--aurora-frame-border, #34343b);
border-radius: 14px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.smodal__inputwrap {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--aurora-border, #27272a);
}
.smodal__icon { color: var(--aurora-text-muted); }
.smodal__input {
flex: 1;
border: none;
background: none;
outline: none;
color: var(--aurora-text);
font-size: 0.95rem;
}
.smodal__esc {
font-size: 0.6875rem;
color: var(--aurora-text-muted);
border: 1px solid var(--aurora-border, #27272a);
border-radius: 5px;
padding: 2px 6px;
}
.smodal__state {
padding: 18px 16px;
font-size: 0.875rem;
color: var(--aurora-text-muted);
}
.smodal__results {
list-style: none;
margin: 0;
padding: 6px;
max-height: 50vh;
overflow-y: auto;
}
.smodal__result {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
border: none;
background: none;
cursor: pointer;
text-align: left;
padding: 10px 12px;
border-radius: 9px;
}
.smodal__result--active { background: var(--aurora-accent-soft); }
.smodal__result-title {
color: var(--aurora-text);
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.smodal__result-type {
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--aurora-text-muted);
border: 1px solid var(--aurora-border, #27272a);
border-radius: 999px;
padding: 2px 8px;
text-transform: capitalize;
}
</style>
+414
View File
@@ -0,0 +1,414 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
menu: {
type: Array,
default: () => [],
},
currentUrl: {
type: String,
default: '',
},
logo: {
type: String,
default: '',
},
darkLogo: {
type: String,
default: '',
},
isDark: {
type: Boolean,
default: false,
},
collapsed: {
type: Boolean,
default: false,
},
auroraNav: {
type: Array,
default: () => [],
},
});
// Custom logo from Settings > General. Dark mode prefers the dark variant
// and falls back to the standard logo; no logo set = "Aurora" wordmark.
const activeLogo = computed(() =>
props.isDark ? props.darkLogo || props.logo : props.logo
);
// Normalize any admin URL down to the part after /wp-admin/ so we can
// compare the current page against menu targets regardless of host/scheme.
const normalize = (url) => {
if (!url) return '';
const idx = url.indexOf('/wp-admin/');
const path = idx === -1 ? url : url.slice(idx + '/wp-admin/'.length);
// The bare admin root ("/wp-admin/" with nothing after it) IS the
// dashboard, same as an explicit index.php — normalize them to one form
// so the Dashboard section correctly reads as active/expanded there.
return path === '' || path.startsWith('?') ? 'index.php' + path : path;
};
const current = computed(() => normalize(props.currentUrl));
const isItemActive = (item) => {
if (!item.url) return false;
return normalize(item.url) === current.value;
};
const isSectionActive = (item) => {
if (isItemActive(item)) return true;
return (item.submenu || []).some((sub) => normalize(sub.url) === current.value);
};
// Which sections are manually expanded. Active section starts expanded.
const expanded = ref(new Set());
const isExpanded = (item) => {
return expanded.value.has(item.id) || isSectionActive(item);
};
const toggle = (item) => {
const next = new Set(expanded.value);
if (next.has(item.id)) {
next.delete(item.id);
} else {
next.add(item.id);
}
expanded.value = next;
};
// Aurora's own branded nav section (Settings, Menu Creator, Admin Notices,
// Role Editor, Activity Log, Database Explorer) — separate from the
// WP-native menu list above it. Expanded by default; per-browser preference
// kept in localStorage, same pattern as the main collapsed-menu state.
const auroraExpanded = ref(localStorage.getItem('aurora-nav-expanded') !== '0');
const toggleAuroraNav = () => {
auroraExpanded.value = !auroraExpanded.value;
localStorage.setItem('aurora-nav-expanded', auroraExpanded.value ? '1' : '0');
};
const isAuroraActive = (item) => normalize(item.url) === current.value;
</script>
<template>
<nav class="sidebar" :class="{ 'sidebar--collapsed': collapsed }">
<div class="sidebar__brand">
<img v-if="activeLogo" :src="activeLogo" alt="" class="sidebar__brand-img" />
<template v-else>{{ collapsed ? 'A' : 'Aurora' }}</template>
</div>
<ul class="sidebar__list">
<template v-for="(item, index) in menu" :key="item.id || index">
<li v-if="item.separator" class="sidebar__separator" />
<li v-else class="sidebar__item">
<div
class="sidebar__row"
:class="{ 'sidebar__row--active': isSectionActive(item) }"
>
<a :href="item.url" class="sidebar__link" :title="collapsed ? item.title : null">
<span class="sidebar__icon">
<span
v-if="item.icon && item.icon.type === 'dashicon'"
class="dashicons"
:class="item.icon.value"
/>
<img
v-else-if="item.icon && item.icon.type === 'image'"
:src="item.icon.value"
alt=""
class="sidebar__icon-img"
/>
<span v-else class="sidebar__icon-dot" />
</span>
<span class="sidebar__title">{{ item.title }}</span>
<span v-if="item.count" class="sidebar__count">{{ item.count }}</span>
</a>
<button
v-if="item.submenu && item.submenu.length"
type="button"
class="sidebar__chevron"
:class="{ 'sidebar__chevron--open': isExpanded(item) }"
:aria-label="`Toggle ${item.title} submenu`"
@click="toggle(item)"
>
›
</button>
</div>
<ul
v-if="item.submenu && item.submenu.length && isExpanded(item)"
class="sidebar__sublist"
>
<li
v-for="(sub, subIndex) in item.submenu"
:key="sub.id || subIndex"
class="sidebar__subitem"
>
<a
:href="sub.url"
class="sidebar__sublink"
:class="{ 'sidebar__sublink--active': normalize(sub.url) === current }"
>
{{ sub.title }}
<span v-if="sub.count" class="sidebar__count">{{ sub.count }}</span>
</a>
</li>
</ul>
</li>
</template>
</ul>
<div v-if="auroraNav.length" class="sidebar__separator" />
<div v-if="auroraNav.length" class="sidebar__aurora">
<div class="sidebar__row sidebar__aurora-brand" @click="toggleAuroraNav">
<a href="#" class="sidebar__link" @click.prevent :title="collapsed ? 'Aurora' : null">
<span class="sidebar__icon">
<span class="dashicons dashicons-superhero-alt" />
</span>
<span class="sidebar__title">Aurora</span>
</a>
<button
type="button"
class="sidebar__chevron"
:class="{ 'sidebar__chevron--open': auroraExpanded }"
aria-label="Toggle Aurora menu"
>
›
</button>
</div>
<ul v-if="auroraExpanded" class="sidebar__list sidebar__aurora-list">
<li v-for="item in auroraNav" :key="item.id" class="sidebar__item">
<div class="sidebar__row" :class="{ 'sidebar__row--active': isAuroraActive(item) }">
<a :href="item.url" class="sidebar__link" :title="collapsed ? item.title : null">
<span class="sidebar__icon">
<span class="dashicons" :class="item.icon?.value" />
</span>
<span class="sidebar__title">{{ item.title }}</span>
</a>
</div>
</li>
</ul>
</div>
</nav>
</template>
<style scoped>
.sidebar {
position: fixed;
top: 0;
left: 0;
width: var(--aurora-sidebar-w);
height: 100vh;
background: var(--aurora-app-bg);
overflow-y: auto;
transition: width 0.2s ease;
z-index: 99999;
padding: 12px 0;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
display: flex;
flex-direction: column;
}
.sidebar__brand {
font-size: 1.25rem;
font-weight: 700;
color: var(--aurora-text);
padding: 8px 20px 16px;
letter-spacing: -0.02em;
}
.sidebar__brand-img {
display: block;
max-height: 30px;
max-width: 190px;
object-fit: contain;
}
.sidebar__list,
.sidebar__sublist {
list-style: none;
margin: 0;
padding: 0;
}
.sidebar__separator {
height: 1px;
background: var(--aurora-border);
margin: 8px 16px;
}
.sidebar__row {
display: flex;
align-items: center;
border-radius: 8px;
margin: 1px 8px;
}
.sidebar__row--active {
background: var(--aurora-accent-soft);
}
.sidebar__link {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
padding: 8px 10px;
text-decoration: none;
color: var(--aurora-text);
font-size: 0.875rem;
min-width: 0;
}
.sidebar__row--active .sidebar__link {
color: var(--aurora-accent);
font-weight: 600;
}
.sidebar__icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 8px;
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
transition: background 0.12s ease, color 0.12s ease;
}
.sidebar__row--active .sidebar__icon {
background: var(--aurora-accent-soft);
color: var(--aurora-accent);
}
.sidebar__icon .dashicons {
font-size: 16px;
width: 16px;
height: 16px;
}
.sidebar__icon-img {
width: 16px;
height: 16px;
object-fit: contain;
}
.sidebar__icon-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--aurora-text-muted);
}
.sidebar__title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__count {
background: var(--aurora-accent);
color: var(--aurora-accent-text);
font-size: 0.6875rem;
font-weight: 600;
border-radius: 999px;
padding: 1px 7px;
flex-shrink: 0;
}
.sidebar__chevron {
background: none;
border: none;
color: var(--aurora-text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 4px 12px;
transition: transform 0.15s ease;
}
.sidebar__chevron--open {
transform: rotate(90deg);
}
.sidebar__sublist {
padding: 2px 0 6px;
}
.sidebar__sublink {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px 6px 42px;
text-decoration: none;
color: var(--aurora-text-muted);
font-size: 0.8125rem;
border-radius: 6px;
margin: 1px 8px;
}
.sidebar__sublink:hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.sidebar__sublink--active {
color: var(--aurora-accent);
font-weight: 600;
}
/* --- Aurora branded nav (right under the native menu's own divider) ---- */
.sidebar__aurora {
padding-bottom: 4px;
}
.sidebar__aurora-brand {
cursor: pointer;
font-weight: 600;
}
.sidebar__aurora-brand .sidebar__title {
color: var(--aurora-text-muted);
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sidebar__aurora-brand .sidebar__icon {
background: var(--aurora-accent-soft);
color: var(--aurora-accent);
}
/* --- Collapsed (icon-only) rail --------------------------------------- */
.sidebar--collapsed {
overflow-x: hidden;
}
.sidebar--collapsed .sidebar__brand {
padding: 8px 0 16px;
text-align: center;
font-size: 1.1rem;
}
.sidebar--collapsed .sidebar__brand-img {
margin: 0 auto;
max-width: 40px;
}
.sidebar--collapsed .sidebar__title,
.sidebar--collapsed .sidebar__count,
.sidebar--collapsed .sidebar__chevron,
.sidebar--collapsed .sidebar__sublist {
display: none;
}
.sidebar--collapsed .sidebar__row {
margin: 1px 10px;
}
.sidebar--collapsed .sidebar__link {
justify-content: center;
padding: 10px 0;
gap: 0;
}
</style>
+341
View File
@@ -0,0 +1,341 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import SearchModal from './SearchModal.vue';
const props = defineProps({
serverData: {
type: Object,
required: true,
},
isDark: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['toggle-dark', 'toggle-menu']);
const menuOpen = ref(false);
const searchOpen = ref(false);
const sitesOpen = ref(false);
// Settings > General > "Disable global search" hides the search entirely.
const searchDisabled = computed(
() => !!props.serverData.settings?.disable_global_search
);
// Remote sites (secrets already stripped server-side). The switcher only
// shows for users holding the configured capability (canSwitchSites).
const remoteSites = computed(() => {
const sites = props.serverData.settings?.remote_sites;
return Array.isArray(sites) ? sites.filter((s) => s && s.url) : [];
});
const showSwitcher = computed(
() => !!props.serverData.canSwitchSites && remoteSites.value.length > 0
);
const siteAdminUrl = (site) => {
let url = (site.url || '').trim();
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
return url.replace(/\/+$/, '') + '/wp-admin/';
};
const siteHost = (site) => {
try {
return new URL(siteAdminUrl(site)).host;
} catch {
return site.url;
}
};
const onKeydown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
if (searchDisabled.value) return;
e.preventDefault();
searchOpen.value = true;
}
};
onMounted(() => document.addEventListener('keydown', onKeydown));
onUnmounted(() => document.removeEventListener('keydown', onKeydown));
</script>
<template>
<header class="toolbar">
<div class="toolbar__left">
<button
type="button"
class="toolbar__icon-btn"
title="Toggle menu"
@click="emit('toggle-menu')"
>
<span class="dashicons dashicons-menu" />
</button>
<a
:href="serverData.siteUrl || '/'"
class="toolbar__home"
title="View website"
target="_blank"
rel="noopener noreferrer"
>
<span class="dashicons dashicons-admin-home" />
</a>
<a :href="serverData.adminUrl" class="toolbar__home" title="Dashboard">
<span class="dashicons dashicons-dashboard" />
</a>
<button
v-if="!searchDisabled"
type="button"
class="toolbar__search"
@click="searchOpen = true"
>
<span class="dashicons dashicons-search toolbar__search-icon" />
<span class="toolbar__search-text">Search…</span>
<span class="toolbar__search-kbd">Ctrl K</span>
</button>
</div>
<div class="toolbar__right">
<div v-if="showSwitcher" class="toolbar__sites">
<button
type="button"
class="toolbar__icon-btn"
title="Switch site"
@click="sitesOpen = !sitesOpen"
>
<span class="dashicons dashicons-admin-multisite" />
</button>
<div v-if="sitesOpen" class="toolbar__dropdown">
<div class="toolbar__dropdown-header">
<div class="toolbar__dropdown-name">Remote sites</div>
</div>
<a
v-for="(site, i) in remoteSites"
:key="i"
:href="siteAdminUrl(site)"
target="_blank"
rel="noopener noreferrer"
class="toolbar__dropdown-item"
>
{{ siteHost(site) }}
<span v-if="site.username" class="toolbar__site-user">{{ site.username }}</span>
</a>
</div>
</div>
<button
type="button"
class="toolbar__icon-btn toolbar__theme-toggle"
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="emit('toggle-dark')"
>
{{ isDark ? '☀' : '☾' }}
</button>
<div class="toolbar__user">
<button
type="button"
class="toolbar__user-btn"
@click="menuOpen = !menuOpen"
>
<img
v-if="serverData.avatar"
:src="serverData.avatar"
alt=""
class="toolbar__avatar"
/>
<span class="toolbar__user-name">{{ serverData.userName }}</span>
</button>
<div v-if="menuOpen" class="toolbar__dropdown">
<div class="toolbar__dropdown-header">
<div class="toolbar__dropdown-name">{{ serverData.userName }}</div>
<div class="toolbar__dropdown-email">{{ serverData.userEmail }}</div>
</div>
<a :href="serverData.logoutUrl" class="toolbar__dropdown-item">
Log out
</a>
</div>
</div>
</div>
<SearchModal
:server-data="serverData"
:open="searchOpen"
@close="searchOpen = false"
/>
</header>
</template>
<style scoped>
.toolbar {
position: fixed;
top: 0;
left: var(--aurora-sidebar-w);
right: 0;
height: var(--aurora-toolbar-h);
background: var(--aurora-app-bg);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
z-index: 99998;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.toolbar__left,
.toolbar__right {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar__home {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
color: var(--aurora-text-muted);
text-decoration: none;
}
.toolbar__home:hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.toolbar__search {
display: flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 10px;
min-width: 240px;
border: 1px solid var(--aurora-border);
border-radius: 10px;
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
font-size: 0.8125rem;
cursor: text;
}
.toolbar__search-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
.toolbar__search-text {
flex: 1;
text-align: left;
}
.toolbar__search-kbd {
font-size: 0.6875rem;
border: 1px solid var(--aurora-border);
border-radius: 5px;
padding: 1px 6px;
background: var(--aurora-bg);
}
.toolbar__icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: none;
background: none;
border-radius: 8px;
cursor: pointer;
color: var(--aurora-text-muted);
}
.toolbar__icon-btn:hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.toolbar__theme-toggle {
font-size: 1.05rem;
line-height: 1;
}
.toolbar__user,
.toolbar__sites {
position: relative;
}
.toolbar__site-user {
color: var(--aurora-text-muted);
font-size: 0.75rem;
margin-left: 8px;
}
.toolbar__user-btn {
display: flex;
align-items: center;
gap: 8px;
border: none;
background: none;
cursor: pointer;
padding: 4px 6px;
border-radius: 8px;
color: var(--aurora-text);
}
.toolbar__user-btn:hover {
background: var(--aurora-bg-subtle);
}
.toolbar__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
}
.toolbar__user-name {
font-size: 0.875rem;
}
.toolbar__dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 200px;
background: var(--aurora-bg);
border: 1px solid var(--aurora-border);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
overflow: hidden;
z-index: 100000;
}
.toolbar__dropdown-header {
padding: 12px 14px;
border-bottom: 1px solid var(--aurora-border);
}
.toolbar__dropdown-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--aurora-text);
}
.toolbar__dropdown-email {
font-size: 0.75rem;
color: var(--aurora-text-muted);
}
.toolbar__dropdown-item {
display: block;
padding: 10px 14px;
text-decoration: none;
color: var(--aurora-text);
font-size: 0.875rem;
}
.toolbar__dropdown-item:hover {
background: var(--aurora-bg-subtle);
}
</style>
+109
View File
@@ -0,0 +1,109 @@
/*
* The ONLY global stylesheet in the plugin. Every rule here either defines
* an --aurora-* theme variable or targets a specific WordPress structural ID
* to frame the native content. No element/tag resets, no utility classes —
* so it cannot leak onto or restyle WordPress's own page content the way the
* old plugin's Tailwind preflight did.
*
* Theme colors live here as plain CSS variables we fully control. Dark mode
* is a single class on <html> (.aurora-dark) that swaps the variable values.
*/
/* Palette carried over from the original plugin's design tokens: a zinc
neutral scale with a blue/indigo accent, large rounded surfaces. */
:root {
--aurora-sidebar-w: 264px;
--aurora-toolbar-h: 56px;
/* Breathing room between page content and the right browser edge. */
--aurora-gutter-r: 44px;
--aurora-radius: 14px;
--aurora-radius-lg: 24px;
/* Light theme */
--aurora-app-bg: #fafafa; /* zinc-50 */
--aurora-bg: #ffffff; /* frame */
--aurora-card-bg: #fafafa; /* zinc-50 */
--aurora-bg-subtle: #f4f4f5; /* zinc-100 */
--aurora-text: #18181b; /* zinc-900 */
--aurora-text-muted: #71717a; /* zinc-500 */
--aurora-border: #e4e4e7; /* zinc-200 */
--aurora-card-border: #ececef;
--aurora-frame-bg: #ffffff; /* native-page content panel */
--aurora-frame-border: #e0e0e3;
--aurora-accent: rgb(71 114 250);
--aurora-accent-soft: rgb(71 114 250 / 0.1);
--aurora-accent-text: #ffffff;
}
/* Minified menu: the toolbar's collapse button toggles this class; every
layout offset derives from --aurora-sidebar-w, so this one override
reflows the toolbar and content automatically. */
html.aurora-collapsed {
--aurora-sidebar-w: 68px;
}
html.aurora-dark {
/* Dark theme — the immersive near-black look of the original */
--aurora-app-bg: #09090b; /* zinc-950 */
--aurora-bg: #18181b; /* zinc-900 frame */
--aurora-card-bg: #1f1f23; /* slightly elevated */
--aurora-bg-subtle: #27272a; /* zinc-800 */
--aurora-text: #f4f4f5; /* zinc-100 */
--aurora-text-muted: #a1a1aa; /* zinc-400 */
--aurora-border: #27272a; /* zinc-800 */
--aurora-card-border: #2e2e33;
--aurora-frame-bg: #1c1c20; /* slightly elevated panel */
--aurora-frame-border: #34343b;
--aurora-accent: rgb(125 158 255);
--aurora-accent-soft: rgb(125 158 255 / 0.14);
}
/* --- Framing: only applies once the Vue shell has signalled ready --- */
html.aurora-ready,
html.aurora-ready body {
background: var(--aurora-app-bg);
}
/* !important here defeats WordPress's own responsive #wpcontent margin
rules (.auto-fold / folded menu states live in media queries at equal
specificity). This is positioning the frame, not restyling content.
The toolbar clearance lives here (on #wpcontent, which holds everything)
rather than on #wpbody, so content rendered above #wpbody — like the
takeover dashboard — also clears the fixed toolbar. */
html.aurora-ready #wpcontent {
margin-left: var(--aurora-sidebar-w) !important;
padding-left: 0 !important;
padding-top: var(--aurora-toolbar-h);
transition: margin-left 0.2s ease;
}
html.aurora-ready #wpbody-content {
background: var(--aurora-frame-bg);
color: var(--aurora-text);
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-lg);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
margin: 16px var(--aurora-gutter-r) 24px 24px;
padding: 32px;
min-height: calc(100vh - var(--aurora-toolbar-h) - 40px);
box-sizing: border-box;
}
/* The settings page renders its own two boxed panels, so drop the outer
content frame there — leaving just those panels on the dark backdrop. */
html.aurora-ready body.toplevel_page_aurora-admin #wpbody-content {
background: var(--aurora-app-bg);
border: none;
border-radius: 0;
box-shadow: none;
}
/*
* Native WordPress pages are fully dark-themed by a dedicated stylesheet
* (admin/assets/native-dark.css), enqueued only on native pages and gated
* on dark mode. It restyles WP's postboxes, forms, tables, buttons, etc.
* Keeping that large surface in one isolated, native-only file means it
* can never leak onto Aurora's own pages or affect light mode.
*/
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
// Deliberately simple: one entry in this first slice (dashboard). Add more
// entries here as additional pages get rebuilt — same flat pattern each
// time, no shared scoping/theme-remap layer to keep in sync across files.
export default defineConfig({
root: fileURLToPath(new URL('./src', import.meta.url)),
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: fileURLToPath(new URL('./dist', import.meta.url)),
emptyOutDir: true,
sourcemap: true,
manifest: true,
rollupOptions: {
input: {
shell: fileURLToPath(new URL('./src/entries/shell.js', import.meta.url)),
dashboard: fileURLToPath(new URL('./src/entries/dashboard.js', import.meta.url)),
settings: fileURLToPath(new URL('./src/entries/settings.js', import.meta.url)),
'menu-creator': fileURLToPath(new URL('./src/entries/menu-creator.js', import.meta.url)),
'admin-notices': fileURLToPath(new URL('./src/entries/admin-notices.js', import.meta.url)),
'role-editor': fileURLToPath(new URL('./src/entries/role-editor.js', import.meta.url)),
'activity-log': fileURLToPath(new URL('./src/entries/activity-log.js', import.meta.url)),
'database-explorer': fileURLToPath(new URL('./src/entries/database-explorer.js', import.meta.url)),
},
output: {
entryFileNames: '[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash][extname]',
},
},
},
});
+43
View File
@@ -0,0 +1,43 @@
<?php
/*
* Plugin Name: Aurora Admin
* Plugin URI: https://aurora.co
* Description: A modern, fast WordPress admin interface.
* Version: 2.0.0
* Author: Aurora
* Text Domain: aurora-admin
* Domain Path: /languages/
* Requires PHP: 7.4
* Requires at least: 5.5
* License: GPLv2 or later
*/
defined("ABSPATH") || exit();
define("AURORA_ADMIN_VERSION", "2.0.0");
define("AURORA_ADMIN_PATH", plugin_dir_path(__FILE__));
define("AURORA_ADMIN_URL", plugins_url("aurora-admin/"));
require AURORA_ADMIN_PATH . "admin/src/App/AuroraAdmin.php";
require AURORA_ADMIN_PATH . "admin/src/Options/Settings.php";
require AURORA_ADMIN_PATH . "admin/src/Utility/Assets.php";
require AURORA_ADMIN_PATH . "admin/src/Shell/MenuSerializer.php";
require AURORA_ADMIN_PATH . "admin/src/Shell/Shell.php";
require AURORA_ADMIN_PATH . "admin/src/Pages/DashboardTakeover.php";
require AURORA_ADMIN_PATH . "admin/src/Pages/SettingsPage.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/DashboardData.php";
require AURORA_ADMIN_PATH . "admin/src/Analytics/Analytics.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/AnalyticsData.php";
require AURORA_ADMIN_PATH . "admin/src/Posts/ScheduledDayFilter.php";
require AURORA_ADMIN_PATH . "admin/src/WhiteLabel/WhiteLabel.php";
require AURORA_ADMIN_PATH . "admin/src/Pages/SubApps.php";
require AURORA_ADMIN_PATH . "admin/src/MenuCreator/MenuCreator.php";
require AURORA_ADMIN_PATH . "admin/src/MenuCreator/MenuCreatorData.php";
require AURORA_ADMIN_PATH . "admin/src/Notices/Notices.php";
require AURORA_ADMIN_PATH . "admin/src/Notices/NoticesData.php";
require AURORA_ADMIN_PATH . "admin/src/Roles/RolesData.php";
require AURORA_ADMIN_PATH . "admin/src/ActivityLog/ActivityLog.php";
require AURORA_ADMIN_PATH . "admin/src/ActivityLog/ActivityLogData.php";
require AURORA_ADMIN_PATH . "admin/src/DatabaseExplorer/DatabaseExplorerData.php";
new AuroraAdmin\App\AuroraAdmin();