Initial commit: Aurora Admin plugin
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) . "`";
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) : ""
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>"
|
||||
); ?>
|
||||
<a href="<?php echo esc_url($all); ?>"><?php esc_html_e("View all scheduled", "aurora-admin"); ?></a>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user