Snapshot synced v3.6.1 working state as baseline

Machine-sync snapshot: the working tree carried the full 3.0.0-3.6.1 arc
(licensing/FileManager/DatabaseExplorer removal, all new replacement screens
and REST controllers) that had not been committed on this machine. Captured
here as a safety baseline before starting the Adminify-parity revamp.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
NixReaper
2026-07-12 08:41:18 -05:00
co-authored by Claude Opus 4.8
parent 07d77bda57
commit e2db2ad4b5
88 changed files with 8944 additions and 2730 deletions
+1
View File
@@ -1,6 +1,7 @@
app/node_modules/ app/node_modules/
app/dist/ app/dist/
dist/ dist/
wporg-submission/
admin/src/Licensing/integrity-manifest.json admin/src/Licensing/integrity-manifest.json
*.log *.log
.DS_Store .DS_Store
+1062
View File
File diff suppressed because it is too large Load Diff
+1325 -213
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -455,3 +455,40 @@ html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar .components
html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar .components-button { html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar .components-button {
color: var(--aurora-text); color: var(--aurora-text);
} }
/* Font Library (font-library.php) --------------------------------------
* WP 6.5+ built this on @wordpress/components' Card/Modal primitives
* ("boot-layout" wrapper) instead of classic postbox markup — its own
* always-white background isn't reached by the postbox/card rules above,
* which left light text sitting on a white panel (invisible) inside an
* otherwise-dark-framed page. Emotion-generated hash classes on some of
* this screen's inner elements (e.g. "css-qi2azi") are unstable across WP
* builds and deliberately not targeted here — only the screen's own
* stable, hand-authored class names are. */
html.aurora-dark.aurora-ready #wpbody-content .boot-layout-container,
html.aurora-dark.aurora-ready #wpbody-content .boot-layout__stage,
html.aurora-dark.aurora-ready #wpbody-content .admin-ui-page,
html.aurora-dark.aurora-ready #wpbody-content .admin-ui-navigable-region,
html.aurora-dark.aurora-ready #wpbody-content .admin-ui-page__header,
html.aurora-dark.aurora-ready #wpbody-content .font-library-page__tab-panel,
html.aurora-dark.aurora-ready #wpbody-content .font-library__tabpanel-layout,
html.aurora-dark.aurora-ready #wpbody-content .font-library__footer {
background: var(--aurora-card-bg);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .admin-ui-page__header-title,
html.aurora-dark.aurora-ready #wpbody-content .font-library__fonts-title {
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .font-library-page__tablist {
border-color: var(--aurora-card-border);
}
html.aurora-dark.aurora-ready #wpbody-content .font-library__fonts-list-item,
html.aurora-dark.aurora-ready #wpbody-content .font-library__font-card {
background: var(--aurora-card-bg);
border-color: var(--aurora-card-border);
color: var(--aurora-text);
}
+1
View File
@@ -71,6 +71,7 @@ class ActivityLog
global $wpdb; global $wpdb;
$user = wp_get_current_user(); $user = wp_get_current_user();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- $wpdb->insert() is WordPress's own escaping-safe API for writing to a custom table (not raw SQL); there's no core post/postmeta abstraction for Aurora's own activity-log table, so any access to it is necessarily "direct" by this sniff's definition.
$wpdb->insert(self::table_name(), [ $wpdb->insert(self::table_name(), [
"action_type" => $action_type, "action_type" => $action_type,
"object_type" => $object_type, "object_type" => $object_type,
@@ -1,6 +1,7 @@
<?php <?php
namespace AuroraAdmin\ActivityLog; namespace AuroraAdmin\ActivityLog;
defined("ABSPATH") || exit(); defined("ABSPATH") || exit();
/** /**
@@ -60,10 +61,16 @@ class ActivityLogData
$page = max(1, (int) $request->get_param("page") ?: 1); $page = max(1, (int) $request->get_param("page") ?: 1);
$offset = ($page - 1) * $per_page; $offset = ($page - 1) * $per_page;
// $table is an internal, hardcoded {$wpdb->prefix}-based table name
// (ActivityLog::table_name()); $where_sql is built only from the fixed
// "1=1"/"col = %s" fragments above (never raw user input).
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}"; $count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reading Aurora's own custom activity-log table, no core API for that; $count_sql is prepare()'d whenever $params is non-empty (the only case with real values to bind) — when $params is empty, $count_sql is just the literal "SELECT COUNT(*) FROM {$table} WHERE 1=1" with nothing user-controlled in it at all.
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql)); $total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql));
// $table/$where_sql: same as $count_sql above.
$sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; $sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reading Aurora's own custom activity-log table; always passed through $wpdb->prepare() with $params plus the trailing $per_page/$offset bound via %d/%d.
$rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, $offset]))); $rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, $offset])));
$items = array_map(function ($row) { $items = array_map(function ($row) {
+15 -15
View File
@@ -1,8 +1,6 @@
<?php <?php
namespace AuroraAdmin\Analytics; namespace AuroraAdmin\Analytics;
use AuroraAdmin\Licensing\License;
defined("ABSPATH") || exit(); defined("ABSPATH") || exit();
/** /**
@@ -11,9 +9,7 @@ defined("ABSPATH") || exit();
* Records one row per front-end pageview into a custom table, server-side * 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 * (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 * happens later in the REST endpoint. Written fresh — does not reuse the
* original plugin's analytics code. Paid feature — the hooks below simply * original plugin's analytics code.
* don't register without a license, so no tracking happens and the table
* never gets written to.
*/ */
class Analytics class Analytics
{ {
@@ -24,10 +20,6 @@ class Analytics
public function __construct() public function __construct()
{ {
if (!License::is_feature_available("analytics")) {
return;
}
add_action("init", [self::class, "maybe_install"]); add_action("init", [self::class, "maybe_install"]);
// Record on the front end only, after the main query is resolved. // Record on the front end only, after the main query is resolved.
add_action("template_redirect", [self::class, "record"], 999); add_action("template_redirect", [self::class, "record"], 999);
@@ -95,12 +87,13 @@ class Analytics
return; return;
} }
$ua = isset($_SERVER["HTTP_USER_AGENT"]) ? $_SERVER["HTTP_USER_AGENT"] : ""; $ua = isset($_SERVER["HTTP_USER_AGENT"]) ? sanitize_text_field(wp_unslash($_SERVER["HTTP_USER_AGENT"])) : "";
if (self::is_bot($ua)) { if (self::is_bot($ua)) {
return; return;
} }
global $wpdb; global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom pageview table.
$wpdb->insert(self::table_name(), [ $wpdb->insert(self::table_name(), [
"session_id" => self::session_id(), "session_id" => self::session_id(),
"url" => substr(self::current_path(), 0, 255), "url" => substr(self::current_path(), 0, 255),
@@ -115,7 +108,8 @@ class Analytics
private static function session_id() private static function session_id()
{ {
if (!empty($_COOKIE[self::COOKIE])) { if (!empty($_COOKIE[self::COOKIE])) {
return preg_replace("/[^a-f0-9]/", "", substr($_COOKIE[self::COOKIE], 0, 32)); $cookie = sanitize_text_field(wp_unslash($_COOKIE[self::COOKIE]));
return preg_replace("/[^a-f0-9]/", "", substr($cookie, 0, 32));
} }
$sid = md5(uniqid("", true)); $sid = md5(uniqid("", true));
// 30-minute rolling session window // 30-minute rolling session window
@@ -128,13 +122,13 @@ class Analytics
private static function current_path() private static function current_path()
{ {
$uri = isset($_SERVER["REQUEST_URI"]) ? $_SERVER["REQUEST_URI"] : "/"; $uri = isset($_SERVER["REQUEST_URI"]) ? sanitize_text_field(wp_unslash($_SERVER["REQUEST_URI"])) : "/";
return wp_parse_url($uri, PHP_URL_PATH) ?: "/"; return wp_parse_url($uri, PHP_URL_PATH) ?: "/";
} }
private static function referrer() private static function referrer()
{ {
$ref = isset($_SERVER["HTTP_REFERER"]) ? $_SERVER["HTTP_REFERER"] : ""; $ref = isset($_SERVER["HTTP_REFERER"]) ? sanitize_text_field(wp_unslash($_SERVER["HTTP_REFERER"])) : "";
if (!$ref) { if (!$ref) {
return ""; return "";
} }
@@ -178,9 +172,15 @@ class Analytics
private static function country() private static function country()
{ {
// Use a CDN-provided country header if available; otherwise leave blank. // Use a CDN-provided country header if available; otherwise leave blank.
// Sanitized into $value before any check runs on it — the empty()/
// strlen() checks below need the unslashed+sanitized copy too, not
// just the eventual return value, since reading the raw superglobal
// for any purpose (even just a length check) is what the sanitization
// rule actually flags.
foreach (["HTTP_CF_IPCOUNTRY", "GEOIP_COUNTRY_CODE", "HTTP_X_COUNTRY_CODE"] as $h) { foreach (["HTTP_CF_IPCOUNTRY", "GEOIP_COUNTRY_CODE", "HTTP_X_COUNTRY_CODE"] as $h) {
if (!empty($_SERVER[$h]) && strlen($_SERVER[$h]) === 2) { $value = isset($_SERVER[$h]) ? sanitize_text_field(wp_unslash($_SERVER[$h])) : "";
return strtoupper(sanitize_text_field($_SERVER[$h])); if ($value !== "" && strlen($value) === 2) {
return strtoupper($value);
} }
} }
return ""; return "";
+36 -8
View File
@@ -1,8 +1,15 @@
<?php <?php
namespace AuroraAdmin\App; namespace AuroraAdmin\App;
// Guard placed immediately after the namespace declaration (rather than
// after the use-import block below, this file's only departure from the
// rest of the codebase's convention) — this file's unusually large
// number of imports pushed the guard past the first several dozen
// lines, past where at least one automated file-access-protection
// scanner looks.
defined("ABSPATH") || exit();
use AuroraAdmin\Options\Settings; use AuroraAdmin\Options\Settings;
use AuroraAdmin\Licensing\License;
use AuroraAdmin\Shell\Shell; use AuroraAdmin\Shell\Shell;
use AuroraAdmin\Pages\DashboardTakeover; use AuroraAdmin\Pages\DashboardTakeover;
use AuroraAdmin\Pages\SettingsPage; use AuroraAdmin\Pages\SettingsPage;
@@ -15,10 +22,18 @@ use AuroraAdmin\Rest\PostsData;
use AuroraAdmin\Rest\PagesData; use AuroraAdmin\Rest\PagesData;
use AuroraAdmin\Rest\MediaData; use AuroraAdmin\Rest\MediaData;
use AuroraAdmin\Rest\UsersData; use AuroraAdmin\Rest\UsersData;
use AuroraAdmin\FileManager\FileManagerData;
use AuroraAdmin\Posts\ScheduledDayFilter; use AuroraAdmin\Posts\ScheduledDayFilter;
use AuroraAdmin\WhiteLabel\WhiteLabel; use AuroraAdmin\WhiteLabel\WhiteLabel;
use AuroraAdmin\Pages\SubApps; use AuroraAdmin\Pages\SubApps;
use AuroraAdmin\Pages\HijackedNativePages;
use AuroraAdmin\Pages\NativeSettingsPages;
use AuroraAdmin\Rest\PermalinksData;
use AuroraAdmin\Rest\DiscussionData;
use AuroraAdmin\Pages\AppearancePages;
use AuroraAdmin\Rest\NavMenusData;
use AuroraAdmin\Rest\ThemesData;
use AuroraAdmin\Rest\WidgetsData;
use AuroraAdmin\Rest\ProfileData;
use AuroraAdmin\MenuCreator\MenuCreator; use AuroraAdmin\MenuCreator\MenuCreator;
use AuroraAdmin\MenuCreator\MenuCreatorData; use AuroraAdmin\MenuCreator\MenuCreatorData;
use AuroraAdmin\Notices\Notices; use AuroraAdmin\Notices\Notices;
@@ -26,11 +41,13 @@ use AuroraAdmin\Notices\NoticesData;
use AuroraAdmin\Roles\RolesData; use AuroraAdmin\Roles\RolesData;
use AuroraAdmin\ActivityLog\ActivityLog; use AuroraAdmin\ActivityLog\ActivityLog;
use AuroraAdmin\ActivityLog\ActivityLogData; use AuroraAdmin\ActivityLog\ActivityLogData;
use AuroraAdmin\DatabaseExplorer\DatabaseExplorerData;
use AuroraAdmin\Login\LoginStyle; use AuroraAdmin\Login\LoginStyle;
use AuroraAdmin\Media\SvgUploads; use AuroraAdmin\Media\SvgUploads;
use AuroraAdmin\Media\MediaFolders;
defined("ABSPATH") || exit(); use AuroraAdmin\ImageFormats\ImageFormats;
use AuroraAdmin\Rest\MediaFolderData;
use AuroraAdmin\Rest\BugReportData;
use AuroraAdmin\Rest\CompanionPluginData;
/** /**
* Plugin bootstrap. Deliberately minimal: each piece of functionality * Plugin bootstrap. Deliberately minimal: each piece of functionality
@@ -43,7 +60,6 @@ class AuroraAdmin
public function __construct() public function __construct()
{ {
new Settings(); new Settings();
new License();
new Shell(); new Shell();
new DashboardTakeover(); new DashboardTakeover();
new SettingsPage(); new SettingsPage();
@@ -56,10 +72,18 @@ class AuroraAdmin
new PagesData(); new PagesData();
new MediaData(); new MediaData();
new UsersData(); new UsersData();
new FileManagerData();
new ScheduledDayFilter(); new ScheduledDayFilter();
new WhiteLabel(); new WhiteLabel();
new SubApps(); new SubApps();
new HijackedNativePages();
new NativeSettingsPages();
new PermalinksData();
new DiscussionData();
new AppearancePages();
new NavMenusData();
new ThemesData();
new WidgetsData();
new ProfileData();
new MenuCreator(); new MenuCreator();
new MenuCreatorData(); new MenuCreatorData();
new Notices(); new Notices();
@@ -67,8 +91,12 @@ class AuroraAdmin
new RolesData(); new RolesData();
new ActivityLog(); new ActivityLog();
new ActivityLogData(); new ActivityLogData();
new DatabaseExplorerData();
new LoginStyle(); new LoginStyle();
new SvgUploads(); new SvgUploads();
new MediaFolders();
new ImageFormats();
new MediaFolderData();
new BugReportData();
new CompanionPluginData();
} }
} }
@@ -1,149 +0,0 @@
<?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) . "`";
}
}
-466
View File
@@ -1,466 +0,0 @@
<?php
namespace AuroraAdmin\FileManager;
use AuroraAdmin\Licensing\License;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's File Manager — browse/edit the WordPress
* install's own files, in the spirit of the "WP File Manager" plugin
* already installed on this site (same root — ABSPATH — and same
* manage_options-only gate), but built fresh rather than embedding
* elFinder, to match every other Aurora page's own REST-controller +
* Vue-page pattern.
*
* manage_options for every single endpoint here, no exceptions — this is
* the highest-risk surface in the whole plugin (arbitrary read/write
* access to the site's own PHP files). Every path a client sends is
* resolved through resolve_path()/safe_child_path(), which realpath()s it
* and verifies it's still inside ABSPATH afterward — string-matching
* "../" alone isn't enough (symlinks, encoded traversal, etc.), so
* containment is always checked against the resolved real path, never the
* raw input.
*
* Deliberately NOT implemented (scope decisions, not oversights):
* - Zip/unzip — extraction has its own well-known class of vulnerability
* (zip-slip: a crafted archive's entries escape the extraction dir).
* - chmod/permissions editing — changing permissions on a live WP install
* is one of the easiest ways to accidentally lock yourself out or open
* a real hole; not worth it for a v1.
* - Move/copy across directories — rename (same-directory) is supported;
* full move/copy trees is more surface area for a first pass.
*/
class FileManagerData
{
// Never let these be uploaded, renamed-to, or created — anything
// server-executable is an instant RCE if it lands somewhere web-served.
const BLOCKED_EXTENSIONS = [
"php", "php3", "php4", "php5", "php7", "phtml", "pht", "phar", "phps",
"cgi", "pl", "py", "rb", "sh", "bash", "asp", "aspx", "jsp", "jspx",
"exe", "dll", "so", "com", "bat", "cmd", "htaccess",
];
// Binary-ish extensions the text editor refuses to open — avoids
// dumping garbled bytes into the editor and "saving" it back mangled.
const BINARY_EXTENSIONS = [
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico", "svg",
"pdf", "zip", "gz", "tar", "rar", "7z",
"mp3", "mp4", "mov", "avi", "wav", "ogg", "webm",
"woff", "woff2", "ttf", "eot", "otf",
"exe", "dll", "so",
];
const MAX_EDIT_SIZE = 2 * 1024 * 1024; // 2MB — editing bigger files in a plain textarea isn't useful anyway.
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
// Checked here, not just at the page-render layer (SubApps.php) — a
// locked page alone doesn't stop someone with manage_options from
// calling these endpoints directly, only the UI does.
$permission = function () {
return current_user_can("manage_options") && License::is_feature_available("file-manager");
};
register_rest_route("aurora-admin/v1", "/file-manager/list", [
"methods" => "GET",
"callback" => [self::class, "list_dir"],
"permission_callback" => $permission,
"args" => ["path" => ["type" => "string", "required" => false]],
]);
register_rest_route("aurora-admin/v1", "/file-manager/folder", [
"methods" => "POST",
"callback" => [self::class, "create_folder"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/file", [
"methods" => "POST",
"callback" => [self::class, "create_file"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/upload", [
"methods" => "POST",
"callback" => [self::class, "upload_file"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/download", [
"methods" => "GET",
"callback" => [self::class, "download_file"],
"permission_callback" => function () {
// Direct navigation link, not fetch() — no X-WP-Nonce header
// possible, so also accept the nonce as a query param (WP's
// cookie-auth check already looks at $_REQUEST, which covers
// this) alongside the same capability+license check as everywhere
// else here.
return current_user_can("manage_options") && License::is_feature_available("file-manager");
},
"args" => ["path" => ["type" => "string", "required" => true]],
]);
register_rest_route("aurora-admin/v1", "/file-manager/rename", [
"methods" => "POST",
"callback" => [self::class, "rename_item"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/delete", [
"methods" => "POST",
"callback" => [self::class, "delete_item"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/file-manager/read", [
"methods" => "GET",
"callback" => [self::class, "read_file"],
"permission_callback" => $permission,
"args" => ["path" => ["type" => "string", "required" => true]],
]);
register_rest_route("aurora-admin/v1", "/file-manager/write", [
"methods" => "POST",
"callback" => [self::class, "write_file"],
"permission_callback" => $permission,
]);
}
private static function root()
{
return rtrim(str_replace("\\", "/", realpath(ABSPATH)), "/");
}
/**
* Resolves a client-supplied relative path to a real, root-contained
* absolute path — or false if it doesn't exist or escapes the root.
* Containment is checked against realpath()'s output, not the raw
* string, so symlinks/traversal tricks can't slip past a naive
* substring check.
*/
private static function resolve_path($relative)
{
$relative = str_replace("\\", "/", (string) $relative);
$relative = ltrim($relative, "/");
if (strpos($relative, "\0") !== false) {
return false;
}
$root = self::root();
$candidate = $relative === "" ? $root : $root . "/" . $relative;
$real = realpath($candidate);
if ($real === false) {
return false;
}
$real = str_replace("\\", "/", $real);
if ($real !== $root && strpos($real, $root . "/") !== 0) {
return false;
}
return $real;
}
private static function sanitize_name($name)
{
$name = wp_basename((string) $name);
$name = trim($name);
if ($name === "" || $name === "." || $name === "..") {
return "";
}
if (preg_match('/[\x00-\x1F]/', $name)) {
return "";
}
return $name;
}
private static function extension($name)
{
return strtolower(pathinfo($name, PATHINFO_EXTENSION));
}
public static function list_dir($request)
{
$relative = (string) $request->get_param("path");
$real = self::resolve_path($relative);
if ($real === false || !is_dir($real)) {
return new \WP_Error("aurora_fm_invalid_path", __("Folder not found.", "aurora-admin"), ["status" => 404]);
}
$root = self::root();
$entries = @scandir($real);
if ($entries === false) {
return new \WP_Error("aurora_fm_read_failed", __("Could not read folder.", "aurora-admin"), ["status" => 500]);
}
$items = [];
foreach ($entries as $entry) {
if ($entry === "." || $entry === "..") {
continue;
}
$full = $real . "/" . $entry;
$is_dir = is_dir($full);
$items[] = [
"name" => $entry,
"path" => ltrim(substr($full, strlen($root)), "/"),
"type" => $is_dir ? "dir" : "file",
"size" => $is_dir ? null : @filesize($full),
"sizeLabel" => $is_dir ? "" : size_format((int) @filesize($full)),
"modified" => gmdate("M j, Y g:i a", @filemtime($full)),
"extension" => $is_dir ? "" : self::extension($entry),
"isEditable" => !$is_dir && self::is_editable($entry, @filesize($full)),
"isWritable" => is_writable($full),
];
}
usort($items, function ($a, $b) {
if ($a["type"] !== $b["type"]) {
return $a["type"] === "dir" ? -1 : 1;
}
return strcasecmp($a["name"], $b["name"]);
});
$current = ltrim(substr($real, strlen($root)), "/");
return new \WP_REST_Response([
"path" => $current,
"breadcrumb" => self::breadcrumb($current),
"items" => $items,
], 200);
}
private static function breadcrumb($path)
{
if ($path === "") {
return [];
}
$parts = explode("/", $path);
$crumbs = [];
$accum = "";
foreach ($parts as $part) {
$accum = $accum === "" ? $part : $accum . "/" . $part;
$crumbs[] = ["label" => $part, "path" => $accum];
}
return $crumbs;
}
private static function is_editable($name, $size)
{
if ($size !== false && $size > self::MAX_EDIT_SIZE) {
return false;
}
$ext = self::extension($name);
if ($ext !== "" && in_array($ext, self::BINARY_EXTENSIONS, true)) {
return false;
}
return true;
}
public static function create_folder($request)
{
$parent_real = self::resolve_path($request->get_param("path"));
$name = self::sanitize_name($request->get_param("name"));
if ($parent_real === false || !is_dir($parent_real) || $name === "") {
return new \WP_Error("aurora_fm_invalid", __("Invalid folder name or location.", "aurora-admin"), ["status" => 400]);
}
$target = $parent_real . "/" . $name;
if (file_exists($target)) {
return new \WP_Error("aurora_fm_exists", __("A file or folder with that name already exists.", "aurora-admin"), ["status" => 409]);
}
if (!@mkdir($target, 0755)) {
return new \WP_Error("aurora_fm_mkdir_failed", __("Could not create the folder.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Folder created.", "aurora-admin")], 200);
}
public static function create_file($request)
{
$parent_real = self::resolve_path($request->get_param("path"));
$name = self::sanitize_name($request->get_param("name"));
if ($parent_real === false || !is_dir($parent_real) || $name === "") {
return new \WP_Error("aurora_fm_invalid", __("Invalid file name or location.", "aurora-admin"), ["status" => 400]);
}
$ext = self::extension($name);
if ($ext !== "" && in_array($ext, self::BLOCKED_EXTENSIONS, true)) {
return new \WP_Error("aurora_fm_blocked_type", __("That file type isn't allowed.", "aurora-admin"), ["status" => 400]);
}
$target = $parent_real . "/" . $name;
if (file_exists($target)) {
return new \WP_Error("aurora_fm_exists", __("A file or folder with that name already exists.", "aurora-admin"), ["status" => 409]);
}
if (@file_put_contents($target, "") === false) {
return new \WP_Error("aurora_fm_create_failed", __("Could not create the file.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("File created.", "aurora-admin")], 200);
}
public static function upload_file($request)
{
$parent_real = self::resolve_path($request->get_param("path"));
if ($parent_real === false || !is_dir($parent_real)) {
return new \WP_Error("aurora_fm_invalid", __("Invalid upload location.", "aurora-admin"), ["status" => 400]);
}
$files = $request->get_file_params();
if (empty($files["file"]) || !isset($files["file"]["tmp_name"])) {
return new \WP_Error("aurora_fm_no_file", __("No file was uploaded.", "aurora-admin"), ["status" => 400]);
}
$file = $files["file"];
if (!empty($file["error"])) {
return new \WP_Error("aurora_fm_upload_error", __("Upload failed.", "aurora-admin"), ["status" => 400]);
}
if ($file["size"] > self::MAX_UPLOAD_SIZE) {
return new \WP_Error("aurora_fm_too_large", __("File is larger than the 50MB limit.", "aurora-admin"), ["status" => 400]);
}
$name = self::sanitize_name($file["name"]);
$ext = self::extension($name);
if ($name === "" || ($ext !== "" && in_array($ext, self::BLOCKED_EXTENSIONS, true))) {
return new \WP_Error("aurora_fm_blocked_type", __("That file type isn't allowed.", "aurora-admin"), ["status" => 400]);
}
$target = $parent_real . "/" . $name;
if (!is_uploaded_file($file["tmp_name"]) || !@move_uploaded_file($file["tmp_name"], $target)) {
return new \WP_Error("aurora_fm_upload_failed", __("Could not save the uploaded file.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("File uploaded.", "aurora-admin")], 200);
}
public static function download_file($request)
{
$real = self::resolve_path($request->get_param("path"));
if ($real === false || !is_file($real)) {
return new \WP_Error("aurora_fm_invalid_path", __("File not found.", "aurora-admin"), ["status" => 404]);
}
nocache_headers();
header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=\"" . basename($real) . "\"");
header("Content-Length: " . filesize($real));
readfile($real);
exit();
}
public static function rename_item($request)
{
$real = self::resolve_path($request->get_param("path"));
$new_name = self::sanitize_name($request->get_param("newName"));
if ($real === false || $new_name === "" || $real === self::root()) {
return new \WP_Error("aurora_fm_invalid", __("Invalid rename request.", "aurora-admin"), ["status" => 400]);
}
$ext = self::extension($new_name);
if (!is_dir($real) && $ext !== "" && in_array($ext, self::BLOCKED_EXTENSIONS, true)) {
return new \WP_Error("aurora_fm_blocked_type", __("That file type isn't allowed.", "aurora-admin"), ["status" => 400]);
}
$parent = dirname($real);
$target = $parent . "/" . $new_name;
if (file_exists($target)) {
return new \WP_Error("aurora_fm_exists", __("A file or folder with that name already exists.", "aurora-admin"), ["status" => 409]);
}
if (!@rename($real, $target)) {
return new \WP_Error("aurora_fm_rename_failed", __("Could not rename.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Renamed.", "aurora-admin")], 200);
}
public static function delete_item($request)
{
$real = self::resolve_path($request->get_param("path"));
if ($real === false || $real === self::root()) {
return new \WP_Error("aurora_fm_invalid", __("Invalid delete request.", "aurora-admin"), ["status" => 400]);
}
$ok = is_dir($real) ? self::delete_recursive($real) : @unlink($real);
if (!$ok) {
return new \WP_Error("aurora_fm_delete_failed", __("Could not delete.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Deleted.", "aurora-admin")], 200);
}
private static function delete_recursive($dir)
{
$entries = @scandir($dir);
if ($entries === false) {
return false;
}
foreach ($entries as $entry) {
if ($entry === "." || $entry === "..") {
continue;
}
$full = $dir . "/" . $entry;
if (is_dir($full) && !is_link($full)) {
if (!self::delete_recursive($full)) {
return false;
}
} elseif (!@unlink($full)) {
return false;
}
}
return @rmdir($dir);
}
public static function read_file($request)
{
$real = self::resolve_path($request->get_param("path"));
if ($real === false || !is_file($real)) {
return new \WP_Error("aurora_fm_invalid_path", __("File not found.", "aurora-admin"), ["status" => 404]);
}
$size = filesize($real);
if (!self::is_editable(basename($real), $size)) {
return new \WP_Error("aurora_fm_not_editable", __("This file can't be opened in the editor (binary or too large).", "aurora-admin"), ["status" => 400]);
}
$content = @file_get_contents($real);
if ($content === false) {
return new \WP_Error("aurora_fm_read_failed", __("Could not read the file.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response([
"content" => $content,
"size" => $size,
"isWritable" => is_writable($real),
], 200);
}
public static function write_file($request)
{
$real = self::resolve_path($request->get_param("path"));
if ($real === false || !is_file($real)) {
return new \WP_Error("aurora_fm_invalid_path", __("File not found.", "aurora-admin"), ["status" => 404]);
}
if (!self::is_editable(basename($real), 0)) {
return new \WP_Error("aurora_fm_not_editable", __("This file can't be edited.", "aurora-admin"), ["status" => 400]);
}
$content = (string) $request->get_param("content");
if (@file_put_contents($real, $content) === false) {
return new \WP_Error("aurora_fm_write_failed", __("Could not save the file — check that it's writable.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Saved.", "aurora-admin")], 200);
}
}
+346
View File
@@ -0,0 +1,346 @@
<?php
namespace AuroraAdmin\ImageFormats;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Settings > Modern Image Formats: generates a WebP or AVIF sibling file
* for every size WordPress already produces for a new JPEG/PNG upload
* (full size plus every registered subsize), then optionally rewrites
* <img> markup into a <picture> element offering the modern format with
* the original as a fallback source.
*
* Deliberately additive, never destructive: the original JPEG/PNG files
* WordPress generates are always left in place untouched. This plugin
* only ever adds sibling files alongside them and points browsers that
* understand the modern format at those siblings via <picture> — nothing
* about the attachment's own post_mime_type, the original files, or any
* other plugin/theme reading wp_get_attachment_image_src() the normal way
* is changed. Existing library images are unaffected until re-uploaded or
* regenerated (e.g. via the Regenerate Thumbnails plugin or `wp media
* regenerate`), same as any other subsize-generation change.
*
* Generated sibling files are recorded in the attachment's own metadata
* under the 'aurora_image_formats' key (a sibling structure to core's own
* 'sizes' key, one entry per size name plus 'full') so cleanup on delete
* and markup rewriting both have a single source of truth for what was
* actually generated.
*/
class ImageFormats
{
const ELIGIBLE_MIME_TYPES = ["image/jpeg", "image/png"];
public function __construct()
{
add_filter("wp_generate_attachment_metadata", [self::class, "generate_sibling_formats"], 20, 2);
add_action("delete_attachment", [self::class, "cleanup_sibling_formats"]);
add_filter("wp_editor_set_quality", [self::class, "set_quality"], 10, 2);
add_filter("wp_content_img_tag", [self::class, "filter_content_img_tag"], 10, 3);
add_filter("wp_get_attachment_image_html", [self::class, "filter_attachment_image_html"], 10, 5);
}
private static function enabled()
{
return (bool) Settings::get("image_formats_enabled", true);
}
/**
* A single target mime type, not a list — matches the Settings UI's
* single "Output format" choice. 'auto' prefers AVIF (smaller files at
* a given quality) and falls back to WebP; an explicit choice that the
* server can't actually produce (checked via wp_image_editor_supports(),
* the same core function any image-editor-capability check should use)
* returns null, which callers treat as "generate nothing" rather than
* erroring — a host without modern format support just doesn't get the
* feature, silently, same as the rest of this plugin's graceful-
* degradation posture elsewhere (e.g. AVIF write support genuinely
* varies a lot by ImageMagick build).
*/
private static function target_mime_type()
{
$format = (string) Settings::get("image_formats_format", "auto");
if ($format === "webp") {
return wp_image_editor_supports(["mime_type" => "image/webp"]) ? "image/webp" : null;
}
if ($format === "avif") {
return wp_image_editor_supports(["mime_type" => "image/avif"]) ? "image/avif" : null;
}
if (wp_image_editor_supports(["mime_type" => "image/avif"])) {
return "image/avif";
}
if (wp_image_editor_supports(["mime_type" => "image/webp"])) {
return "image/webp";
}
return null;
}
/**
* Runs after WordPress's own subsize generation (priority 20, and in
* any case wp_generate_attachment_metadata() only fires this filter
* once 'sizes' is already fully populated) — so every size this method
* sees already has a real file on disk to re-encode.
*/
public static function generate_sibling_formats($metadata, $attachment_id)
{
if (!self::enabled() || !is_array($metadata)) {
return $metadata;
}
$mime = get_post_mime_type($attachment_id);
if (!in_array($mime, self::ELIGIBLE_MIME_TYPES, true)) {
return $metadata;
}
$target_mime = self::target_mime_type();
if ($target_mime === null) {
return $metadata;
}
$file = get_attached_file($attachment_id);
if (!$file || !file_exists($file)) {
return $metadata;
}
$formats = [];
$full = self::generate_sibling($file, $target_mime);
if ($full !== null) {
$formats["full"] = $full;
}
if (!empty($metadata["sizes"]) && is_array($metadata["sizes"])) {
$base_dir = dirname($file);
foreach ($metadata["sizes"] as $size_name => $size_data) {
if (empty($size_data["file"])) {
continue;
}
$size_path = $base_dir . "/" . $size_data["file"];
if (!file_exists($size_path)) {
continue;
}
$sibling = self::generate_sibling($size_path, $target_mime);
if ($sibling !== null) {
$formats[$size_name] = $sibling;
}
}
}
if (!empty($formats)) {
$metadata["aurora_image_formats"] = $formats;
}
return $metadata;
}
/**
* Re-encodes a single already-generated size file into the target mime
* type, in the same directory (WP_Image_Editor::save() with a null
* filename derives a sibling filename from the source automatically).
* Discards the result if it isn't actually smaller than the source —
* there's no point keeping a "modern format" copy around that's bigger
* than the file it's meant to replace on the wire.
*/
private static function generate_sibling($source_path, $target_mime)
{
$editor = wp_get_image_editor($source_path);
if (is_wp_error($editor)) {
return null;
}
$saved = $editor->save(null, $target_mime);
if (is_wp_error($saved) || empty($saved["file"])) {
return null;
}
$original_size = @filesize($source_path);
if ($original_size && !empty($saved["filesize"]) && $saved["filesize"] >= $original_size) {
if (!empty($saved["path"]) && file_exists($saved["path"])) {
wp_delete_file($saved["path"]);
}
return null;
}
return [
"file" => $saved["file"],
"filesize" => $saved["filesize"] ?? null,
"mime-type" => $saved["mime-type"] ?? $target_mime,
];
}
/**
* Quality for the modern formats specifically — core's own default
* (82 for most editors) is a reasonable target for WebP/AVIF too, but
* setting it explicitly here means a future change to core's JPEG
* default doesn't silently change modern-format quality along with it.
* Untouched for every other mime type.
*/
public static function set_quality($quality, $mime_type)
{
if ($mime_type === "image/webp" || $mime_type === "image/avif") {
return 82;
}
return $quality;
}
/**
* Deletes every generated sibling file when the attachment itself is
* deleted — WordPress core only knows to clean up the files it
* generated itself (recorded in 'sizes'), not this plugin's additions,
* so without this every deleted image would leave orphaned .webp/.avif
* files behind indefinitely.
*/
public static function cleanup_sibling_formats($attachment_id)
{
$metadata = wp_get_attachment_metadata($attachment_id);
if (empty($metadata["aurora_image_formats"]) || !is_array($metadata["aurora_image_formats"])) {
return;
}
$file = get_attached_file($attachment_id);
if (!$file) {
return;
}
$base_dir = dirname($file);
foreach ($metadata["aurora_image_formats"] as $entry) {
if (empty($entry["file"])) {
continue;
}
$path = $base_dir . "/" . $entry["file"];
if (file_exists($path)) {
wp_delete_file($path);
}
}
}
// --- <picture> markup rewriting -----------------------------------------
/**
* Covers images WordPress finds inside post content, widgets, and
* similar rendered text (wp_filter_content_tags() applies this core
* filter to each <img> tag it detects).
*/
public static function filter_content_img_tag($filtered_image, $context, $attachment_id)
{
$picture = self::build_picture($filtered_image, (int) $attachment_id);
return $picture ?? $filtered_image;
}
/**
* Covers images rendered directly via wp_get_attachment_image() (post
* thumbnails, block-editor image blocks that call it, etc.) — a
* separate core filter from wp_content_img_tag above since core itself
* doesn't route wp_get_attachment_image() output back through that one.
*/
public static function filter_attachment_image_html($html, $attachment_id, $size, $icon, $attr)
{
$picture = self::build_picture($html, (int) $attachment_id);
return $picture ?? $html;
}
/**
* Returns a <picture> wrapping the original $html as the fallback
* child, with one <source> offering the generated modern format, or
* null if there's nothing to rewrite (feature/picture-element disabled,
* no <img> tag present, or no sibling formats were ever generated for
* this attachment).
*/
private static function build_picture($html, $attachment_id)
{
if (!self::enabled() || !Settings::get("image_formats_picture_element", true)) {
return null;
}
if ($attachment_id <= 0 || strpos($html, "<img") === false) {
return null;
}
$metadata = wp_get_attachment_metadata($attachment_id);
if (empty($metadata["aurora_image_formats"]) || !is_array($metadata["aurora_image_formats"])) {
return null;
}
$srcset = self::build_srcset($metadata);
if ($srcset === "") {
return null;
}
$formats = $metadata["aurora_image_formats"];
$representative = $formats["full"] ?? reset($formats);
$mime = $representative["mime-type"] ?? "";
if ($mime === "") {
return null;
}
// The original <img>'s own `sizes` attribute already reflects the
// theme/layout's responsive-width logic (core computed it once when
// building the <img> tag) — reusing it on the <source> is correct and
// avoids re-deriving the same value a second way.
$sizes_attr = "";
if (preg_match('/\ssizes=["\']([^"\']+)["\']/', $html, $m)) {
$sizes_attr = ' sizes="' . esc_attr($m[1]) . '"';
}
$source = sprintf(
'<source type="%1$s" srcset="%2$s"%3$s>',
esc_attr($mime),
esc_attr($srcset),
$sizes_attr
);
return "<picture>" . $source . $html . "</picture>";
}
private static function build_srcset($metadata)
{
$formats = $metadata["aurora_image_formats"];
$entries = [];
if (!empty($formats["full"]) && !empty($metadata["width"])) {
$entry = self::srcset_entry($metadata, $formats["full"], (int) $metadata["width"]);
if ($entry !== "") {
$entries[] = $entry;
}
}
if (!empty($metadata["sizes"]) && is_array($metadata["sizes"])) {
foreach ($metadata["sizes"] as $size_name => $size_data) {
if (empty($formats[$size_name]) || empty($size_data["width"])) {
continue;
}
$entry = self::srcset_entry($metadata, $formats[$size_name], (int) $size_data["width"]);
if ($entry !== "") {
$entries[] = $entry;
}
}
}
return implode(", ", array_unique($entries));
}
/**
* Sibling files live in the same directory as the size file they were
* generated from, which for a standard (non-year/month-disabled)
* install is dirname($metadata['file']) relative to the uploads
* baseurl — the same relative-path convention core's own subsize URLs
* use, not something specific to this plugin's file layout.
*/
private static function srcset_entry($metadata, $format_entry, $width)
{
if (empty($format_entry["file"]) || $width <= 0 || empty($metadata["file"])) {
return "";
}
$upload_dir = wp_get_upload_dir();
$relative_dir = trailingslashit(dirname($metadata["file"]));
if ($relative_dir === "./") {
$relative_dir = "";
}
$url = $upload_dir["baseurl"] . "/" . $relative_dir . $format_entry["file"];
return esc_url_raw($url) . " " . $width . "w";
}
}
-70
View File
@@ -1,70 +0,0 @@
<?php
namespace AuroraAdmin\Licensing;
defined("ABSPATH") || exit();
/**
* Detects tampering with the files that implement the free/paid gate
* itself, so someone can't just delete/comment-out a license check and
* redistribute a "cracked" copy that shows every paid feature with no
* license at all.
*
* Compares each protected file's sha256 against integrity-manifest.json,
* generated at zip-build time (bin/build-integrity-manifest.php) — not
* committed to git, same "regenerate, don't version" pattern as app/dist.
* A dev checkout with no manifest present is never treated as tampered
* (see is_tampered()), so this only ever applies to the distributed zip.
*
* Honest limit: this stops editing/removing a check *inside* one of the
* protected files (the realistic, low-effort tampering case — it changes
* the file's hash). It cannot stop someone who edits this file itself to
* neutralize the comparison, or who never lets WordPress load license-
* gated code the normal way at all. No check living in plain, editable
* PHP can close that gap — only a compiled/encoded extension could, and
* that has its own real costs (hosting compatibility, trust). This raises
* the bar; it does not make circumvention impossible.
*/
class Integrity
{
const MANIFEST_FILE = "admin/src/Licensing/integrity-manifest.json";
/** @var bool|null Cached for the request — file hashing isn't free. */
private static $tampered = null;
public static function is_tampered()
{
if (self::$tampered !== null) {
return self::$tampered;
}
$manifest = self::load_manifest();
if ($manifest === null) {
// No manifest — a source checkout (dev) rather than a built zip.
// Nothing to compare against, so never lock a dev environment out.
self::$tampered = false;
return false;
}
foreach ($manifest as $relative_path => $expected_hash) {
$full_path = AURORA_ADMIN_PATH . $relative_path;
if (!file_exists($full_path) || hash_file("sha256", $full_path) !== $expected_hash) {
self::$tampered = true;
return true;
}
}
self::$tampered = false;
return false;
}
private static function load_manifest()
{
$path = AURORA_ADMIN_PATH . self::MANIFEST_FILE;
if (!file_exists($path)) {
return null;
}
$decoded = json_decode((string) file_get_contents($path), true);
return is_array($decoded) && $decoded ? $decoded : null;
}
}
-205
View File
@@ -1,205 +0,0 @@
<?php
namespace AuroraAdmin\Licensing;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Single gate every paid feature funnels through, so the free/paid split
* lives in one place instead of being re-decided in each feature's code.
*
* Validates against the real license server: the "aurora-licensing" WP
* plugin running on auroraplugins.com, REST namespace
* /wp-json/aurora-licensing/v1/ (validate/activate/deactivate). Every
* result is cached in the aurora_admin_license_status option for
* STATUS_TTL, refreshed immediately (not just on expiry) whenever the
* license_key setting actually changes — see on_settings_updated().
*/
class License
{
/**
* Paid sub-apps/features, keyed by the id used to gate them, mapped to a
* human label used in upgrade prompts.
*/
const PAID_FEATURES = [
"role-editor" => "Role Editor",
"activity-log" => "Activity Log",
"database-explorer" => "Database Explorer",
"file-manager" => "File Manager",
"analytics" => "Analytics",
"white-label" => "White Label",
"remote-site-switcher" => "Remote Site Switcher",
];
const API_BASE = "https://auroraplugins.com/wp-json/aurora-licensing/v1/";
const STATUS_OPTION = "aurora_admin_license_status";
const STATUS_TTL = 12 * HOUR_IN_SECONDS;
public function __construct()
{
add_action("update_option_aurora_admin_settings", [self::class, "on_settings_updated"], 10, 2);
}
/**
* Fires on every save of the aurora_admin_settings option (not just the
* license field) — only acts when license_key actually changed, so
* saving an unrelated settings category never triggers a remote call.
* Deactivates the old key's activation on this domain before activating
* the new one, so switching keys doesn't leak a stale activation slot.
*/
public static function on_settings_updated($old_value, $value)
{
$old_key = is_array($old_value) ? trim((string) ($old_value["license_key"] ?? "")) : "";
$new_key = is_array($value) ? trim((string) ($value["license_key"] ?? "")) : "";
if ($old_key === $new_key) {
return;
}
if ($old_key !== "") {
self::call("deactivate", ["license_key" => $old_key, "domain" => self::domain()]);
}
if ($new_key === "") {
delete_option(self::STATUS_OPTION);
return;
}
self::activate($new_key);
}
public static function is_active()
{
$status = self::status();
return !empty($status["valid"]) && ($status["status"] ?? "") === "active";
}
/**
* True for any feature not in PAID_FEATURES (nothing to gate) or when a
* license is active; false only when the feature is paid and unlicensed.
*
* Checked first, ahead of an actual license: if the gate files
* themselves have been tampered with (see Integrity::is_tampered()),
* every paid feature re-locks regardless of license status — a modified
* copy can't be redistributed showing paid features unlocked with no
* license at all.
*/
public static function is_feature_available($feature)
{
if (!isset(self::PAID_FEATURES[$feature])) {
return true;
}
if (Integrity::is_tampered()) {
return false;
}
return self::is_active();
}
public static function feature_label($feature)
{
return isset(self::PAID_FEATURES[$feature]) ? self::PAID_FEATURES[$feature] : "";
}
/**
* The license server's /validate response shape (see call()), cached in
* aurora_admin_license_status for STATUS_TTL so every paid-feature check
* across an admin page load — there are several per request — doesn't
* each cost a remote HTTP round trip. Re-validated immediately (not just
* on expiry) if the cached result was for a different key than what's
* currently saved, so a key change is never served stale data even if
* on_settings_updated() somehow didn't fire first.
*/
public static function status()
{
$key = trim((string) Settings::get("license_key", ""));
if ($key === "") {
return ["valid" => false, "status" => "none"];
}
$cached = get_option(self::STATUS_OPTION);
$fresh =
is_array($cached) &&
($cached["checked_key"] ?? null) === $key &&
($cached["checked_at"] ?? 0) > time() - self::STATUS_TTL;
if ($fresh) {
return $cached;
}
return self::validate($key);
}
public static function validate($key)
{
$result = self::call("validate", [
"license_key" => $key,
"domain" => self::domain(),
]);
$result["checked_key"] = $key;
$result["checked_at"] = time();
update_option(self::STATUS_OPTION, $result, false);
return $result;
}
private static function activate($key)
{
self::call("activate", [
"license_key" => $key,
"domain" => self::domain(),
]);
// /activate's response is a narrower shape ({activated, plan, ...} or
// an error) than /validate's ({valid, status, active_sites, ...}) —
// re-validate right after so the cached status has the full shape
// status()/is_active() and the Settings UI expect either way.
self::validate($key);
}
private static function domain()
{
// The server normalizes domain server-side (strips protocol/www/
// trailing slash) — a bare host is enough, no need to duplicate that
// normalization here.
return (string) parse_url(home_url(), PHP_URL_HOST);
}
/**
* POST $endpoint against the license server. Always returns a decoded
* array — network failures and non-JSON responses collapse into the
* same {valid:false, status:"unreachable", ...} shape as a real error
* response, so callers only ever need to branch on the response fields,
* never on WP_Error separately.
*/
private static function call($endpoint, $body)
{
$response = wp_remote_post(self::API_BASE . $endpoint, [
"timeout" => 8,
"headers" => ["Content-Type" => "application/json"],
"body" => wp_json_encode($body),
]);
if (is_wp_error($response)) {
return [
"valid" => false,
"status" => "unreachable",
"error" => "unreachable",
"message" => $response->get_error_message(),
];
}
$decoded = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($decoded)) {
return [
"valid" => false,
"status" => "unreachable",
"error" => "invalid_response",
"message" => "License server returned an unexpected response.",
];
}
return $decoded;
}
}
+392
View File
@@ -0,0 +1,392 @@
<?php
namespace AuroraAdmin\Media;
defined("ABSPATH") || exit();
/**
* Storage + model for Media Library folders — duplicates the organizing
* capability of the FileBird Pro plugin, at the user's request, with its
* own independent schema rather than reusing FileBird's tables.
*
* Deliberately simpler than FileBird's own model in two ways:
* - No folder "type" (folder vs. collection/saved-search) — collections
* add no organizational value over folders, skipped unless asked for.
* - One folder per attachment, enforced here (assign_attachments() always
* removes any existing membership row first) — avoids the ambiguity of
* multi-folder membership in the UI, even though the join table's shape
* would technically allow it.
*
* REST surface lives in MediaFolderData.php; this class only owns storage.
*/
class MediaFolders
{
const TABLE_FOLDERS = "aurora_media_folders";
const TABLE_ITEMS = "aurora_media_folder_items";
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_media_folders_db_version";
// Pseudo-folder ids the UI always shows above the real tree. Never
// written to the folders table — resolved specially wherever a folder id
// is accepted (get_tree(), MediaData::list_media()).
const ALL_MEDIA = 0;
const UNCATEGORIZED = -1;
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
}
public static function folders_table()
{
global $wpdb;
return $wpdb->prefix . self::TABLE_FOLDERS;
}
public static function items_table()
{
global $wpdb;
return $wpdb->prefix . self::TABLE_ITEMS;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$charset = $wpdb->get_charset_collate();
$folders_sql = "CREATE TABLE " . self::folders_table() . " (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(191) NOT NULL,
parent BIGINT UNSIGNED NOT NULL DEFAULT 0,
ord INT NOT NULL DEFAULT 0,
color VARCHAR(7) NULL,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_parent (parent)
) {$charset};";
$items_sql = "CREATE TABLE " . self::items_table() . " (
folder_id BIGINT UNSIGNED NOT NULL,
attachment_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (folder_id, attachment_id),
KEY idx_attachment (attachment_id)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($folders_sql);
dbDelta($items_sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
/**
* Full folder tree (nested children[]) with per-folder attachment counts,
* plus the two pseudo-folders pinned above it by the caller (REST layer),
* not here — this only returns the real, user-created folders.
*/
public static function get_tree()
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query.
$rows = $wpdb->get_results("SELECT id, name, parent, ord, color FROM " . self::folders_table() . " ORDER BY parent ASC, ord ASC, name ASC", ARRAY_A);
$counts = self::counts_by_folder();
$by_parent = [];
foreach ($rows as $row) {
$row["id"] = (int) $row["id"];
$row["parent"] = (int) $row["parent"];
$row["ord"] = (int) $row["ord"];
$row["count"] = $counts[$row["id"]] ?? 0;
$row["children"] = [];
$by_parent[$row["parent"]][] = $row;
}
$build = function ($parent_id) use (&$build, &$by_parent) {
$children = $by_parent[$parent_id] ?? [];
foreach ($children as &$child) {
$child["children"] = $build($child["id"]);
}
return $children;
};
return $build(0);
}
private static function counts_by_folder()
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names from internal, hardcoded {$wpdb->prefix}-based constants, not user input; no user-supplied values in this query.
$rows = $wpdb->get_results("SELECT fi.folder_id, COUNT(*) AS c FROM " . self::items_table() . " fi INNER JOIN {$wpdb->posts} p ON p.ID = fi.attachment_id WHERE p.post_type = 'attachment' AND p.post_status = 'inherit' GROUP BY fi.folder_id");
$counts = [];
foreach ($rows as $row) {
$counts[(int) $row->folder_id] = (int) $row->c;
}
return $counts;
}
public static function total_attachment_count()
{
return (int) wp_count_posts("attachment")->inherit;
}
public static function uncategorized_count()
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query.
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->posts} p WHERE p.post_type = 'attachment' AND p.post_status = 'inherit' AND p.ID NOT IN (SELECT attachment_id FROM " . self::items_table() . ")");
}
public static function create_folder($name, $parent)
{
global $wpdb;
$name = self::unique_name(trim((string) $name), (int) $parent);
if ($name === "") {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$ord = (int) $wpdb->get_var($wpdb->prepare("SELECT COALESCE(MAX(ord), -1) + 1 FROM " . self::folders_table() . " WHERE parent = %d", (int) $parent));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->insert(
self::folders_table(),
[
"name" => $name,
"parent" => (int) $parent,
"ord" => $ord,
"created_by" => get_current_user_id(),
"created_at" => current_time("mysql"),
],
["%s", "%d", "%d", "%d", "%s"]
);
return (int) $wpdb->insert_id;
}
private static function unique_name($name, $parent)
{
if ($name === "") {
return "";
}
global $wpdb;
$base = $name;
$suffix = 2;
while (self::name_taken($parent, $name)) {
$name = $base . " ({$suffix})";
$suffix++;
}
return $name;
}
private static function name_taken($parent, $name)
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d/%s values are passed through $wpdb->prepare() below.
return (bool) $wpdb->get_var($wpdb->prepare("SELECT id FROM " . self::folders_table() . " WHERE parent = %d AND name = %s", (int) $parent, $name));
}
public static function rename_folder($id, $name)
{
global $wpdb;
$id = (int) $id;
$name = trim((string) $name);
if (!$id || $name === "") {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$parent = (int) $wpdb->get_var($wpdb->prepare("SELECT parent FROM " . self::folders_table() . " WHERE id = %d", $id));
$name = self::unique_name($name, $parent);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->update(self::folders_table(), ["name" => $name], ["id" => $id], ["%s"], ["%d"]);
return true;
}
/**
* Reparents a folder and renumbers the destination parent's children by
* the caller-supplied order. Simpler than FileBird's live insert-shift
* math (which recomputes `ord` for every sibling on every drag step) —
* the UI sends the full sibling id list in its new order after a drop,
* and this just writes that order out in one pass.
*/
public static function move_folder($id, $new_parent, $sibling_order = [])
{
global $wpdb;
$id = (int) $id;
$new_parent = (int) $new_parent;
if ($id === $new_parent) {
return false;
}
if (self::is_descendant($new_parent, $id)) {
// Refuse to move a folder into its own descendant — would orphan
// the subtree into an unreachable cycle.
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$name = $wpdb->get_var($wpdb->prepare("SELECT name FROM " . self::folders_table() . " WHERE id = %d", $id));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$current_parent = (int) $wpdb->get_var($wpdb->prepare("SELECT parent FROM " . self::folders_table() . " WHERE id = %d", $id));
if ($current_parent !== $new_parent) {
$renamed = self::unique_name((string) $name, $new_parent);
if ($renamed !== $name) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->update(self::folders_table(), ["name" => $renamed], ["id" => $id], ["%s"], ["%d"]);
}
}
$order = array_map("intval", $sibling_order);
if (!in_array($id, $order, true)) {
$order[] = $id;
}
foreach ($order as $index => $sibling_id) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->update(
self::folders_table(),
["parent" => $new_parent, "ord" => $index],
["id" => $sibling_id],
["%d", "%d"],
["%d"]
);
}
return true;
}
private static function is_descendant($maybe_descendant_id, $ancestor_id)
{
global $wpdb;
$current = (int) $maybe_descendant_id;
// Bounded walk up the parent chain — folder depth is never large
// enough for this to matter, and it guarantees termination even if
// data were ever corrupted into a cycle.
for ($i = 0; $i < 100 && $current > 0; $i++) {
if ($current === (int) $ancestor_id) {
return true;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$current = (int) $wpdb->get_var($wpdb->prepare("SELECT parent FROM " . self::folders_table() . " WHERE id = %d", $current));
}
return false;
}
/**
* Deletes a folder. Child folders are reparented to root rather than
* cascade-deleted — less destructive default, matches the plan's choice
* over FileBird's own recursive delete.
*/
public static function delete_folder($id)
{
global $wpdb;
$id = (int) $id;
if (!$id) {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->update(self::folders_table(), ["parent" => 0], ["parent" => $id], ["%d"], ["%d"]);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->delete() is WordPress's own escaping-safe API for writing to Aurora's custom folder-items table.
$wpdb->delete(self::items_table(), ["folder_id" => $id], ["%d"]);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->delete() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->delete(self::folders_table(), ["id" => $id], ["%d"]);
return true;
}
/**
* Moves the given attachments into $folder_id, replacing any existing
* folder membership (enforces one-folder-per-attachment). $folder_id of
* UNCATEGORIZED just removes membership without assigning a new folder.
*/
public static function assign_attachments($folder_id, $attachment_ids)
{
global $wpdb;
$folder_id = (int) $folder_id;
$attachment_ids = array_map("intval", (array) $attachment_ids);
$attachment_ids = array_filter($attachment_ids);
if (empty($attachment_ids)) {
return false;
}
$placeholders = implode(",", array_fill(0, count($attachment_ids), "%d"));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is an internal, hardcoded {$wpdb->prefix}-based constant, not user input; {$placeholders} is a dynamically built run of %d tokens (one per element of $attachment_ids) consumed by the prepare() call immediately below, so the placeholders are in fact present and filled.
$wpdb->query($wpdb->prepare("DELETE FROM " . self::items_table() . " WHERE attachment_id IN ({$placeholders})", ...$attachment_ids));
if ($folder_id === self::UNCATEGORIZED || $folder_id === self::ALL_MEDIA) {
return true;
}
foreach ($attachment_ids as $attachment_id) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom folder-items table.
$wpdb->insert(
self::items_table(),
["folder_id" => $folder_id, "attachment_id" => $attachment_id],
["%d", "%d"]
);
}
return true;
}
public static function attachment_ids_in_folder($folder_id)
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
return array_map("intval", $wpdb->get_col($wpdb->prepare("SELECT attachment_id FROM " . self::items_table() . " WHERE folder_id = %d", (int) $folder_id)));
}
public static function folder_id_for_attachment($attachment_id)
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$folder_id = $wpdb->get_var($wpdb->prepare("SELECT folder_id FROM " . self::items_table() . " WHERE attachment_id = %d", (int) $attachment_id));
return $folder_id !== null ? (int) $folder_id : self::UNCATEGORIZED;
}
public static function all_assigned_attachment_ids()
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query.
return array_map("intval", $wpdb->get_col("SELECT DISTINCT attachment_id FROM " . self::items_table()));
}
/**
* Batch lookup, used by MediaData::list_media() so a page of results
* costs one query instead of one per attachment.
*/
public static function folder_ids_for_attachments($attachment_ids)
{
$attachment_ids = array_map("intval", array_filter((array) $attachment_ids));
if (empty($attachment_ids)) {
return [];
}
global $wpdb;
$placeholders = implode(",", array_fill(0, count($attachment_ids), "%d"));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is an internal, hardcoded {$wpdb->prefix}-based constant, not user input; {$placeholders} is a dynamically built run of %d tokens (one per element of $attachment_ids, each cast via array_map("intval", ...) above) consumed by the prepare() call immediately below via PHP's ...$attachment_ids spread — the placeholder count and value count are mechanically the same array, so they always match; this exact pattern (dynamic IN-clause placeholder count built from array_fill(), spread into prepare()) is WordPress core's own documented way to safely parameterize a variable-length IN clause, since prepare() has no native "bind an array" placeholder.
$rows = $wpdb->get_results($wpdb->prepare("SELECT attachment_id, folder_id FROM " . self::items_table() . " WHERE attachment_id IN ({$placeholders})", ...$attachment_ids));
$map = [];
foreach ($rows as $row) {
$map[(int) $row->attachment_id] = (int) $row->folder_id;
}
return $map;
}
}
+1
View File
@@ -74,6 +74,7 @@ class MenuCreator
} }
$table = self::table_name(); $table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name, not user input; no user-supplied values in this query. Runs on every admin page load to build the sidebar menu, so a per-request cache wouldn't help; a persistent cache would need its own invalidation on every config save, more complexity than this read is worth.
$configs = $wpdb->get_results("SELECT roles, items FROM {$table} WHERE status = 'published'"); $configs = $wpdb->get_results("SELECT roles, items FROM {$table} WHERE status = 'published'");
if (!$configs) { if (!$configs) {
return; return;
+11 -9
View File
@@ -104,11 +104,10 @@ class MenuCreatorData
$status = $request->get_param("status"); $status = $request->get_param("status");
if ($status) { if ($status) {
$rows = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name, not user input; $status is bound via the $wpdb->prepare() %s placeholder.
"SELECT * FROM {$table} WHERE status = %s ORDER BY updated_at DESC", $rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE status = %s ORDER BY updated_at DESC", $status));
$status
));
} else { } else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name, not user input; no user-supplied values in this query.
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY updated_at DESC"); $rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY updated_at DESC");
} }
@@ -118,10 +117,8 @@ class MenuCreatorData
public static function get_one($request) public static function get_one($request)
{ {
global $wpdb; global $wpdb;
$row = $wpdb->get_row($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
"SELECT * FROM " . MenuCreator::table_name() . " WHERE id = %d", $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM " . MenuCreator::table_name() . " WHERE id = %d", (int) $request->get_param("id")));
(int) $request->get_param("id")
));
if (!$row) { if (!$row) {
return new \WP_Error("not_found", "Menu not found", ["status" => 404]); return new \WP_Error("not_found", "Menu not found", ["status" => 404]);
} }
@@ -133,6 +130,7 @@ class MenuCreatorData
global $wpdb; global $wpdb;
$now = current_time("mysql"); $now = current_time("mysql");
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom menu-configs table.
$wpdb->insert(MenuCreator::table_name(), [ $wpdb->insert(MenuCreator::table_name(), [
"name" => sanitize_text_field($request->get_param("name")), "name" => sanitize_text_field($request->get_param("name")),
"status" => self::sanitize_status($request->get_param("status")), "status" => self::sanitize_status($request->get_param("status")),
@@ -142,7 +140,9 @@ class MenuCreatorData
"updated_at" => $now, "updated_at" => $now,
]); ]);
return self::get_one(new \WP_REST_Request("GET", "", ["id" => $wpdb->insert_id])); $get_request = new \WP_REST_Request("GET");
$get_request->set_url_params(["id" => $wpdb->insert_id]);
return self::get_one($get_request);
} }
public static function update($request) public static function update($request)
@@ -164,6 +164,7 @@ class MenuCreatorData
$fields["items"] = wp_json_encode(self::sanitize_items($request->get_param("items"))); $fields["items"] = wp_json_encode(self::sanitize_items($request->get_param("items")));
} }
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom menu-configs table.
$wpdb->update(MenuCreator::table_name(), $fields, ["id" => $id]); $wpdb->update(MenuCreator::table_name(), $fields, ["id" => $id]);
return self::get_one($request); return self::get_one($request);
@@ -172,6 +173,7 @@ class MenuCreatorData
public static function delete($request) public static function delete($request)
{ {
global $wpdb; global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->delete() is WordPress's own escaping-safe API for writing to Aurora's custom menu-configs table.
$wpdb->delete(MenuCreator::table_name(), ["id" => (int) $request->get_param("id")]); $wpdb->delete(MenuCreator::table_name(), ["id" => (int) $request->get_param("id")]);
return new \WP_REST_Response(["deleted" => true], 200); return new \WP_REST_Response(["deleted" => true], 200);
} }
+2 -3
View File
@@ -109,9 +109,8 @@ class Notices
} }
$current_page = isset($GLOBALS["pagenow"]) ? $GLOBALS["pagenow"] : ""; $current_page = isset($GLOBALS["pagenow"]) ? $GLOBALS["pagenow"] : "";
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query. Runs on every admin page load to decide which notices to show, so caching would need its own invalidation on every notice save — more complexity than this read is worth.
"SELECT * FROM " . self::table_name() . " WHERE active = 1" $rows = $wpdb->get_results("SELECT * FROM " . self::table_name() . " WHERE active = 1");
);
foreach ($rows as $row) { foreach ($rows as $row) {
$roles = json_decode($row->roles ?: "[]", true); $roles = json_decode($row->roles ?: "[]", true);
+7 -4
View File
@@ -52,6 +52,7 @@ class NoticesData
public static function list_notices() public static function list_notices()
{ {
global $wpdb; global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query.
$rows = $wpdb->get_results("SELECT * FROM " . Notices::table_name() . " ORDER BY updated_at DESC"); $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); return new \WP_REST_Response(array_map([self::class, "format"], $rows), 200);
} }
@@ -60,6 +61,7 @@ class NoticesData
{ {
global $wpdb; global $wpdb;
$now = current_time("mysql"); $now = current_time("mysql");
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom notices table.
$wpdb->insert(Notices::table_name(), [ $wpdb->insert(Notices::table_name(), [
"title" => sanitize_text_field($request->get_param("title")), "title" => sanitize_text_field($request->get_param("title")),
"message" => sanitize_textarea_field((string) $request->get_param("message")), "message" => sanitize_textarea_field((string) $request->get_param("message")),
@@ -72,10 +74,8 @@ class NoticesData
"updated_at" => $now, "updated_at" => $now,
]); ]);
$row = $wpdb->get_row($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
"SELECT * FROM " . Notices::table_name() . " WHERE id = %d", $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM " . Notices::table_name() . " WHERE id = %d", $wpdb->insert_id));
$wpdb->insert_id
));
return new \WP_REST_Response(self::format($row), 200); return new \WP_REST_Response(self::format($row), 200);
} }
@@ -106,8 +106,10 @@ class NoticesData
$fields["active"] = $request->get_param("active") ? 1 : 0; $fields["active"] = $request->get_param("active") ? 1 : 0;
} }
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom notices table.
$wpdb->update(Notices::table_name(), $fields, ["id" => $id]); $wpdb->update(Notices::table_name(), $fields, ["id" => $id]);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM " . Notices::table_name() . " WHERE id = %d", $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); return new \WP_REST_Response(self::format($row), 200);
} }
@@ -115,6 +117,7 @@ class NoticesData
public static function delete($request) public static function delete($request)
{ {
global $wpdb; global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->delete() is WordPress's own escaping-safe API for writing to Aurora's custom notices table.
$wpdb->delete(Notices::table_name(), ["id" => (int) $request->get_param("id")]); $wpdb->delete(Notices::table_name(), ["id" => (int) $request->get_param("id")]);
return new \WP_REST_Response(["deleted" => true], 200); return new \WP_REST_Response(["deleted" => true], 200);
} }
+31 -17
View File
@@ -16,6 +16,29 @@ class Settings
public function __construct() public function __construct()
{ {
add_action("init", [self::class, "register"]); add_action("init", [self::class, "register"]);
add_action("init", [self::class, "remove_retired_keys"]);
}
// One-time cleanup for settings keys that no longer exist in the UI:
// the removed Remote Site Switcher feature (remote_sites stored a real
// secret — application passwords — that nothing ever consumed) and the
// removed licensing system (license_key, dev_license_override). Actively
// deletes them from the database rather than just leaving them inertly
// stored.
public static function remove_retired_keys()
{
$settings = self::get();
$retired = ["remote_sites", "remote_site_switcher_capability", "license_key", "dev_license_override"];
$present = array_intersect($retired, array_keys($settings));
if (empty($present)) {
return;
}
foreach ($present as $key) {
unset($settings[$key]);
}
update_option(self::OPTION_NAME, $settings);
} }
public static function register() public static function register()
@@ -28,8 +51,8 @@ class Settings
// it wholesale. This lets pages save just the keys they own (e.g. the // 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) // dashboard sends only dashboard_card_order, the shell only dark_mode)
// without wiping settings they never received — which matters because // without wiping settings they never received — which matters because
// most pages get a sanitized copy with secrets stripped (see // most pages get a sanitized copy (see get_safe()) and must never
// get_safe()) and must never write that stripped copy back. // write that stripped copy back.
"sanitize_callback" => [self::class, "merge_on_save"], "sanitize_callback" => [self::class, "merge_on_save"],
"show_in_rest" => [ "show_in_rest" => [
"schema" => [ "schema" => [
@@ -77,25 +100,16 @@ class Settings
/** /**
* Settings safe to expose on every admin page (the shell serializes this * Settings safe to expose on every admin page (the shell serializes this
* for all logged-in users). Strips secrets: the license key and remote * for all logged-in users). Strips backup_s3_credentials/
* site application passwords. The settings page itself (manage_options * backup_gdrive_credentials (contain a secret access key / OAuth client
* only) uses get() and receives the full value. * secret + refresh token) — the exact "future field that shouldn't be
* broadcast" case this method was originally kept separate from get()
* to handle.
*/ */
public static function get_safe() public static function get_safe()
{ {
$settings = self::get(); $settings = self::get();
unset($settings["backup_s3_credentials"], $settings["backup_gdrive_credentials"]);
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; return $settings;
} }
} }
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the Appearance-area screens for the "full admin takeover"
* initiative: Nav Menus, Themes, Widgets (all real hijacked replacements).
*
* Theme Editor is deliberately NOT hijacked or replaced — arbitrary
* in-browser PHP/CSS file editing is exactly the category of functionality
* WordPress.org's Plugin Directory policy excludes (see the removed
* File Manager sub-app, now distributed as a separate standalone plugin
* instead of being bundled here). Visits to theme-editor.php fall through
* to WordPress core's own Theme Editor unmodified, which already respects
* DISALLOW_FILE_EDIT itself.
*/
class AppearancePages
{
public function __construct()
{
ScreenHijack::register(
"load-nav-menus.php",
"edit_theme_options",
"full_admin_takeover",
"nav-menus",
"aurora-admin-nav-menus-root"
);
ScreenHijack::register(
"load-themes.php",
"switch_themes",
"full_admin_takeover",
"themes",
"aurora-admin-themes-root",
null,
null,
function () {
return [
"customizeUrl" => esc_url_raw(admin_url("customize.php")),
// Native theme-install.php covers both "browse WordPress.org" and
// "Upload Theme" (a button at the top of that same screen) — not
// rebuilt here, same reuse-native-flow-for-add pattern as Plugins'
// addUrl (plugin-install.php) and Media's newMediaUrl
// (media-new.php).
"addThemeUrl" => current_user_can("install_themes") ? esc_url_raw(admin_url("theme-install.php")) : "",
];
}
);
ScreenHijack::register(
"load-widgets.php",
"edit_theme_options",
"full_admin_takeover",
"widgets",
"aurora-admin-widgets-root"
);
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers every native screen Aurora replaces in place (same URL, no
* redirect) via ScreenHijack. Kept as its own bootstrap class rather than
* inline calls in AuroraAdmin's constructor since this list is expected to
* grow past a dozen entries as more native screens are covered.
*
* Each of these previously lived in SubApps::PAGES as a registered
* "aurora-admin-*" submenu with a matching redirect_native_pages() branch.
* Plugins is the one screen that still uses that mechanism — plugins.php
* prints output from within its own load hook in a way that can't be
* cleanly buffered, confirmed by the old reference plugin's own abandoned
* attempt at hijacking it — so it's deliberately not listed here.
*/
class HijackedNativePages
{
public function __construct()
{
ScreenHijack::register(
"load-edit-comments.php",
"moderate_comments",
"modern_comments",
"comments",
"aurora-admin-comments-root"
);
// "edit.php" is shared by Posts (the default post type) and every other
// post type registered on the site, including Pages — screen_match is
// what tells them apart; other custom post types fall through untouched.
ScreenHijack::register(
"load-edit.php",
"edit_posts",
"modern_posts",
"posts",
"aurora-admin-posts-root",
function ($screen) {
return $screen->post_type === "post";
},
null,
function () {
return ["newPostUrl" => esc_url_raw(admin_url("post-new.php"))];
}
);
ScreenHijack::register(
"load-edit.php",
"edit_pages",
"modern_pages",
"pages",
"aurora-admin-pages-root",
function ($screen) {
return $screen->post_type === "page";
},
null,
function () {
return ["newPageUrl" => esc_url_raw(admin_url("post-new.php?post_type=page"))];
}
);
ScreenHijack::register(
"load-upload.php",
"upload_files",
"modern_media",
"media",
"aurora-admin-media-root",
null,
null,
function () {
return [
"newMediaUrl" => esc_url_raw(admin_url("media-new.php")),
];
}
);
ScreenHijack::register(
"load-users.php",
"list_users",
"modern_users",
"users",
"aurora-admin-users-root",
null,
null,
function () {
return ["newUserUrl" => esc_url_raw(admin_url("user-new.php"))];
}
);
// media-new.php / user-new.php: Media Upload and Add New User. Free,
// gated by the same modern_media / modern_users toggles as their
// already-free list-view companions (Media Library / Users) — no
// reason to paywall mid-workflow between "browse" and "add".
ScreenHijack::register(
"load-media-new.php",
"upload_files",
"modern_media",
"media-upload",
"aurora-admin-media-upload-root",
null,
null,
function () {
return ["libraryUrl" => esc_url_raw(admin_url("upload.php"))];
}
);
ScreenHijack::register(
"load-user-new.php",
"create_users",
"modern_users",
"add-user",
"aurora-admin-add-user-root",
null,
null,
function () {
$roles = [];
foreach (wp_roles()->roles as $slug => $info) {
$roles[] = ["value" => $slug, "label" => translate_user_role($info["name"])];
}
return [
"usersUrl" => esc_url_raw(admin_url("users.php")),
"roles" => $roles,
"defaultRole" => get_option("default_role", "subscriber"),
];
}
);
// profile.php: reskins only WordPress's own fields (name, contact
// info, bio, password). If any plugin has hooked show_user_profile or
// personal_options — the two documented extension points for adding
// fields to this exact screen (2FA setup, WooCommerce customer fields,
// etc.) — this deliberately does NOT hijack, falling through to the
// real native screen instead, since a Vue reskin has no way to host
// another plugin's raw HTML/JS output safely. Checked at hijack time
// (load-profile.php), which fires after every plugin's own init/
// admin_init hooks have already registered their callbacks.
ScreenHijack::register(
"load-profile.php",
"read",
"modern_users",
"profile",
"aurora-admin-profile-root",
function () {
return !has_action("show_user_profile") && !has_action("personal_options");
}
);
}
}
+327
View File
@@ -0,0 +1,327 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the 5 native Settings screens (General/Writing/Reading/
* Discussion/Permalinks) via ScreenHijack, part of the "full admin
* takeover" initiative (see PROJECT_STATUS.md). Unlike Posts/Pages/Media/
* Users/Comments, these are gated on the master `full_admin_takeover`
* setting (default off), since they're new ground rather than a migration
* of something already shipped.
*
* All 5 share one Vue entry (native-settings.js / NativeSettings.vue) and
* one mount id — only one native Settings screen is ever hijacked per
* request, so there's no collision. Each just supplies a different field
* schema + current values via its data_callback. Every field except
* Permalinks' structure is one of core's own REST-exposed settings
* (`/wp/v2/settings`), so the Vue side reads/writes that route directly;
* Permalinks has no core REST route for rewrite rules and uses
* PermalinksData's own aurora-admin/v1/permalinks route instead.
*/
class NativeSettingsPages
{
// Core's REST settings route exposes some fields under a different name
// than the wp_options row they actually live in (registered via
// register_setting()'s show_in_rest 'name' argument) — reading them back
// with get_option() needs the real option name, not the REST one, or the
// field just reads back empty.
const OPTION_NAME_MAP = [
"title" => "blogname",
"description" => "blogdescription",
"url" => "siteurl",
];
public function __construct()
{
self::register_screen(
"load-options-general.php",
"general",
"General",
"Site title, tagline, timezone, and date/time formats.",
[
["key" => "title", "type" => "text", "label" => "Site Title"],
["key" => "description", "type" => "text", "label" => "Tagline"],
["key" => "url", "type" => "text", "label" => "Site Address (URL)", "readonly" => true],
["key" => "admin_email", "type" => "text", "label" => "Administration Email Address"],
["key" => "timezone_string", "type" => "text", "label" => "Timezone", "placeholder" => "e.g. America/New_York"],
["key" => "date_format", "type" => "text", "label" => "Date Format"],
["key" => "time_format", "type" => "text", "label" => "Time Format"],
[
"key" => "start_of_week",
"type" => "select",
"label" => "Week Starts On",
"options" => [
["value" => "0", "label" => "Sunday"],
["value" => "1", "label" => "Monday"],
["value" => "2", "label" => "Tuesday"],
["value" => "3", "label" => "Wednesday"],
["value" => "4", "label" => "Thursday"],
["value" => "5", "label" => "Friday"],
["value" => "6", "label" => "Saturday"],
],
],
]
);
self::register_screen(
"load-options-writing.php",
"writing",
"Writing",
"Defaults used when publishing new content.",
[
// use_smilies is registered in core's REST schema (register_initial_settings())
// but was never added here — a real gap, not a deliberate omission.
["key" => "use_smilies", "type" => "toggle", "label" => "Convert emoticons like :-) and :-P to graphics on display"],
["key" => "default_category", "type" => "select", "label" => "Default Post Category", "options" => self::category_options()],
["key" => "default_post_format", "type" => "select", "label" => "Default Post Format", "options" => self::post_format_options()],
]
);
self::register_screen(
"load-options-reading.php",
"reading",
"Reading",
"What visitors see on the front page and how many posts are listed.",
[
[
"key" => "show_on_front",
"type" => "select",
"label" => "Homepage Displays",
"options" => [
["value" => "posts", "label" => "Your latest posts"],
["value" => "page", "label" => "A static page"],
],
],
[
"key" => "page_on_front",
"type" => "select",
"label" => "Homepage",
"options" => self::page_options(),
"showIf" => ["key" => "show_on_front", "equals" => "page"],
],
[
"key" => "page_for_posts",
"type" => "select",
"label" => "Posts Page",
"options" => self::page_options(),
"showIf" => ["key" => "show_on_front", "equals" => "page"],
],
["key" => "posts_per_page", "type" => "text", "label" => "Posts Per Page"],
]
);
self::register_screen(
"load-options-discussion.php",
"discussion",
"Discussion",
"Default article settings for pings and comments.",
[
// Saved via DiscussionData's own /discussion-settings route, not
// wp/v2/settings — core's REST schema only exposes the first two
// of these fields (see DiscussionData.php's docblock for why).
["key" => "default_pingback_flag", "type" => "toggle", "label" => "Attempt to notify any blogs linked to from the article"],
[
"key" => "default_ping_status",
"type" => "select",
"label" => "Allow link notifications from other blogs (pingbacks and trackbacks)",
"options" => [
["value" => "open", "label" => "Open"],
["value" => "closed", "label" => "Closed"],
],
],
[
"key" => "default_comment_status",
"type" => "select",
"label" => "Allow people to submit comments on new posts",
"options" => [
["value" => "open", "label" => "Open"],
["value" => "closed", "label" => "Closed"],
],
],
["key" => "require_name_email", "type" => "toggle", "label" => "Comment author must fill out name and email"],
["key" => "comment_registration", "type" => "toggle", "label" => "Users must be registered and logged in to comment"],
["key" => "close_comments_for_old_posts", "type" => "toggle", "label" => "Automatically close comments on old posts"],
[
"key" => "close_comments_days_old",
"type" => "text",
"label" => "Close comments when post is how many days old",
"showIf" => ["key" => "close_comments_for_old_posts", "equals" => true],
],
["key" => "show_comments_cookies_opt_in", "type" => "toggle", "label" => "Show comments cookies opt-in checkbox"],
["key" => "thread_comments", "type" => "toggle", "label" => "Enable threaded (nested) comments"],
[
"key" => "thread_comments_depth",
"type" => "select",
"label" => "Number of levels for threaded (nested) comments",
"options" => array_map(
function ($n) {
return ["value" => (string) $n, "label" => (string) $n];
},
range(2, 10)
),
"showIf" => ["key" => "thread_comments", "equals" => true],
],
["key" => "page_comments", "type" => "toggle", "label" => "Break comments into pages"],
[
"key" => "comments_per_page",
"type" => "text",
"label" => "Top level comments per page",
"showIf" => ["key" => "page_comments", "equals" => true],
],
[
"key" => "default_comments_page",
"type" => "select",
"label" => "Comments page to display by default",
"options" => [
["value" => "newest", "label" => "Last page"],
["value" => "oldest", "label" => "First page"],
],
"showIf" => ["key" => "page_comments", "equals" => true],
],
[
"key" => "comment_order",
"type" => "select",
"label" => "Comments to display at the top of each page",
"options" => [
["value" => "asc", "label" => "Older"],
["value" => "desc", "label" => "Newer"],
],
],
["key" => "comments_notify", "type" => "toggle", "label" => "Email me whenever: anyone posts a comment"],
["key" => "moderation_notify", "type" => "toggle", "label" => "Email me whenever: a comment is held for moderation"],
["key" => "wp_notes_notify", "type" => "toggle", "label" => "Email me whenever: WordPress news and updates", "default" => true],
["key" => "comment_moderation", "type" => "toggle", "label" => "Comment must be manually approved"],
["key" => "comment_previously_approved", "type" => "toggle", "label" => "Comment author must have a previously approved comment"],
["key" => "comment_max_links", "type" => "text", "label" => "Hold a comment in the queue if it contains this many links"],
[
"key" => "moderation_keys",
"type" => "textarea",
"label" => "Comment Moderation",
"description" => "When a comment contains any of these words in its content, author name, URL, email, IP address, or browser's user agent string, it will be held in the moderation queue. One word or IP address per line.",
],
[
"key" => "disallowed_keys",
"type" => "textarea",
"label" => "Disallowed Comment Keys",
"description" => "When a comment contains any of these words in its content, author name, URL, email, IP address, or browser's user agent string, it will be put in the Trash. One word or IP address per line.",
],
["key" => "show_avatars", "type" => "toggle", "label" => "Show Avatars"],
[
"key" => "avatar_rating",
"type" => "select",
"label" => "Maximum Rating",
"options" => [
["value" => "G", "label" => "G — Suitable for all audiences"],
["value" => "PG", "label" => "PG — Possibly offensive, usually for audiences 13 and above"],
["value" => "R", "label" => "R — Intended for adult audiences above 17"],
["value" => "X", "label" => "X — Even more mature than above"],
],
"showIf" => ["key" => "show_avatars", "equals" => true],
],
[
"key" => "avatar_default",
"type" => "select",
"label" => "Default Avatar",
"options" => [
["value" => "mystery", "label" => "Mystery Person"],
["value" => "blank", "label" => "Blank"],
["value" => "gravatar_default", "label" => "Gravatar Logo"],
["value" => "identicon", "label" => "Identicon (Generated)"],
["value" => "wavatar", "label" => "Wavatar (Generated)"],
["value" => "monsterid", "label" => "MonsterID (Generated)"],
["value" => "retro", "label" => "Retro (Generated)"],
["value" => "robohash", "label" => "RoboHash (Generated)"],
["value" => "initials", "label" => "Initials (Generated)"],
["value" => "color", "label" => "Color (Generated)"],
],
"default" => "mystery",
"showIf" => ["key" => "show_avatars", "equals" => true],
],
]
);
self::register_screen(
"load-options-permalink.php",
"permalinks",
"Permalinks",
"The URL structure used for posts and pages.",
[["key" => "permalink_structure", "type" => "text", "label" => "Custom Structure"]]
);
}
private static function category_options()
{
$categories = get_categories(["hide_empty" => false]);
return array_map(
function ($cat) {
return ["value" => (string) $cat->term_id, "label" => $cat->name];
},
$categories
);
}
private static function post_format_options()
{
// get_post_format_strings() doesn't include "Standard" (the '0'/default
// value native WP stores when no format is set) — added manually to
// match the native Writing screen's own dropdown exactly.
$options = [["value" => "0", "label" => "Standard"]];
foreach (get_post_format_strings() as $slug => $label) {
if ($slug === "standard") {
continue;
}
$options[] = ["value" => $slug, "label" => $label];
}
return $options;
}
private static function page_options()
{
$options = [["value" => "0", "label" => "— Select —"]];
$pages = get_pages();
foreach ($pages as $page) {
$options[] = ["value" => (string) $page->ID, "label" => $page->post_title ?: "(no title)"];
}
return $options;
}
private static function register_screen($hook_suffix, $screen, $title, $subtitle, $fields)
{
ScreenHijack::register(
$hook_suffix,
"manage_options",
"full_admin_takeover",
"native-settings",
"aurora-admin-native-settings-root",
null,
null,
function () use ($screen, $title, $subtitle, $fields) {
$values = [];
foreach ($fields as $field) {
$option_name = self::OPTION_NAME_MAP[$field["key"]] ?? $field["key"];
$value = get_option($option_name, $field["default"] ?? "");
// Toggle fields need a real boolean — get_option() returns
// whatever's actually stored ("1"/""/true/false depending on how
// it was last saved), and a falsy non-bool like "" would
// otherwise still satisfy Vue's `!!val` check inconsistently.
$values[$field["key"]] = $field["type"] === "toggle" ? (bool) $value : $value;
}
return [
"screen" => $screen,
"title" => $title,
"subtitle" => $subtitle,
"fields" => $fields,
"values" => $values,
];
},
"admin-takeover"
);
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Replaces a native WordPress admin screen with an Aurora Vue page without
* changing its URL, instead of SubApps' registered-page + redirect approach.
*
* WordPress fires "load-{hook_suffix}" once a screen is confirmed but before
* it prints anything, so the recipe is: check access, buffer everything the
* native screen would have printed, throw the buffer away once WordPress
* reaches admin_footer, and print Aurora's own mount div in its place. The
* URL the browser sees never changes, so there's no separate slug that can
* fall out of sync with a plugin's own redirect, a bookmark, or any other
* direct visit — the exact bug class SubApps::redirect_native_pages() has to
* work around case by case (see its Plugins comment).
*
* Not every native screen can be hijacked this way — some (plugins.php is
* the confirmed case so far) print output from within the load-{page}.php
* callback itself or otherwise short-circuit before in_admin_header fires.
* Those stay on SubApps' redirect mechanism instead.
*/
class ScreenHijack
{
// Set true the moment any registered hijack actually activates for the
// current request (all gating checks passed). Shell::is_own_page() reads
// this — hijacked screens keep their native URL (no ?page=aurora-admin),
// so the shell can't otherwise tell them apart from a real native page
// when deciding whether to load native-dark.css's own-page exclusion.
private static $hijack_active = false;
public static function is_hijack_active()
{
return self::$hijack_active;
}
/**
* @param string $hook_suffix "load-{page}.php", e.g. "load-edit.php"
* @param string $capability Required capability, checked with current_user_can()
* @param string|null $setting_key Settings::get() key gating this hijack
* (default true), or null to always hijack once capability passes
* @param string $entry Vite entry name (without "src/entries/" or ".js")
* @param string $root_id DOM id for the mount div
* @param callable|null $screen_match fn(WP_Screen $screen): bool — extra
* match beyond the hook itself firing, e.g. distinguishing edit.php's
* several post types
* @param callable|null $short_circuit fn(WP_Query $query): void, hooked to
* pre_get_posts only if provided — for list screens where letting
* WordPress's own query run would be wasted work
* @param callable|null $data_callback fn(): array — extra data merged into
* the mount div's data-aurora-admin JSON, beyond the restUrl/restNonce
* defaults every page gets
*/
public static function register(
$hook_suffix,
$capability,
$setting_key,
$entry,
$root_id,
$screen_match = null,
$short_circuit = null,
$data_callback = null
) {
add_action(
$hook_suffix,
function () use (
$capability,
$setting_key,
$entry,
$root_id,
$screen_match,
$short_circuit,
$data_callback
) {
if (!current_user_can($capability)) {
return;
}
if ($setting_key !== null && !Settings::get($setting_key, true)) {
return;
}
if ($screen_match !== null) {
$screen = get_current_screen();
if (!$screen || !$screen_match($screen)) {
return;
}
}
self::$hijack_active = true;
if ($short_circuit !== null) {
add_filter("pre_get_posts", $short_circuit);
}
add_action(
"in_admin_header",
function () {
ob_start();
},
999
);
add_action(
"admin_footer",
function () use ($entry, $root_id, $data_callback) {
// A screen that exited early (its own wp_die(), a mid-render
// redirect, a validation failure taking a different code path
// than the initial GET) may never have reached in_admin_header,
// or may have already closed its own buffer — calling
// ob_end_clean() unconditionally in that case throws a notice
// and can corrupt the response. Only discard a buffer that's
// actually open.
if (ob_get_level() > 0) {
ob_end_clean();
}
$data = array_merge(
[
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
],
$data_callback !== null ? $data_callback() : []
);
// The buffer just discarded didn't just hold the native
// screen's content — in_admin_header fires before WP prints
// <div id="wpbody"><div id="wpbody-content">, and those
// wrapper divs' closing tags print (in admin-footer.php)
// before this admin_footer callback runs, so both got
// captured and thrown away too. Re-print them around the
// mount div so the DOM WP itself would have produced still
// exists — windowChrome.js's traffic-light bar and the shell's
// frame CSS both key off #wpbody-content being there.
?>
<div id="wpbody" role="main">
<div id="wpbody-content">
<div
id="<?php echo esc_attr($root_id); ?>"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
</div>
</div>
<?php
},
0
);
add_action("admin_enqueue_scripts", function () use ($entry) {
Assets::enqueue("aurora-admin-" . $entry, "entries/" . $entry . ".js");
});
},
5
);
}
}
-5
View File
@@ -3,7 +3,6 @@ namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets; use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings; use AuroraAdmin\Options\Settings;
use AuroraAdmin\Licensing\License;
defined("ABSPATH") || exit(); defined("ABSPATH") || exit();
@@ -74,10 +73,6 @@ class SettingsPage
"settings" => Settings::get(), "settings" => Settings::get(),
"roles" => $roles, "roles" => $roles,
"postTypes" => $post_types, "postTypes" => $post_types,
// Real-time result from the license server (see License::status()),
// not just whether a key is present — drives the status line under
// the License key field (active/invalid/site-limit-reached/etc.).
"licenseStatus" => License::status(),
]; ];
?> ?>
<div <div
+32 -126
View File
@@ -2,7 +2,6 @@
namespace AuroraAdmin\Pages; namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets; use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Licensing\License;
use AuroraAdmin\Options\Settings; use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit(); defined("ABSPATH") || exit();
@@ -33,25 +32,17 @@ defined("ABSPATH") || exit();
*/ */
class SubApps class SubApps
{ {
// "feature" ties a page to a License::PAID_FEATURES key — null means the public static function pages()
// page is free. Locked pages still register normally (real capability, {
// real URL) but render() swaps in an upgrade prompt instead of the real return [
// feature when unlicensed, and maybe_enqueue() loads that prompt's tiny "aurora-admin-menu-creator" => ["title" => __("Menu Creator", "aurora-admin"), "entry" => "menu-creator"],
// JS bundle instead of the real (much heavier) feature bundle. "aurora-admin-notices" => ["title" => __("Admin Notices", "aurora-admin"), "entry" => "admin-notices"],
const PAGES = [ "aurora-admin-roles" => ["title" => __("Role Editor", "aurora-admin"), "entry" => "role-editor"],
"aurora-admin-menu-creator" => ["title" => "Menu Creator", "entry" => "menu-creator", "feature" => null], "aurora-admin-activity-log" => ["title" => __("Activity Log", "aurora-admin"), "entry" => "activity-log"],
"aurora-admin-notices" => ["title" => "Admin Notices", "entry" => "admin-notices", "feature" => null], "aurora-admin-plugins" => ["title" => __("Installed Plugins", "aurora-admin"), "entry" => "plugins"],
"aurora-admin-roles" => ["title" => "Role Editor", "entry" => "role-editor", "feature" => "role-editor"], "aurora-admin-bug-report" => ["title" => __("Report a Bug", "aurora-admin"), "entry" => "bug-report"],
"aurora-admin-activity-log" => ["title" => "Activity Log", "entry" => "activity-log", "feature" => "activity-log"], ];
"aurora-admin-database" => ["title" => "Database Explorer", "entry" => "database-explorer", "feature" => "database-explorer"], }
"aurora-admin-file-manager" => ["title" => "File Manager", "entry" => "file-manager", "feature" => "file-manager"],
"aurora-admin-plugins" => ["title" => "Installed Plugins", "entry" => "plugins", "feature" => null],
"aurora-admin-comments" => ["title" => "Comments", "entry" => "comments", "feature" => null],
"aurora-admin-posts" => ["title" => "Posts", "entry" => "posts", "feature" => null],
"aurora-admin-pages" => ["title" => "Pages", "entry" => "pages", "feature" => null],
"aurora-admin-media" => ["title" => "Media Library", "entry" => "media", "feature" => null],
"aurora-admin-users" => ["title" => "Users", "entry" => "users", "feature" => null],
];
private static $hook_suffixes = []; private static $hook_suffixes = [];
@@ -64,12 +55,12 @@ class SubApps
public static function register_menus() public static function register_menus()
{ {
foreach (self::PAGES as $slug => $page) { foreach (self::pages() as $slug => $page) {
$parent = self::parent_slug($slug); $parent = self::parent_slug($slug);
$hook = add_submenu_page( $hook = add_submenu_page(
$parent, $parent,
__($page["title"], "aurora-admin"), $page["title"],
__($page["title"], "aurora-admin"), $page["title"],
self::capability($slug), self::capability($slug),
$slug, $slug,
[self::class, "render"] [self::class, "render"]
@@ -83,18 +74,6 @@ class SubApps
if ($slug === "aurora-admin-plugins") { if ($slug === "aurora-admin-plugins") {
return "plugins.php"; return "plugins.php";
} }
if ($slug === "aurora-admin-posts") {
return "edit.php";
}
if ($slug === "aurora-admin-pages") {
return "edit.php?post_type=page";
}
if ($slug === "aurora-admin-media") {
return "upload.php";
}
if ($slug === "aurora-admin-users") {
return "users.php";
}
return "aurora-admin"; return "aurora-admin";
} }
@@ -103,34 +82,18 @@ class SubApps
if ($slug === "aurora-admin-plugins") { if ($slug === "aurora-admin-plugins") {
return "activate_plugins"; return "activate_plugins";
} }
if ($slug === "aurora-admin-comments" || $slug === "aurora-admin-posts") {
return "edit_posts";
}
if ($slug === "aurora-admin-pages") {
return "edit_pages";
}
if ($slug === "aurora-admin-media") {
return "upload_files";
}
if ($slug === "aurora-admin-users") {
return "list_users";
}
return "manage_options"; return "manage_options";
} }
public static function maybe_enqueue($hook_suffix) public static function maybe_enqueue($hook_suffix)
{ {
$slug = array_search($hook_suffix, self::$hook_suffixes, true); $slug = array_search($hook_suffix, self::$hook_suffixes, true);
if ($slug === false || !isset(self::PAGES[$slug])) { $pages = self::pages();
return; if ($slug === false || !isset($pages[$slug])) {
}
$page = self::PAGES[$slug];
if ($page["feature"] && !License::is_feature_available($page["feature"])) {
Assets::enqueue("aurora-admin-locked-feature", "entries/locked-feature.js");
return; return;
} }
$page = $pages[$slug];
Assets::enqueue("aurora-admin-" . $page["entry"], "entries/" . $page["entry"] . ".js"); Assets::enqueue("aurora-admin-" . $page["entry"], "entries/" . $page["entry"] . ".js");
} }
@@ -138,6 +101,7 @@ class SubApps
{ {
global $pagenow; global $pagenow;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only isset() presence check (no value read), used only to skip pages that already have a ?page= param; no state change.
if (isset($_GET["page"]) || wp_doing_ajax()) { if (isset($_GET["page"]) || wp_doing_ajax()) {
return; return;
} }
@@ -161,82 +125,14 @@ class SubApps
exit; exit;
} }
// Settings > Comments > "Modern comments page" — previously always
// redirected regardless of this setting; default true so existing
// installs keep their current behavior until explicitly turned off.
if (
$pagenow === "edit-comments.php" &&
current_user_can("edit_posts") &&
Settings::get("modern_comments", true)
) {
wp_safe_redirect(admin_url("admin.php?page=aurora-admin-comments"));
exit;
}
// Settings > Posts > "Modern posts table". Only the default post-list
// view (edit.php with no post_type, or post_type=post) redirects —
// edit.php?post_type=page (Pages) and other custom post types are
// untouched, since only Posts has a replacement page.
$post_type = isset($_GET["post_type"]) ? sanitize_key(wp_unslash($_GET["post_type"])) : "post";
if (
$pagenow === "edit.php" &&
$post_type === "post" &&
current_user_can("edit_posts") &&
Settings::get("modern_posts", true)
) {
wp_safe_redirect(admin_url("admin.php?page=aurora-admin-posts"));
exit;
}
// Settings > Pages > "Modern pages table".
if (
$pagenow === "edit.php" &&
$post_type === "page" &&
current_user_can("edit_pages") &&
Settings::get("modern_pages", true)
) {
wp_safe_redirect(admin_url("admin.php?page=aurora-admin-pages"));
exit;
}
// Settings > Media > "Modern media library". upload.php serves both
// the default grid view and ?mode=list — both redirect the same way.
// media-new.php (the upload/drag-drop screen) is a separate file,
// untouched, same as post-new.php for Posts/Pages.
if (
$pagenow === "upload.php" &&
current_user_can("upload_files") &&
Settings::get("modern_media", true)
) {
wp_safe_redirect(admin_url("admin.php?page=aurora-admin-media"));
exit;
}
// Settings > Users > "Modern users table". user-new.php (account
// creation) is a separate file, untouched.
if (
$pagenow === "users.php" &&
current_user_can("list_users") &&
Settings::get("modern_users", true)
) {
wp_safe_redirect(admin_url("admin.php?page=aurora-admin-users"));
exit;
}
} }
public static function render() public static function render()
{ {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, routes rendering to the matching page's Vue root by slug; no state change.
$page = isset($_GET["page"]) ? sanitize_text_field(wp_unslash($_GET["page"])) : ""; $page = isset($_GET["page"]) ? sanitize_text_field(wp_unslash($_GET["page"])) : "";
if (!isset(self::PAGES[$page])) { $pages = self::pages();
return; if (!isset($pages[$page])) {
}
$feature = self::PAGES[$page]["feature"];
if ($feature && !License::is_feature_available($feature)) {
$data = ["feature" => License::feature_label($feature)];
?>
<div data-aurora-locked-feature="<?php echo esc_attr(wp_json_encode($data)); ?>"></div>
<?php
return; return;
} }
@@ -258,7 +154,17 @@ class SubApps
"newMediaUrl" => esc_url_raw(admin_url("media-new.php")), "newMediaUrl" => esc_url_raw(admin_url("media-new.php")),
"newUserUrl" => esc_url_raw(admin_url("user-new.php")), "newUserUrl" => esc_url_raw(admin_url("user-new.php")),
]; ];
$root_id = "aurora-admin-" . self::PAGES[$page]["entry"] . "-root";
// Report a Bug auto-fills its diagnostic fields from these — saves the
// reporter from having to look any of it up themselves.
if ($page === "aurora-admin-bug-report") {
$data["pluginVersion"] = defined("AURORA_ADMIN_VERSION") ? AURORA_ADMIN_VERSION : "";
$data["wpVersion"] = get_bloginfo("version");
$data["phpVersion"] = phpversion();
$data["theme"] = wp_get_theme()->get("Name");
}
$root_id = "aurora-admin-" . $pages[$page]["entry"] . "-root";
?> ?>
<div <div
id="<?php echo esc_attr($root_id); ?>" id="<?php echo esc_attr($root_id); ?>"
+1
View File
@@ -18,6 +18,7 @@ class ScheduledDayFilter
private static function day() private static function day()
{ {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only query-string filter for the posts list (like core's own list table filters, e.g. ?post_status=), no state change; value is sanitized and further validated against a strict date pattern below.
$day = isset($_GET["aurora_day"]) ? sanitize_text_field(wp_unslash($_GET["aurora_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 : ""; return preg_match('/^\d{4}-\d{2}-\d{2}$/', $day) ? $day : "";
} }
+13 -24
View File
@@ -2,7 +2,6 @@
namespace AuroraAdmin\Rest; namespace AuroraAdmin\Rest;
use AuroraAdmin\Analytics\Analytics; use AuroraAdmin\Analytics\Analytics;
use AuroraAdmin\Licensing\License;
defined("ABSPATH") || exit(); defined("ABSPATH") || exit();
@@ -23,7 +22,7 @@ class AnalyticsData
"methods" => "GET", "methods" => "GET",
"callback" => [self::class, "handle"], "callback" => [self::class, "handle"],
"permission_callback" => function () { "permission_callback" => function () {
return current_user_can("read") && License::is_feature_available("analytics"); return current_user_can("read");
}, },
]); ]);
} }
@@ -41,14 +40,14 @@ class AnalyticsData
$where = $wpdb->prepare("created_at BETWEEN %s AND %s", $from_dt, $to_dt); $where = $wpdb->prepare("created_at BETWEEN %s AND %s", $from_dt, $to_dt);
// --- Totals --- // --- Totals ---
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
$total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE {$where}"); $total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE {$where}");
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
$unique = (int) $wpdb->get_var("SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE {$where}"); $unique = (int) $wpdb->get_var("SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE {$where}");
// --- Daily series --- // --- Daily series ---
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
"SELECT DATE(created_at) AS d, COUNT(*) AS c FROM {$table} WHERE {$where} GROUP BY DATE(created_at)", $rows = $wpdb->get_results("SELECT DATE(created_at) AS d, COUNT(*) AS c FROM {$table} WHERE {$where} GROUP BY DATE(created_at)", OBJECT_K);
OBJECT_K
);
$series = []; $series = [];
$cursor = strtotime($from); $cursor = strtotime($from);
$end = strtotime($to); $end = strtotime($to);
@@ -62,9 +61,8 @@ class AnalyticsData
} }
// --- Device breakdown --- // --- Device breakdown ---
$devices = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
"SELECT device, COUNT(*) AS c FROM {$table} WHERE {$where} GROUP BY device ORDER BY c DESC" $devices = $wpdb->get_results("SELECT device, COUNT(*) AS c FROM {$table} WHERE {$where} GROUP BY device ORDER BY c DESC");
);
$deviceUsage = []; $deviceUsage = [];
foreach ($devices as $d) { foreach ($devices as $d) {
$deviceUsage[] = [ $deviceUsage[] = [
@@ -80,11 +78,8 @@ class AnalyticsData
$topCountries = self::top($wpdb, $table, $where, "country", 6, true); $topCountries = self::top($wpdb, $table, $where, "country", 6, true);
// --- Bounce rate & avg session depth --- // --- Bounce rate & avg session depth ---
$sessionCounts = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
"SELECT session_id, COUNT(*) AS c, $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");
TIMESTAMPDIFF(SECOND, MIN(created_at), MAX(created_at)) AS dur
FROM {$table} WHERE {$where} GROUP BY session_id"
);
$sessions = count($sessionCounts); $sessions = count($sessionCounts);
$bounces = 0; $bounces = 0;
$totalDur = 0; $totalDur = 0;
@@ -99,12 +94,8 @@ class AnalyticsData
// --- Active users (last 5 minutes) --- // --- Active users (last 5 minutes) ---
$activeCutoff = gmdate("Y-m-d H:i:s", current_time("timestamp") - 300); $activeCutoff = gmdate("Y-m-d H:i:s", current_time("timestamp") - 300);
$active = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name, not user input; $activeCutoff is bound via the $wpdb->prepare() %s placeholder. Deliberately not cached — this is a live "active right now" figure.
$wpdb->prepare( $active = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE created_at >= %s", $activeCutoff));
"SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE created_at >= %s",
$activeCutoff
)
);
return new \WP_REST_Response([ return new \WP_REST_Response([
"pageViews" => ["total" => $total, "unique" => $unique, "series" => $series], "pageViews" => ["total" => $total, "unique" => $unique, "series" => $series],
@@ -124,10 +115,8 @@ class AnalyticsData
private static function top($wpdb, $table, $where, $col, $limit, $skip_empty = false) private static function top($wpdb, $table, $where, $col, $limit, $skip_empty = false)
{ {
$extra = $skip_empty ? " AND {$col} <> ''" : ""; $extra = $skip_empty ? " AND {$col} <> ''" : "";
$results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is an internal, hardcoded table name; $col/$limit are internal literals passed only by this file's own three call sites above ("url"/"referrer"/"country", 6 — never from $request), not user input; $where was already built via $wpdb->prepare() by the caller.
"SELECT {$col} AS label, COUNT(*) AS c FROM {$table} $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}");
WHERE {$where}{$extra} GROUP BY {$col} ORDER BY c DESC LIMIT {$limit}"
);
$out = []; $out = [];
foreach ($results as $r) { foreach ($results as $r) {
$out[] = ["label" => $r->label !== "" ? $r->label : "Direct", "count" => (int) $r->c]; $out[] = ["label" => $r->label !== "" ? $r->label : "Direct", "count" => (int) $r->c];
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* POST /bug-report — forwards a bug report to the Aurora Dragon Studio
* feedback endpoint, tagged with basic diagnostics (plugin/WP/PHP version,
* active theme, domain) so reports can be triaged without a separate
* ticketing system.
*/
class BugReportData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/bug-report", [
"methods" => "POST",
"callback" => [self::class, "submit"],
"permission_callback" => function () {
return current_user_can("manage_options");
},
"args" => [
"description" => ["type" => "string", "required" => true],
],
]);
}
const API_URL = "https://auroraplugins.com/wp-json/aurora-licensing/v1/report-bug";
public static function submit($request)
{
$description = sanitize_textarea_field((string) $request->get_param("description"));
if (trim($description) === "") {
return new \WP_REST_Response(["success" => false, "message" => "Description is required."], 400);
}
$response = wp_remote_post(self::API_URL, [
"timeout" => 8,
"headers" => ["Content-Type" => "application/json"],
"body" => wp_json_encode([
"description" => $description,
"domain" => (string) wp_parse_url(home_url(), PHP_URL_HOST),
"plugin_version" => defined("AURORA_ADMIN_VERSION") ? AURORA_ADMIN_VERSION : "",
"wp_version" => get_bloginfo("version"),
"php_version" => phpversion(),
"theme" => wp_get_theme()->get("Name"),
]),
]);
if (is_wp_error($response)) {
return new \WP_REST_Response([
"success" => false,
"message" => $response->get_error_message(),
], 502);
}
$decoded = json_decode(wp_remote_retrieve_body($response), true);
if (empty($decoded["success"])) {
return new \WP_REST_Response([
"success" => false,
"message" => $decoded["message"] ?? "Could not send the report.",
], 502);
}
return new \WP_REST_Response(["success" => true], 200);
}
}
+1
View File
@@ -137,6 +137,7 @@ class CommentsData
"postTitle" => get_the_title($post_id) ?: __("(No title)", "aurora-admin"), "postTitle" => get_the_title($post_id) ?: __("(No title)", "aurora-admin"),
"postEditUrl" => get_edit_post_link($post_id, "raw") ?: "", "postEditUrl" => get_edit_post_link($post_id, "raw") ?: "",
"date" => mysql2date("M j, Y", $comment->comment_date), "date" => mysql2date("M j, Y", $comment->comment_date),
// translators: %s is a human-readable relative time (e.g. "3 hours", "2 days").
"relativeDate" => sprintf(__("%s ago", "aurora-admin"), human_time_diff(strtotime($comment->comment_date_gmt), current_time("timestamp", true))), "relativeDate" => sprintf(__("%s ago", "aurora-admin"), human_time_diff(strtotime($comment->comment_date_gmt), current_time("timestamp", true))),
"status" => $status, "status" => $status,
"statusLabel" => self::status_label($status), "statusLabel" => self::status_label($status),
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* Installs Aurora's standalone companion plugins (File Manager, Database
* Explorer, Site Backup, and any future ones) — each split out of Aurora
* Admin either because WordPress.org's Plugin Directory policy doesn't
* accept that category of functionality in a directory-listed plugin
* (File Manager, confirmed via two rejections), or proactively to reduce
* that same risk (Database Explorer, Site Backup — see
* PROJECT_STATUS.md's per-plugin sections for the full history). Rather
* than a bare download link, this wraps WP_Upgrader/Plugin_Upgrader — the
* same core API the native Plugins > Add New "Install Now" button and the
* Uploads tab both use — pointed at a fixed, hardcoded HTTPS zip URL per
* plugin (never client-supplied, so there's no SSRF surface here: the
* install target isn't user input).
*/
class CompanionPluginData
{
// Add an entry here for each companion plugin. The REST routes below are
// generated per-slug (e.g. /companion-plugins/file-manager/status), so a
// new companion plugin only needs a new entry here plus a matching
// settingsConfig.js field — no new PHP route-registration code.
const PLUGINS = [
"file-manager" => [
"zip_url" => "https://aurora.auroradragon.studio/aurora-file-manager.zip",
"plugin_file" => "aurora-file-manager/aurora-file-manager.php",
"label" => "Aurora File Manager",
],
"database-explorer" => [
"zip_url" => "https://aurora.auroradragon.studio/aurora-database-explorer.zip",
"plugin_file" => "aurora-database-explorer/aurora-database-explorer.php",
"label" => "Aurora Database Explorer",
],
"site-backup" => [
"zip_url" => "https://aurora.auroradragon.studio/aurora-site-backup.zip",
"plugin_file" => "aurora-site-backup/aurora-site-backup.php",
"label" => "Aurora Site Backup",
],
];
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("install_plugins") && current_user_can("activate_plugins");
};
foreach (array_keys(self::PLUGINS) as $slug) {
register_rest_route("aurora-admin/v1", "/companion-plugins/{$slug}/status", [
"methods" => "GET",
"callback" => function () use ($slug) {
return self::status($slug);
},
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/companion-plugins/{$slug}/install", [
"methods" => "POST",
"callback" => function () use ($slug) {
return self::install($slug);
},
"permission_callback" => $permission,
]);
}
}
private static function is_installed($plugin_file)
{
if (!function_exists("get_plugins")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}
return array_key_exists($plugin_file, get_plugins());
}
private static function is_active($plugin_file)
{
return is_plugin_active($plugin_file);
}
public static function status($slug)
{
if (!function_exists("is_plugin_active")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}
$plugin_file = self::PLUGINS[$slug]["plugin_file"];
return new \WP_REST_Response([
"installed" => self::is_installed($plugin_file),
"active" => self::is_installed($plugin_file) && self::is_active($plugin_file),
], 200);
}
/**
* Installs (if not already present) and activates the given companion
* plugin. Uses Plugin_Upgrader::install() against the fixed zip URL — the
* exact same core class WordPress's own plugin-install.php uses, so it
* inherits the same WP_Filesystem credential handling (a host requiring
* FTP/SSH credentials for direct file writes will surface that as a
* normal WP_Error here, not a fatal).
*/
public static function install($slug)
{
$plugin = self::PLUGINS[$slug];
$plugin_file = $plugin["plugin_file"];
if (self::is_installed($plugin_file)) {
if (!self::is_active($plugin_file)) {
$activated = activate_plugin($plugin_file);
if (is_wp_error($activated)) {
return new \WP_REST_Response(["success" => false, "message" => $activated->get_error_message()], 500);
}
}
/* translators: %s: companion plugin name, e.g. "Aurora File Manager" */
return new \WP_REST_Response(["success" => true, "message" => sprintf(__("%s is installed and active.", "aurora-admin"), $plugin["label"])], 200);
}
require_once ABSPATH . "wp-admin/includes/class-wp-upgrader.php";
require_once ABSPATH . "wp-admin/includes/plugin-install.php";
require_once ABSPATH . "wp-admin/includes/plugin.php";
$skin = new \Automatic_Upgrader_Skin();
$upgrader = new \Plugin_Upgrader($skin);
$result = $upgrader->install($plugin["zip_url"]);
if (is_wp_error($result)) {
return new \WP_REST_Response(["success" => false, "message" => $result->get_error_message()], 500);
}
if ($result !== true) {
$error = $skin->get_errors();
return new \WP_REST_Response([
"success" => false,
"message" => is_wp_error($error) && $error->get_error_message() ? $error->get_error_message() : __("Installation failed.", "aurora-admin"),
], 500);
}
if (!self::is_installed($plugin_file)) {
return new \WP_REST_Response(["success" => false, "message" => __("Installed, but the plugin file wasn't found afterward — check Plugins for details.", "aurora-admin")], 500);
}
$activated = activate_plugin($plugin_file);
if (is_wp_error($activated)) {
return new \WP_REST_Response([
"success" => true,
"message" => __("Installed, but couldn't activate automatically — activate it from the Plugins screen.", "aurora-admin"),
], 200);
}
/* translators: %s: companion plugin name, e.g. "Aurora File Manager" */
return new \WP_REST_Response(["success" => true, "message" => sprintf(__("%s installed and activated.", "aurora-admin"), $plugin["label"])], 200);
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* Core's REST settings schema (register_initial_settings(), wp-includes/
* option.php) only exposes 2 of options-discussion.php's ~15 real fields
* (default_ping_status/default_comment_status) — everything else on that
* native screen (moderation, threading, notification emails, avatars) has
* no REST route at all. This is the same situation Permalinks was in
* (PermalinksData.php) — a dedicated controller doing raw get_option()/
* update_option() reads/writes instead of going through wp/v2/settings.
*
* Every key this accepts is explicitly allowlisted below — the client
* sends a {values: {...}} object, but only keys this class already knows
* about are ever written, so a modified request can't use this route to
* write an arbitrary WP option.
*/
class DiscussionData
{
const BOOLEAN_KEYS = [
"default_pingback_flag",
"require_name_email",
"comment_registration",
"close_comments_for_old_posts",
"show_comments_cookies_opt_in",
"thread_comments",
"page_comments",
"comments_notify",
"moderation_notify",
"wp_notes_notify",
"comment_moderation",
"comment_previously_approved",
"show_avatars",
];
const INTEGER_KEYS = ["close_comments_days_old", "comments_per_page", "thread_comments_depth", "comment_max_links"];
const TEXTAREA_KEYS = ["moderation_keys", "disallowed_keys"];
const ENUM_KEYS = [
"default_ping_status" => ["open", "closed"],
"default_comment_status" => ["open", "closed"],
"default_comments_page" => ["newest", "oldest"],
"comment_order" => ["asc", "desc"],
"avatar_rating" => ["G", "PG", "R", "X"],
"avatar_default" => [
"mystery",
"blank",
"gravatar_default",
"identicon",
"wavatar",
"monsterid",
"retro",
"robohash",
"initials",
"color",
],
];
// Matches native options-discussion.php's own get_option() defaults —
// everything else defaults to empty/false when never saved.
const DEFAULTS = [
"wp_notes_notify" => true,
"avatar_default" => "mystery",
];
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/discussion-settings", [
"methods" => "GET",
"callback" => [self::class, "get_values"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/discussion-settings", [
"methods" => "POST",
"callback" => [self::class, "update_values"],
"permission_callback" => [self::class, "check_permission"],
"args" => [
"values" => ["type" => "object", "required" => true],
],
]);
}
public static function check_permission()
{
return current_user_can("manage_options");
}
private static function all_keys()
{
return array_merge(self::BOOLEAN_KEYS, self::INTEGER_KEYS, self::TEXTAREA_KEYS, array_keys(self::ENUM_KEYS));
}
public static function get_values()
{
$out = [];
foreach (self::all_keys() as $key) {
$default = self::DEFAULTS[$key] ?? "";
$value = get_option($key, $default);
if (in_array($key, self::BOOLEAN_KEYS, true)) {
$out[$key] = (bool) $value;
} elseif (in_array($key, self::INTEGER_KEYS, true)) {
$out[$key] = (int) $value;
} else {
$out[$key] = (string) $value;
}
}
return new \WP_REST_Response($out, 200);
}
public static function update_values($request)
{
$values = (array) $request->get_param("values");
foreach ($values as $key => $value) {
if (in_array($key, self::BOOLEAN_KEYS, true)) {
update_option($key, $value ? "1" : "");
} elseif (in_array($key, self::INTEGER_KEYS, true)) {
update_option($key, max(0, (int) $value));
} elseif (in_array($key, self::TEXTAREA_KEYS, true)) {
update_option($key, sanitize_textarea_field((string) $value));
} elseif (isset(self::ENUM_KEYS[$key]) && in_array($value, self::ENUM_KEYS[$key], true)) {
update_option($key, $value);
}
// Any other key (unknown/unauthorized) is silently ignored rather
// than erroring — keeps a partial-field save from failing outright.
}
return new \WP_REST_Response(["success" => true], 200);
}
}
+28 -2
View File
@@ -1,6 +1,8 @@
<?php <?php
namespace AuroraAdmin\Rest; namespace AuroraAdmin\Rest;
use AuroraAdmin\Media\MediaFolders;
defined("ABSPATH") || exit(); defined("ABSPATH") || exit();
/** /**
@@ -32,6 +34,7 @@ class MediaData
"search" => ["type" => "string", "required" => false], "search" => ["type" => "string", "required" => false],
"page" => ["type" => "integer", "required" => false], "page" => ["type" => "integer", "required" => false],
"perPage" => ["type" => "integer", "required" => false], "perPage" => ["type" => "integer", "required" => false],
"folderId" => ["type" => "integer", "required" => false],
], ],
]); ]);
@@ -77,10 +80,30 @@ class MediaData
$args["s"] = $search; $args["s"] = $search;
} }
$folder_param = $request->get_param("folderId");
if ($folder_param !== null && $folder_param !== "") {
$folder_id = (int) $folder_param;
if ($folder_id === MediaFolders::UNCATEGORIZED) {
$in_any_folder = MediaFolders::all_assigned_attachment_ids();
// phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- a WordPress VIP-scale performance advisory, not a WP.org Plugin Directory requirement; "show media not assigned to any folder" has no other query shape without a real inverse-membership index, and Media Folders is bounded by how many attachments a site actually has, not VIP-scale traffic.
$args["post__not_in"] = !empty($in_any_folder) ? $in_any_folder : [0];
} elseif ($folder_id !== MediaFolders::ALL_MEDIA) {
$ids = MediaFolders::attachment_ids_in_folder($folder_id);
$args["post__in"] = !empty($ids) ? $ids : [0];
}
}
$query = new \WP_Query($args); $query = new \WP_Query($args);
$folder_map = MediaFolders::folder_ids_for_attachments(wp_list_pluck($query->posts, "ID"));
return new \WP_REST_Response([ return new \WP_REST_Response([
"items" => array_map([self::class, "serialize_attachment"], $query->posts), "items" => array_map(
function ($post) use ($folder_map) {
return self::serialize_attachment($post, $folder_map[$post->ID] ?? MediaFolders::UNCATEGORIZED);
},
$query->posts
),
"stats" => self::stats(), "stats" => self::stats(),
"pagination" => [ "pagination" => [
"page" => $page, "page" => $page,
@@ -107,7 +130,7 @@ class MediaData
return new \WP_REST_Response(["success" => true, "message" => __("Media item deleted.", "aurora-admin")], 200); return new \WP_REST_Response(["success" => true, "message" => __("Media item deleted.", "aurora-admin")], 200);
} }
private static function serialize_attachment($post) private static function serialize_attachment($post, $folder_id = null)
{ {
$author = get_userdata($post->post_author); $author = get_userdata($post->post_author);
$mime = $post->post_mime_type; $mime = $post->post_mime_type;
@@ -139,6 +162,7 @@ class MediaData
"date" => get_the_date("M j, Y", $post), "date" => get_the_date("M j, Y", $post),
"editUrl" => get_edit_post_link($post->ID, "raw") ?: "", "editUrl" => get_edit_post_link($post->ID, "raw") ?: "",
"canDelete" => current_user_can("delete_post", $post->ID), "canDelete" => current_user_can("delete_post", $post->ID),
"folderId" => $folder_id,
]; ];
} }
@@ -155,6 +179,7 @@ class MediaData
"unattached" => 0, "unattached" => 0,
]; ];
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- {$wpdb->posts} is WordPress core's own table, no user-supplied value in the query; there's no WP_Query/get_posts() shape for "counts grouped by mime-type prefix", which is exactly what this powers (the Media Library's type-filter tab counts).
$rows = $wpdb->get_results( $rows = $wpdb->get_results(
"SELECT post_mime_type, COUNT(*) AS c FROM {$wpdb->posts} "SELECT post_mime_type, COUNT(*) AS c FROM {$wpdb->posts}
WHERE post_type = 'attachment' AND post_status = 'inherit' WHERE post_type = 'attachment' AND post_status = 'inherit'
@@ -169,6 +194,7 @@ class MediaData
} }
} }
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- {$wpdb->posts} is WordPress core's own table, no user-supplied value in the query.
$counts["unattached"] = (int) $wpdb->get_var( $counts["unattached"] = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->posts} "SELECT COUNT(*) FROM {$wpdb->posts}
WHERE post_type = 'attachment' AND post_status = 'inherit' AND post_parent = 0" WHERE post_type = 'attachment' AND post_status = 'inherit' AND post_parent = 0"
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace AuroraAdmin\Rest;
use AuroraAdmin\Media\MediaFolders;
defined("ABSPATH") || exit();
/**
* REST endpoints for Media Folders.
*/
class MediaFolderData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/media-folders", [
"methods" => "GET",
"callback" => [self::class, "get_tree"],
"permission_callback" => [self::class, "can_manage"],
]);
register_rest_route("aurora-admin/v1", "/media-folders", [
"methods" => "POST",
"callback" => [self::class, "create_folder"],
"permission_callback" => [self::class, "can_manage"],
"args" => [
"name" => ["type" => "string", "required" => true],
"parent" => ["type" => "integer", "required" => false],
],
]);
register_rest_route("aurora-admin/v1", "/media-folders/(?P<id>\d+)", [
"methods" => "POST",
"callback" => [self::class, "update_folder"],
"permission_callback" => [self::class, "can_manage"],
"args" => [
"id" => ["type" => "integer", "required" => true],
"name" => ["type" => "string", "required" => false],
"parent" => ["type" => "integer", "required" => false],
"siblingOrder" => ["type" => "array", "required" => false],
],
]);
register_rest_route("aurora-admin/v1", "/media-folders/(?P<id>\d+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_folder"],
"permission_callback" => [self::class, "can_manage"],
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/media-folders/assign", [
"methods" => "POST",
"callback" => [self::class, "assign"],
"permission_callback" => [self::class, "can_manage"],
"args" => [
"folderId" => ["type" => "integer", "required" => true],
"attachmentIds" => ["type" => "array", "required" => true],
],
]);
}
public static function can_manage()
{
return current_user_can("upload_files");
}
public static function get_tree($request)
{
return new \WP_REST_Response(
[
"tree" => MediaFolders::get_tree(),
"allMediaCount" => MediaFolders::total_attachment_count(),
"uncategorizedCount" => MediaFolders::uncategorized_count(),
],
200
);
}
public static function create_folder($request)
{
$name = sanitize_text_field((string) $request->get_param("name"));
$parent = (int) ($request->get_param("parent") ?: 0);
$id = MediaFolders::create_folder($name, $parent);
if (!$id) {
return new \WP_Error("aurora_folder_invalid", __("Folder name is required.", "aurora-admin"), ["status" => 400]);
}
return new \WP_REST_Response(["id" => $id], 201);
}
public static function update_folder($request)
{
$id = (int) $request["id"];
$name = $request->get_param("name");
$parent = $request->get_param("parent");
if ($name !== null && $name !== "") {
MediaFolders::rename_folder($id, sanitize_text_field((string) $name));
}
if ($parent !== null) {
$sibling_order = (array) ($request->get_param("siblingOrder") ?: []);
$moved = MediaFolders::move_folder($id, (int) $parent, $sibling_order);
if (!$moved) {
return new \WP_Error(
"aurora_folder_move_failed",
__("Could not move that folder there.", "aurora-admin"),
["status" => 400]
);
}
}
return new \WP_REST_Response(["success" => true], 200);
}
public static function delete_folder($request)
{
$id = (int) $request["id"];
MediaFolders::delete_folder($id);
return new \WP_REST_Response(["success" => true], 200);
}
public static function assign($request)
{
$folder_id = (int) $request->get_param("folderId");
$ids = (array) $request->get_param("attachmentIds");
$ids = array_values(
array_filter($ids, function ($id) {
return current_user_can("edit_post", (int) $id);
})
);
if (empty($ids)) {
return new \WP_Error(
"aurora_folder_assign_failed",
__("There is nothing to move.", "aurora-admin"),
["status" => 400]
);
}
MediaFolders::assign_attachments($folder_id, $ids);
return new \WP_REST_Response(["success" => true], 200);
}
}
+336
View File
@@ -0,0 +1,336 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's Nav Menus replacement (Appearance > Menus).
* Not to be confused with MenuCreator/MenuCreatorData, which manages
* Aurora's own admin sidebar — this is WordPress's front-end navigation
* menu system (wp_nav_menu, theme locations), a completely different
* feature that happens to share a similar name.
*
* Thin wrapper around core's own nav-menu functions (wp_get_nav_menus(),
* wp_update_nav_menu_item(), etc.) — the same functions options-nav-menus.php
* itself calls, just driven by REST instead of a giant form post.
*/
class NavMenusData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function check_permission()
{
return current_user_can("edit_theme_options");
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/nav-menus", [
[
"methods" => "GET",
"callback" => [self::class, "list_menus"],
"permission_callback" => [self::class, "check_permission"],
],
[
"methods" => "POST",
"callback" => [self::class, "create_menu"],
"permission_callback" => [self::class, "check_permission"],
"args" => ["name" => ["type" => "string", "required" => true]],
],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/(?P<id>\d+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_menu"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/(?P<id>\d+)/items", [
[
"methods" => "GET",
"callback" => [self::class, "list_items"],
"permission_callback" => [self::class, "check_permission"],
],
[
"methods" => "POST",
"callback" => [self::class, "add_item"],
"permission_callback" => [self::class, "check_permission"],
],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/items/(?P<id>\d+)", [
[
"methods" => "PUT",
"callback" => [self::class, "update_item"],
"permission_callback" => [self::class, "check_permission"],
],
[
"methods" => "DELETE",
"callback" => [self::class, "delete_item"],
"permission_callback" => [self::class, "check_permission"],
],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/(?P<id>\d+)/reorder", [
"methods" => "POST",
"callback" => [self::class, "reorder_items"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/locations", [
[
"methods" => "GET",
"callback" => [self::class, "list_locations"],
"permission_callback" => [self::class, "check_permission"],
],
[
"methods" => "POST",
"callback" => [self::class, "assign_location"],
"permission_callback" => [self::class, "check_permission"],
],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/pickers", [
"methods" => "GET",
"callback" => [self::class, "pickers"],
"permission_callback" => [self::class, "check_permission"],
]);
}
public static function list_menus()
{
$menus = wp_get_nav_menus();
$locations = get_nav_menu_locations();
$out = [];
foreach ($menus as $menu) {
$assigned = array_keys($locations, $menu->term_id, true);
$out[] = [
"id" => (int) $menu->term_id,
"name" => $menu->name,
"itemCount" => (int) $menu->count,
"locations" => $assigned,
];
}
return new \WP_REST_Response($out, 200);
}
public static function create_menu($request)
{
$name = sanitize_text_field((string) $request->get_param("name"));
if ($name === "") {
return new \WP_Error("aurora_menu_invalid", __("Menu name is required.", "aurora-admin"), ["status" => 400]);
}
$id = wp_create_nav_menu($name);
if (is_wp_error($id)) {
return new \WP_Error("aurora_menu_create_failed", $id->get_error_message(), ["status" => 500]);
}
return new \WP_REST_Response(["id" => (int) $id, "name" => $name, "itemCount" => 0, "locations" => []], 201);
}
public static function delete_menu($request)
{
$id = (int) $request["id"];
$result = wp_delete_nav_menu($id);
if (is_wp_error($result) || !$result) {
return new \WP_Error("aurora_menu_delete_failed", __("Could not delete the menu.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true], 200);
}
public static function list_items($request)
{
$menu_id = (int) $request["id"];
$items = wp_get_nav_menu_items($menu_id);
if ($items === false) {
return new \WP_Error("aurora_menu_invalid", __("Invalid menu.", "aurora-admin"), ["status" => 404]);
}
return new \WP_REST_Response(array_map([self::class, "serialize_item"], $items), 200);
}
public static function add_item($request)
{
$menu_id = (int) $request["id"];
$body = $request->get_json_params();
$args = self::build_item_args($body);
$item_id = wp_update_nav_menu_item($menu_id, 0, $args);
if (is_wp_error($item_id)) {
return new \WP_Error("aurora_menu_item_failed", $item_id->get_error_message(), ["status" => 500]);
}
$item = get_post($item_id);
return new \WP_REST_Response(self::serialize_item(wp_setup_nav_menu_item($item)), 201);
}
public static function update_item($request)
{
$item_id = (int) $request["id"];
$body = $request->get_json_params();
$menu_id = (int) ($body["menuId"] ?? 0);
$existing = get_post($item_id);
if (!$existing || $existing->post_type !== "nav_menu_item") {
return new \WP_Error("aurora_menu_item_invalid", __("Invalid menu item.", "aurora-admin"), ["status" => 404]);
}
$args = self::build_item_args($body, $item_id);
$result = wp_update_nav_menu_item($menu_id ?: self::menu_id_for_item($item_id), $item_id, $args);
if (is_wp_error($result)) {
return new \WP_Error("aurora_menu_item_failed", $result->get_error_message(), ["status" => 500]);
}
return new \WP_REST_Response(self::serialize_item(wp_setup_nav_menu_item(get_post($item_id))), 200);
}
public static function delete_item($request)
{
$item_id = (int) $request["id"];
$result = wp_delete_post($item_id, true);
if (!$result) {
return new \WP_Error("aurora_menu_item_delete_failed", __("Could not delete the menu item.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true], 200);
}
// Bulk reorder / re-parent after a drag or up/down move in the UI —
// clients send the full flat list of {id, parent, order} once, since
// nav menu item order/hierarchy is only meaningful as a whole set.
public static function reorder_items($request)
{
$menu_id = (int) $request["id"];
$body = $request->get_json_params();
$items = is_array($body["items"] ?? null) ? $body["items"] : [];
foreach ($items as $item) {
$item_id = (int) ($item["id"] ?? 0);
if (!$item_id) {
continue;
}
wp_update_nav_menu_item($menu_id, $item_id, [
"menu-item-parent-id" => (int) ($item["parent"] ?? 0),
"menu-item-position" => (int) ($item["order"] ?? 0),
]);
}
return new \WP_REST_Response(["success" => true], 200);
}
public static function list_locations()
{
$registered = get_registered_nav_menus();
$assigned = get_nav_menu_locations();
$out = [];
foreach ($registered as $slug => $description) {
$out[] = [
"slug" => $slug,
"description" => $description,
"menuId" => isset($assigned[$slug]) ? (int) $assigned[$slug] : null,
];
}
return new \WP_REST_Response($out, 200);
}
public static function assign_location($request)
{
$body = $request->get_json_params();
$slug = sanitize_key((string) ($body["location"] ?? ""));
$menu_id = (int) ($body["menuId"] ?? 0);
if ($slug === "" || !array_key_exists($slug, get_registered_nav_menus())) {
return new \WP_Error("aurora_menu_location_invalid", __("Invalid theme location.", "aurora-admin"), ["status" => 400]);
}
$locations = get_theme_mod("nav_menu_locations", []);
if ($menu_id > 0) {
$locations[$slug] = $menu_id;
} else {
unset($locations[$slug]);
}
set_theme_mod("nav_menu_locations", $locations);
return new \WP_REST_Response(["success" => true], 200);
}
// Data the "add items" panel needs: recent pages/posts/categories to
// pick from, plus a custom-link option handled entirely client-side.
public static function pickers()
{
$pages = get_posts(["post_type" => "page", "numberposts" => 50, "orderby" => "title", "order" => "ASC"]);
$posts = get_posts(["post_type" => "post", "numberposts" => 50, "orderby" => "date", "order" => "DESC"]);
$categories = get_categories(["hide_empty" => false, "number" => 50]);
return new \WP_REST_Response([
"pages" => array_map(fn($p) => ["id" => $p->ID, "title" => $p->post_title ?: __("(no title)", "aurora-admin")], $pages),
"posts" => array_map(fn($p) => ["id" => $p->ID, "title" => $p->post_title ?: __("(no title)", "aurora-admin")], $posts),
"categories" => array_map(fn($c) => ["id" => $c->term_id, "title" => $c->name], $categories),
], 200);
}
private static function serialize_item($item)
{
return [
"id" => (int) $item->ID,
"title" => $item->title,
"url" => $item->url,
"type" => $item->type, // "custom" | "post_type" | "taxonomy"
"objectId" => (int) $item->object_id,
"parent" => (int) $item->menu_item_parent,
"order" => (int) $item->menu_order,
];
}
private static function build_item_args($body, $existing_id = 0)
{
$type = sanitize_key((string) ($body["type"] ?? "custom"));
$title = sanitize_text_field((string) ($body["title"] ?? ""));
$args = [
"menu-item-title" => $title,
"menu-item-status" => "publish",
"menu-item-parent-id" => (int) ($body["parent"] ?? 0),
];
if ($existing_id) {
$args["menu-item-position"] = (int) ($body["order"] ?? 0);
}
if ($type === "custom") {
$args["menu-item-type"] = "custom";
$args["menu-item-url"] = esc_url_raw((string) ($body["url"] ?? "#"));
} else {
$object_id = (int) ($body["objectId"] ?? 0);
$args["menu-item-object-id"] = $object_id;
if ($type === "category") {
$args["menu-item-type"] = "taxonomy";
$args["menu-item-object"] = "category";
} else {
$args["menu-item-type"] = "post_type";
$args["menu-item-object"] = $type === "page" ? "page" : "post";
}
if ($title === "") {
$args["menu-item-title"] = get_the_title($object_id);
}
}
return $args;
}
private static function menu_id_for_item($item_id)
{
$menus = wp_get_object_terms($item_id, "nav_menu");
return !empty($menus) && !is_wp_error($menus) ? (int) $menus[0]->term_id : 0;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* Core has no REST route for the permalink structure (rewrite rules aren't
* a registered setting), so options-permalink.php's one real field needs its
* own tiny controller — same options_permalink_structure() logic core's own
* settings page runs, called directly instead of going through wp-admin's
* form handler.
*/
class PermalinksData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/permalinks", [
"methods" => "GET",
"callback" => [self::class, "get_structure"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/permalinks", [
"methods" => "POST",
"callback" => [self::class, "update_structure"],
"permission_callback" => [self::class, "check_permission"],
"args" => [
"structure" => ["type" => "string", "required" => true],
],
]);
}
public static function check_permission()
{
return current_user_can("manage_options");
}
public static function get_structure()
{
return new \WP_REST_Response([
"structure" => get_option("permalink_structure", ""),
], 200);
}
public static function update_structure($request)
{
$structure = sanitize_text_field((string) $request->get_param("structure"));
global $wp_rewrite;
$wp_rewrite->set_permalink_structure($structure);
$wp_rewrite->flush_rules();
return new \WP_REST_Response([
"success" => true,
"structure" => get_option("permalink_structure", ""),
], 200);
}
}
+10
View File
@@ -153,6 +153,16 @@ class PluginsData
if (!function_exists("delete_plugins")) { if (!function_exists("delete_plugins")) {
require_once ABSPATH . "wp-admin/includes/plugin.php"; require_once ABSPATH . "wp-admin/includes/plugin.php";
} }
// delete_plugins() calls request_filesystem_credentials() internally
// (WP_Filesystem setup) — that's defined in file.php, which a REST API
// request doesn't load by default the way a normal wp-admin page load
// does. Without this, deleting a plugin fataled with "Call to
// undefined function request_filesystem_credentials()" instead of
// actually deleting anything. update_plugin() below already required
// this for the same reason; delete_plugin() just never got it.
if (!function_exists("request_filesystem_credentials")) {
require_once ABSPATH . "wp-admin/includes/file.php";
}
$file = self::sanitize_file($request->get_param("file")); $file = self::sanitize_file($request->get_param("file"));
if (is_plugin_active($file)) { if (is_plugin_active($file)) {
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* The one REST route Aurora's Profile reskin needs beyond core's own
* /wp/v2/users/me (which already covers name/contact/bio fields): core
* deliberately excludes password from that endpoint's schema, so changing
* it needs this tiny wrapper around wp_set_password() — the same function
* the native Your Profile screen itself calls.
*/
class ProfileData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/profile/password", [
"methods" => "POST",
"callback" => [self::class, "update_password"],
"permission_callback" => function () {
return is_user_logged_in();
},
"args" => [
"password" => ["type" => "string", "required" => true],
],
]);
}
public static function update_password($request)
{
$password = (string) $request->get_param("password");
if (strlen($password) < 8) {
return new \WP_Error("aurora_profile_password_short", __("Password must be at least 8 characters.", "aurora-admin"), ["status" => 400]);
}
$user_id = get_current_user_id();
wp_set_password($password, $user_id);
// wp_set_password() invalidates every session's auth cookie (it's
// derived in part from the password hash) — including the one this
// very request came in on. Re-issue it immediately, same as core's own
// user-edit.php does when you change your own password, or the next
// request (the REST response reaching the browser) would already be
// logged out.
wp_clear_auth_cookie();
wp_set_auth_cookie($user_id);
return new \WP_REST_Response(["success" => true], 200);
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's Themes replacement (Appearance > Themes'
* browse/activate/delete — not the Customizer, which Shell already
* deliberately excludes from framing/hijacking).
*/
class ThemesData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function check_permission()
{
return current_user_can("switch_themes");
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/themes", [
"methods" => "GET",
"callback" => [self::class, "list_themes"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/themes/activate", [
"methods" => "POST",
"callback" => [self::class, "activate_theme"],
"permission_callback" => [self::class, "check_permission"],
"args" => ["stylesheet" => ["type" => "string", "required" => true]],
]);
register_rest_route("aurora-admin/v1", "/themes/(?P<stylesheet>[^/]+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_theme"],
"permission_callback" => function () {
return current_user_can("delete_themes");
},
]);
}
public static function list_themes()
{
$themes = wp_get_themes();
$active = get_stylesheet();
$out = [];
foreach ($themes as $stylesheet => $theme) {
$out[] = [
"stylesheet" => $stylesheet,
"name" => $theme->get("Name"),
"version" => $theme->get("Version"),
"description" => $theme->get("Description"),
"screenshot" => $theme->get_screenshot() ?: "",
"active" => $stylesheet === $active,
];
}
// Active theme first, alphabetical after.
usort($out, function ($a, $b) {
if ($a["active"] !== $b["active"]) {
return $b["active"] <=> $a["active"];
}
return strcasecmp($a["name"], $b["name"]);
});
return new \WP_REST_Response($out, 200);
}
public static function activate_theme($request)
{
$stylesheet = sanitize_text_field((string) $request->get_param("stylesheet"));
$theme = wp_get_theme($stylesheet);
if (!$theme->exists()) {
return new \WP_Error("aurora_theme_invalid", __("Theme not found.", "aurora-admin"), ["status" => 404]);
}
switch_theme($stylesheet);
return new \WP_REST_Response(["success" => true, "stylesheet" => $stylesheet], 200);
}
public static function delete_theme($request)
{
$stylesheet = sanitize_text_field((string) $request["stylesheet"]);
if ($stylesheet === get_stylesheet()) {
return new \WP_Error("aurora_theme_active", __("Can't delete the active theme.", "aurora-admin"), ["status" => 400]);
}
require_once ABSPATH . "wp-admin/includes/file.php";
require_once ABSPATH . "wp-admin/includes/theme.php";
$result = delete_theme($stylesheet);
if (is_wp_error($result)) {
return new \WP_Error("aurora_theme_delete_failed", $result->get_error_message(), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true], 200);
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for a deliberately simplified Widgets replacement:
* list registered sidebars (+ the "Inactive Widgets" pseudo-sidebar core
* itself uses to park deactivated instances), move an existing widget
* instance between sidebars, reorder within a sidebar, and remove an
* already-inactive instance entirely.
*
* What's NOT built here, deliberately: adding a brand-new instance of a
* widget type. Each widget class defines its own default instance settings
* and its own settings form — replicating that generically for every
* registered widget (core's + every theme/plugin's) is a full block-widget
* editor's worth of scope on its own, the same "don't rebuild the real
* editor" call made for the block editor and full drag-and-drop widget
* blocks. Users can still create a new instance via the native block
* widgets screen if this toggle is off; this covers reorganizing what's
* already there.
*/
class WidgetsData
{
const INACTIVE_ID = "wp_inactive_widgets";
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function check_permission()
{
return current_user_can("edit_theme_options");
}
/**
* wp_get_sidebars_widgets() is marked for internal core use only, so we
* read the same option it wraps directly. Unlike core's version this
* doesn't auto-assign never-placed widgets to a default sidebar — an
* edge case acceptable to skip given this is a deliberately simplified
* replacement (see class docblock).
*/
private static function get_sidebars_widgets()
{
$assignments = get_option("sidebars_widgets", []);
unset($assignments["array_version"]);
return $assignments;
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/widgets", [
"methods" => "GET",
"callback" => [self::class, "list_widgets"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/widgets/move", [
"methods" => "POST",
"callback" => [self::class, "move_widget"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/widgets/(?P<id>[^/]+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_widget"],
"permission_callback" => [self::class, "check_permission"],
]);
}
public static function list_widgets()
{
global $wp_registered_sidebars, $wp_registered_widgets;
$assignments = self::get_sidebars_widgets();
$sidebars = [];
foreach ($wp_registered_sidebars as $id => $sidebar) {
$sidebars[] = [
"id" => $id,
"name" => $sidebar["name"],
"widgets" => self::serialize_widget_list($assignments[$id] ?? []),
];
}
$sidebars[] = [
"id" => self::INACTIVE_ID,
"name" => __("Inactive Widgets", "aurora-admin"),
"widgets" => self::serialize_widget_list($assignments[self::INACTIVE_ID] ?? []),
];
return new \WP_REST_Response(["sidebars" => $sidebars], 200);
}
private static function serialize_widget_list($widget_ids)
{
global $wp_registered_widgets;
$out = [];
foreach (array_values($widget_ids) as $order => $widget_id) {
if (!preg_match('/^(.+)-(\d+)$/', $widget_id, $m)) {
// Some legacy widgets aren't multi-instance and use a bare id.
$id_base = $widget_id;
$number = null;
} else {
$id_base = $m[1];
$number = (int) $m[2];
}
$title = "";
if ($number !== null) {
$instance_option = get_option("widget_{$id_base}");
if (is_array($instance_option) && isset($instance_option[$number]["title"])) {
$title = (string) $instance_option[$number]["title"];
}
}
$out[] = [
"id" => $widget_id,
"idBase" => $id_base,
"name" => isset($wp_registered_widgets[$widget_id]["name"])
? $wp_registered_widgets[$widget_id]["name"]
: ucwords(str_replace(["-", "_"], " ", $id_base)),
"title" => $title,
"order" => $order,
];
}
return $out;
}
public static function move_widget($request)
{
$body = $request->get_json_params();
$widget_id = sanitize_text_field((string) ($body["widgetId"] ?? ""));
$from = sanitize_key((string) ($body["fromSidebar"] ?? ""));
$to = sanitize_key((string) ($body["toSidebar"] ?? ""));
$order = (int) ($body["order"] ?? -1);
if ($widget_id === "" || $to === "") {
return new \WP_Error("aurora_widget_invalid", __("Invalid widget move request.", "aurora-admin"), ["status" => 400]);
}
$assignments = self::get_sidebars_widgets();
if (!isset($assignments[$to])) {
$assignments[$to] = [];
}
if ($from !== "" && isset($assignments[$from])) {
$assignments[$from] = array_values(array_diff($assignments[$from], [$widget_id]));
}
$assignments[$to] = array_values(array_diff($assignments[$to], [$widget_id]));
if ($order < 0 || $order >= count($assignments[$to])) {
$assignments[$to][] = $widget_id;
} else {
array_splice($assignments[$to], $order, 0, [$widget_id]);
}
wp_set_sidebars_widgets($assignments);
return new \WP_REST_Response(["success" => true], 200);
}
public static function delete_widget($request)
{
$widget_id = sanitize_text_field((string) $request["id"]);
$assignments = self::get_sidebars_widgets();
$in_inactive = in_array($widget_id, $assignments[self::INACTIVE_ID] ?? [], true);
if (!$in_inactive) {
return new \WP_Error(
"aurora_widget_delete_requires_inactive",
__("Move the widget to Inactive Widgets before deleting it.", "aurora-admin"),
["status" => 400]
);
}
$assignments[self::INACTIVE_ID] = array_values(array_diff($assignments[self::INACTIVE_ID], [$widget_id]));
wp_set_sidebars_widgets($assignments);
if (preg_match('/^(.+)-(\d+)$/', $widget_id, $m)) {
$id_base = $m[1];
$number = (int) $m[2];
$instances = get_option("widget_{$id_base}");
if (is_array($instances) && isset($instances[$number])) {
unset($instances[$number]);
update_option("widget_{$id_base}", $instances);
}
}
return new \WP_REST_Response(["success" => true], 200);
}
}
+1
View File
@@ -1,6 +1,7 @@
<?php <?php
namespace AuroraAdmin\Roles; namespace AuroraAdmin\Roles;
defined("ABSPATH") || exit(); defined("ABSPATH") || exit();
/** /**
+6 -26
View File
@@ -144,36 +144,16 @@ class MenuSerializer
return ""; return "";
} }
// Both checks default to true so existing installs keep their current // Plugins is the one screen still on the redirect mechanism (plugins.php
// behavior (previously always on regardless of these settings) until // can't be cleanly output-buffered — see SubApps::redirect_native_pages()
// explicitly turned off in Settings > Plugins / Settings > Comments. // for why) — its sidebar link still needs rewriting to the Aurora URL.
// Posts/Pages/Media/Users/Comments hijack their native URLs in place
// instead (see HijackedNativePages), so their native sidebar links
// already point at the right place with no rewrite needed.
if ($slug === "plugins.php" && Settings::get("modern_plugins", true)) { if ($slug === "plugins.php" && Settings::get("modern_plugins", true)) {
return admin_url("plugins.php?page=aurora-admin-plugins"); return admin_url("plugins.php?page=aurora-admin-plugins");
} }
if ($slug === "edit-comments.php" && Settings::get("modern_comments", true)) {
return admin_url("admin.php?page=aurora-admin-comments");
}
// "edit.php" (no query string) is specifically the default post type's
// (Posts) list — other post types register as "edit.php?post_type=xyz",
// a different string, so they're untouched.
if ($slug === "edit.php" && Settings::get("modern_posts", true)) {
return admin_url("admin.php?page=aurora-admin-posts");
}
if ($slug === "edit.php?post_type=page" && Settings::get("modern_pages", true)) {
return admin_url("admin.php?page=aurora-admin-pages");
}
if ($slug === "upload.php" && Settings::get("modern_media", true)) {
return admin_url("admin.php?page=aurora-admin-media");
}
if ($slug === "users.php" && Settings::get("modern_users", true)) {
return admin_url("admin.php?page=aurora-admin-users");
}
// Already an absolute URL. // Already an absolute URL.
if (preg_match('#^https?://#', $slug)) { if (preg_match('#^https?://#', $slug)) {
return $slug; return $slug;
+30 -21
View File
@@ -3,7 +3,7 @@ namespace AuroraAdmin\Shell;
use AuroraAdmin\Utility\Assets; use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings; use AuroraAdmin\Options\Settings;
use AuroraAdmin\Licensing\License; use AuroraAdmin\Pages\ScreenHijack;
defined("ABSPATH") || exit(); defined("ABSPATH") || exit();
@@ -78,6 +78,7 @@ class Shell
*/ */
private static function is_own_page() private static function is_own_page()
{ {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, used only to decide light/dark chrome theming for the current screen; no state change.
$page = isset($_GET["page"]) ? sanitize_text_field(wp_unslash($_GET["page"])) : ""; $page = isset($_GET["page"]) ? sanitize_text_field(wp_unslash($_GET["page"])) : "";
if (strpos($page, "aurora-admin") === 0) { if (strpos($page, "aurora-admin") === 0) {
return true; return true;
@@ -86,7 +87,16 @@ class Shell
// The dashboard (index.php) is taken over by Aurora's own dashboard, so // 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 // treat it as one of our pages: dark frame, and the native-dark theme
// sheet is skipped there. // sheet is skipped there.
return isset($GLOBALS["pagenow"]) && $GLOBALS["pagenow"] === "index.php"; if (isset($GLOBALS["pagenow"]) && $GLOBALS["pagenow"] === "index.php") {
return true;
}
// ScreenHijack-based pages (Posts, Pages, Media, Users, Comments) keep
// their native URL — no ?page=aurora-admin to key off — so they'd
// otherwise be misdetected as native pages and get native-dark.css's
// own-page exclusion skipped, letting its generic `a` color rules leak
// into the real Aurora UI these screens render.
return ScreenHijack::is_hijack_active();
} }
public static function print_early_styles() public static function print_early_styles()
@@ -189,12 +199,16 @@ class Shell
$current_user = wp_get_current_user(); $current_user = wp_get_current_user();
// Who may see the remote-site switcher in the toolbar (Settings > // WP core sets this global on pages that are logically "under" a menu
// General > Remote Site Switcher Capability, default manage_options). // section but aren't themselves a listed submenu item — e.g.
$switcher_cap = Settings::get("remote_site_switcher_capability", ""); // theme-install.php sets $parent_file = 'themes.php' (see wp-admin/
if (!is_string($switcher_cap) || trim($switcher_cap) === "") { // theme-install.php), media-new.php sets 'upload.php', user-new.php
$switcher_cap = "manage_options"; // sets 'users.php'. Native admin uses it to keep the *parent* menu
} // section highlighted/expanded while on these "hidden child" screens;
// the sidebar needs the same signal, since MenuSerializer::serialize()
// only scrapes $submenu (which these pages deliberately aren't part
// of — WP's own native left menu doesn't list them as links either).
global $parent_file;
$data = [ $data = [
"restUrl" => esc_url_raw(get_rest_url()), "restUrl" => esc_url_raw(get_rest_url()),
@@ -206,9 +220,9 @@ class Shell
"userEmail" => $current_user->user_email, "userEmail" => $current_user->user_email,
"avatar" => get_avatar_url($current_user->ID, ["size" => 64]), "avatar" => get_avatar_url($current_user->ID, ["size" => 64]),
"currentUrl" => esc_url_raw(self::current_admin_url()), "currentUrl" => esc_url_raw(self::current_admin_url()),
"activeParentFile" => is_string($parent_file) ? $parent_file : "",
// Secrets stripped — this is serialized for every admin user. // Secrets stripped — this is serialized for every admin user.
"settings" => Settings::get_safe(), "settings" => Settings::get_safe(),
"canSwitchSites" => current_user_can($switcher_cap) && License::is_feature_available("remote-site-switcher"),
"menu" => MenuSerializer::serialize(), "menu" => MenuSerializer::serialize(),
"auroraNav" => self::aurora_nav(), "auroraNav" => self::aurora_nav(),
]; ];
@@ -222,7 +236,7 @@ class Shell
private static function current_admin_url() private static function current_admin_url()
{ {
$request = isset($_SERVER["REQUEST_URI"]) ? $_SERVER["REQUEST_URI"] : ""; $request = isset($_SERVER["REQUEST_URI"]) ? sanitize_text_field(wp_unslash($_SERVER["REQUEST_URI"])) : "";
return home_url($request); return home_url($request);
} }
@@ -235,13 +249,12 @@ class Shell
private static function aurora_nav() private static function aurora_nav()
{ {
$items = [ $items = [
["id" => "aurora-admin", "title" => __("Settings", "aurora-admin"), "icon" => "dashicons-admin-generic", "feature" => null], ["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", "feature" => null], ["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", "feature" => null], ["id" => "aurora-admin-notices", "title" => __("Admin Notices", "aurora-admin"), "icon" => "dashicons-megaphone"],
["id" => "aurora-admin-roles", "title" => __("Role Editor", "aurora-admin"), "icon" => "dashicons-groups", "feature" => "role-editor"], ["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", "feature" => "activity-log"], ["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", "feature" => "database-explorer"], ["id" => "aurora-admin-bug-report", "title" => __("Report a Bug", "aurora-admin"), "icon" => "dashicons-warning"],
["id" => "aurora-admin-file-manager", "title" => __("File Manager", "aurora-admin"), "icon" => "dashicons-portfolio", "feature" => "file-manager"],
]; ];
$nav = []; $nav = [];
@@ -254,10 +267,6 @@ class Shell
"title" => $item["title"], "title" => $item["title"],
"url" => esc_url_raw(admin_url("admin.php?page=" . $item["id"])), "url" => esc_url_raw(admin_url("admin.php?page=" . $item["id"])),
"icon" => ["type" => "dashicon", "value" => $item["icon"]], "icon" => ["type" => "dashicon", "value" => $item["icon"]],
// Still a real, navigable link when locked — SubApps::render() shows
// an upgrade prompt there instead of the real feature. This flag
// just drives the small lock badge in the sidebar (Sidebar.vue).
"locked" => $item["feature"] && !License::is_feature_available($item["feature"]),
]; ];
} }
-9
View File
@@ -2,7 +2,6 @@
namespace AuroraAdmin\WhiteLabel; namespace AuroraAdmin\WhiteLabel;
use AuroraAdmin\Options\Settings; use AuroraAdmin\Options\Settings;
use AuroraAdmin\Licensing\License;
defined("ABSPATH") || exit(); defined("ABSPATH") || exit();
@@ -11,10 +10,6 @@ defined("ABSPATH") || exit();
* - admin_favicon: replaces the favicon across wp-admin * - admin_favicon: replaces the favicon across wp-admin
* - plugin_name: renames the plugin in the plugins list and the admin menu * - plugin_name: renames the plugin in the plugins list and the admin menu
* - text_replacements: find/replace pairs applied to translated admin text * - text_replacements: find/replace pairs applied to translated admin text
*
* Paid feature — the settings fields stay visible either way (so an admin
* can pre-configure before upgrading), but none of the hooks below register
* without a license, so nothing actually applies yet.
*/ */
class WhiteLabel class WhiteLabel
{ {
@@ -23,10 +18,6 @@ class WhiteLabel
public function __construct() public function __construct()
{ {
if (!License::is_feature_available("white-label")) {
return;
}
add_action("admin_head", [self::class, "favicon"]); add_action("admin_head", [self::class, "favicon"]);
add_filter("all_plugins", [self::class, "rename_plugin"]); add_filter("all_plugins", [self::class, "rename_plugin"]);
add_action("admin_menu", [self::class, "rename_menu"], 999); add_action("admin_menu", [self::class, "rename_menu"], 999);
+342 -2
View File
@@ -1,13 +1,22 @@
{ {
"name": "aurora-admin-app", "name": "aurora-admin-app",
"version": "2.0.0", "version": "2.16.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aurora-admin-app", "name": "aurora-admin-app",
"version": "2.0.0", "version": "2.16.0",
"dependencies": { "dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"codemirror": "^6.0.2",
"vue": "^3.5.26" "vue": "^3.5.26"
}, },
"devDependencies": { "devDependencies": {
@@ -61,6 +70,198 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-php": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
"integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/php": "^1.0.0"
}
},
"node_modules/@codemirror/lang-sql": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
"integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-xml": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
"integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/xml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.5",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.5.tgz",
"integrity": "sha512-7uT/vUgH6dfXWn3WqOe23KneILMvGy5wQjNMEcRXLKzziJ9NOktpW6tGoyQpwVkBgE5Gj6hKkCcsddbnkaWrOQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -509,6 +710,112 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz",
"integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.4.tgz",
"integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/php": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz",
"integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.1.0"
}
},
"node_modules/@lezer/xml": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
"integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.2", "version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
@@ -1019,6 +1326,27 @@
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1246,6 +1574,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1358,6 +1692,12 @@
"optional": true "optional": true
} }
} }
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
} }
} }
} }
+10 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aurora-admin-app", "name": "aurora-admin-app",
"version": "2.7.2", "version": "3.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -8,6 +8,15 @@
"build": "vite build" "build": "vite build"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"codemirror": "^6.0.2",
"vue": "^3.5.26" "vue": "^3.5.26"
}, },
"devDependencies": { "devDependencies": {
+60
View File
@@ -0,0 +1,60 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
// 0-100. When null, renders an indeterminate/pulsing bar instead of a
// determinate fill — used for phases with no cheap way to compute a
// percentage (e.g. "building the file list" before zipping starts).
percent: { type: Number, default: null },
label: { type: String, default: '' },
});
const clamped = computed(() => (props.percent === null ? null : Math.max(0, Math.min(100, props.percent))));
</script>
<template>
<div class="progress-bar">
<div v-if="label" class="progress-bar__label">{{ label }}</div>
<div class="progress-bar__track" :class="{ 'progress-bar__track--indeterminate': clamped === null }">
<div
v-if="clamped !== null"
class="progress-bar__fill"
:style="{ width: clamped + '%' }"
/>
</div>
</div>
</template>
<style scoped>
.progress-bar__label {
font-size: 0.8125rem;
color: var(--aurora-text-muted);
margin-bottom: 6px;
}
.progress-bar__track {
height: 8px;
border-radius: 999px;
background: var(--aurora-bg-subtle);
overflow: hidden;
position: relative;
}
.progress-bar__fill {
height: 100%;
border-radius: 999px;
background: var(--aurora-accent);
transition: width 0.3s ease;
}
.progress-bar__track--indeterminate::before {
content: '';
position: absolute;
top: 0; bottom: 0;
width: 40%;
border-radius: 999px;
background: var(--aurora-accent);
animation: progress-bar-indeterminate 1.2s ease-in-out infinite;
}
@keyframes progress-bar-indeterminate {
0% { left: -40%; }
100% { left: 100%; }
}
</style>
+11 -3
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -8,13 +8,17 @@ defineProps({
type: String, type: String,
default: '', default: '',
}, },
disabled: {
type: Boolean,
default: false,
},
}); });
defineEmits(['update:modelValue']); defineEmits(['update:modelValue']);
</script> </script>
<template> <template>
<label class="toggle"> <label class="toggle" :class="{ 'toggle--disabled': disabled }">
<span v-if="label" class="toggle__label">{{ label }}</span> <span v-if="label" class="toggle__label">{{ label }}</span>
<button <button
type="button" type="button"
@@ -22,7 +26,8 @@ defineEmits(['update:modelValue']);
:class="{ 'toggle__switch--on': modelValue }" :class="{ 'toggle__switch--on': modelValue }"
role="switch" role="switch"
:aria-checked="modelValue" :aria-checked="modelValue"
@click="$emit('update:modelValue', !modelValue)" :disabled="disabled"
@click="!disabled && $emit('update:modelValue', !modelValue)"
> >
<span class="toggle__thumb" /> <span class="toggle__thumb" />
</button> </button>
@@ -59,6 +64,9 @@ defineEmits(['update:modelValue']);
background: var(--aurora-accent); background: var(--aurora-accent);
} }
.toggle--disabled { cursor: default; opacity: 0.55; }
.toggle--disabled .toggle__switch { cursor: default; }
.toggle__thumb { .toggle__thumb {
position: absolute; position: absolute;
top: 0.15rem; top: 0.15rem;
+98
View File
@@ -0,0 +1,98 @@
import { ref, onUnmounted } from 'vue';
// Phase weights for a rough overall-progress percentage. Backup and
// restore jobs use different phase sets (see BackupEngine::effective_phases
// on the PHP side) — only the phases relevant to whichever job is active
// contribute, everything else is ignored.
const PHASE_WEIGHTS = {
db_dump: 40,
file_zip: 40,
upload_remote: 20,
download_remote: 20,
db_restore: 50,
file_restore: 50,
};
const PHASE_LABELS = {
pending: 'Starting…',
db_dump: 'Backing up the database…',
file_zip: 'Archiving files…',
upload_remote: 'Uploading to remote storage…',
download_remote: 'Downloading from remote storage…',
db_restore: 'Restoring the database…',
file_restore: 'Restoring files…',
done: 'Done',
failed: 'Failed',
cancelled: 'Cancelled',
};
/**
* Drives a backup/restore job to completion by repeatedly calling the
* REST "step" route — a setTimeout-recursion loop (not setInterval), so a
* slow step can never overlap the next poll for the same job. The
* server-side wp_cron safety net keeps the job moving even if this stops
* polling (e.g. the tab is closed mid-backup) — this composable is purely
* the foreground "watch it happen live" experience, not the only thing
* keeping a job alive.
*/
export function useJobPolling(api) {
const job = ref(null);
const isPolling = ref(false);
const error = ref('');
let timer = null;
let stopped = false;
const percent = () => {
if (!job.value) return null;
const weight = PHASE_WEIGHTS[job.value.phase];
if (!weight) return job.value.status === 'done' ? 100 : null;
// No fine-grained progress within a phase is cheap to compute
// client-side (server tracks byte/row offsets, not percentages) — an
// indeterminate bar per phase, weighted by how much of the whole job
// that phase represents, is a reasonable approximation without a
// second round trip just to compute a number.
return null;
};
const label = () => {
if (!job.value) return '';
if (job.value.status === 'failed') return job.value.error_message || 'Failed';
return PHASE_LABELS[job.value.phase] || PHASE_LABELS[job.value.status] || '';
};
const poll = async (jobId) => {
if (stopped) return;
try {
const res = await api(`/backup/jobs/${jobId}/step`, { method: 'POST' });
job.value = res.job;
} catch (e) {
error.value = e.message || 'Request failed';
isPolling.value = false;
return;
}
if (!job.value || ['done', 'failed', 'cancelled'].includes(job.value.status)) {
isPolling.value = false;
return;
}
timer = setTimeout(() => poll(jobId), 1500);
};
const start = (jobId) => {
stopped = false;
isPolling.value = true;
error.value = '';
poll(jobId);
};
const stop = () => {
stopped = true;
isPolling.value = false;
if (timer) clearTimeout(timer);
};
onUnmounted(stop);
return { job, isPolling, error, start, stop, percent, label };
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import AddUser from '@/pages/users/AddUser.vue';
const mountEl = document.getElementById('aurora-admin-add-user-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(AddUser, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import BugReport from '@/pages/bug-report/BugReport.vue';
const mountEl = document.getElementById('aurora-admin-bug-report-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(BugReport, { serverData: data }).mount(mountEl);
}
-10
View File
@@ -1,10 +0,0 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import DatabaseExplorer from '@/pages/database-explorer/DatabaseExplorer.vue';
const mountEl = document.getElementById('aurora-admin-database-explorer-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(DatabaseExplorer, { serverData: data }).mount(mountEl);
}
-10
View File
@@ -1,10 +0,0 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import FileManager from '@/pages/file-manager/FileManager.vue';
const mountEl = document.getElementById('aurora-admin-file-manager-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(FileManager, { serverData: data }).mount(mountEl);
}
-10
View File
@@ -1,10 +0,0 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import UpgradeNotice from '@/pages/_shared/UpgradeNotice.vue';
const mountEl = document.querySelector('[data-aurora-locked-feature]');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraLockedFeature || '{}');
createApp(UpgradeNotice, { feature: data.feature, description: data.description }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import MediaUpload from '@/pages/media-upload/MediaUpload.vue';
const mountEl = document.getElementById('aurora-admin-media-upload-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(MediaUpload, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import NativeSettings from '@/pages/native-settings/NativeSettings.vue';
const mountEl = document.getElementById('aurora-admin-native-settings-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(NativeSettings, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import NavMenus from '@/pages/nav-menus/NavMenus.vue';
const mountEl = document.getElementById('aurora-admin-nav-menus-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(NavMenus, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Profile from '@/pages/profile/Profile.vue';
const mountEl = document.getElementById('aurora-admin-profile-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Profile, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Themes from '@/pages/themes/Themes.vue';
const mountEl = document.getElementById('aurora-admin-themes-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Themes, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Widgets from '@/pages/widgets/Widgets.vue';
const mountEl = document.getElementById('aurora-admin-widgets-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Widgets, { serverData: data }).mount(mountEl);
}
+26 -3
View File
@@ -72,7 +72,14 @@ const search = defineModel('search', { type: String, default: '' });
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 20px; gap: 20px;
min-height: calc(100vh - var(--aurora-toolbar-h) - 60px); /* A fixed height (not just min-height) so the two panes below can each
scroll independently within it — previously this only set a minimum
and let the whole shell grow with content, meaning both panes shared
one page-level scrollbar. Selecting an item near the bottom of a long
list left the detail panel's newly-loaded content rendered above the
current scroll position, looking like nothing happened until you
scrolled all the way back up. */
height: calc(100vh - var(--aurora-toolbar-h) - 60px);
margin: -32px; margin: -32px;
padding: 22px; padding: 22px;
box-sizing: border-box; box-sizing: border-box;
@@ -85,6 +92,8 @@ const search = defineModel('search', { type: String, default: '' });
.app-list-shell__nav { .app-list-shell__nav {
width: 340px; width: 340px;
flex-shrink: 0; flex-shrink: 0;
height: 100%;
overflow-y: auto;
border-radius: 16px; border-radius: 16px;
padding: 24px 20px; padding: 24px 20px;
box-sizing: border-box; box-sizing: border-box;
@@ -150,6 +159,8 @@ const search = defineModel('search', { type: String, default: '' });
/* Right — no background/border of its own, same reasoning. */ /* Right — no background/border of its own, same reasoning. */
.app-list-shell__content { .app-list-shell__content {
flex: 1; min-width: 0; flex: 1; min-width: 0;
height: 100%;
overflow-y: auto;
border-radius: 16px; border-radius: 16px;
padding: 28px 32px; padding: 28px 32px;
min-height: 480px; min-height: 480px;
@@ -172,7 +183,19 @@ const search = defineModel('search', { type: String, default: '' });
.app-list-shell__empty-text { font-size: 0.8125rem; color: var(--aurora-text-muted); max-width: 320px; margin: 0; } .app-list-shell__empty-text { font-size: 0.8125rem; color: var(--aurora-text-muted); max-width: 320px; margin: 0; }
@media (max-width: 1000px) { @media (max-width: 1000px) {
.app-list-shell { flex-direction: column; } /* Stacked instead of side-by-side, so the fixed-height/independent-
.app-list-shell__nav { width: auto; } scroll treatment above (built for two side-by-side panes) would just
squash both panes into half-height boxes — switch to natural height
with each pane capped and independently scrollable instead. */
.app-list-shell { flex-direction: column; height: auto; }
.app-list-shell__nav {
width: auto;
height: auto;
max-height: 40vh;
}
.app-list-shell__content {
height: auto;
max-height: 60vh;
}
} }
</style> </style>
-81
View File
@@ -1,81 +0,0 @@
<script setup>
defineProps({
feature: { type: String, required: true },
description: { type: String, default: '' },
});
</script>
<template>
<div class="upgrade-notice">
<div class="upgrade-notice__card">
<span class="upgrade-notice__icon">
<span class="dashicons dashicons-lock" />
</span>
<h1 class="upgrade-notice__title">{{ feature }} is a Pro feature</h1>
<p class="upgrade-notice__text">
{{ description || `Upgrade to Aurora Admin Pro to unlock ${feature}.` }}
</p>
<a class="upgrade-notice__cta" href="admin.php?page=aurora-admin#license">
Enter a license key
</a>
</div>
</div>
</template>
<style scoped>
.upgrade-notice {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
padding: 40px 20px;
}
.upgrade-notice__card {
max-width: 380px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.upgrade-notice__icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
background: var(--aurora-accent-soft);
color: var(--aurora-accent);
margin-bottom: 4px;
}
.upgrade-notice__icon .dashicons {
font-size: 26px;
width: 26px;
height: 26px;
}
.upgrade-notice__title {
font-size: 1.15rem;
font-weight: 700;
margin: 0;
color: var(--aurora-text);
}
.upgrade-notice__text {
font-size: 0.875rem;
color: var(--aurora-text-muted);
margin: 0;
}
.upgrade-notice__cta {
margin-top: 8px;
padding: 8px 18px;
border-radius: 8px;
background: var(--aurora-accent);
color: var(--aurora-accent-text);
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
}
.upgrade-notice__cta:hover {
opacity: 0.9;
}
</style>
+128
View File
@@ -0,0 +1,128 @@
<script setup>
import { reactive, ref } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const description = ref('');
const sending = ref(false);
const notice = ref('');
const error = ref('');
const diagnostics = reactive({
pluginVersion: props.serverData.pluginVersion || '',
wpVersion: props.serverData.wpVersion || '',
phpVersion: props.serverData.phpVersion || '',
theme: props.serverData.theme || '',
});
const send = async () => {
if (!description.value.trim()) return;
sending.value = true;
error.value = '';
notice.value = '';
try {
const res = await fetch(`${props.serverData.restUrl}aurora-admin/v1/bug-report`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
},
body: JSON.stringify({ description: description.value }),
});
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data?.message || 'Could not send your report.');
notice.value = 'Thanks — your report was sent.';
description.value = '';
} catch (e) {
error.value = e.message;
} finally {
sending.value = false;
}
};
</script>
<template>
<main class="bug-report-page">
<header class="bug-report-page__header">
<p class="bug-report-page__eyebrow">Support</p>
<h1>Report a Bug</h1>
<p class="bug-report-page__sub">
Tell us what went wrong. We'll get your diagnostics automatically —
just describe the issue below.
</p>
</header>
<div v-if="notice" class="bug-report-notice bug-report-notice--success">{{ notice }}</div>
<div v-if="error" class="bug-report-notice bug-report-notice--error">{{ error }}</div>
<form class="bug-report-form" @submit.prevent="send">
<label class="bug-report-field">
<span>What happened?</span>
<textarea
v-model="description"
rows="8"
placeholder="Describe the bug, what you expected, and the steps to reproduce it…"
required
/>
</label>
<div class="bug-report-diagnostics">
<h2>Diagnostics (sent automatically)</h2>
<dl>
<div><dt>Plugin version</dt><dd>{{ diagnostics.pluginVersion || '—' }}</dd></div>
<div><dt>WordPress version</dt><dd>{{ diagnostics.wpVersion || '—' }}</dd></div>
<div><dt>PHP version</dt><dd>{{ diagnostics.phpVersion || '—' }}</dd></div>
<div><dt>Active theme</dt><dd>{{ diagnostics.theme || '—' }}</dd></div>
</dl>
</div>
<div class="bug-report-form__actions">
<button type="submit" class="button-primary" :disabled="sending || !description.trim()">
{{ sending ? 'Sending…' : 'Send Report' }}
</button>
</div>
</form>
</main>
</template>
<style scoped>
.bug-report-page { max-width: 640px; margin: 0 auto; }
.bug-report-page__header { margin-bottom: 16px; }
.bug-report-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.bug-report-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.bug-report-page__sub { margin: 8px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
.bug-report-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
.bug-report-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.bug-report-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.bug-report-form { display: flex; flex-direction: column; gap: 20px; }
.bug-report-field { display: flex; flex-direction: column; gap: 6px; }
.bug-report-field span { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-text); }
.bug-report-field textarea {
padding: 10px 12px; border-radius: 8px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.875rem;
font-family: inherit; resize: vertical;
}
.bug-report-diagnostics {
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
border-radius: 10px; padding: 14px 16px;
}
.bug-report-diagnostics h2 {
margin: 0 0 10px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.03em; color: var(--aurora-text-muted); font-weight: 600;
}
.bug-report-diagnostics dl { margin: 0; display: flex; flex-direction: column; gap: 6px; }
.bug-report-diagnostics dl > div { display: flex; justify-content: space-between; font-size: 0.8125rem; }
.bug-report-diagnostics dt { color: var(--aurora-text-muted); }
.bug-report-diagnostics dd { margin: 0; color: var(--aurora-text); font-weight: 500; }
.bug-report-form__actions { display: flex; justify-content: flex-end; }
</style>
+9 -6
View File
@@ -13,7 +13,6 @@ import ActiveUsers from './cards/ActiveUsers.vue';
import DeviceUsage from './cards/DeviceUsage.vue'; import DeviceUsage from './cards/DeviceUsage.vue';
import Engagement from './cards/Engagement.vue'; import Engagement from './cards/Engagement.vue';
import TopList from './cards/TopList.vue'; import TopList from './cards/TopList.vue';
import UpgradeNotice from '../_shared/UpgradeNotice.vue';
const props = defineProps({ const props = defineProps({
serverData: { type: Object, required: true }, serverData: { type: Object, required: true },
@@ -108,11 +107,9 @@ onMounted(() => reload(range.value[0], range.value[1]));
</div> </div>
</div> </div>
<UpgradeNotice <div v-else-if="analyticsError" class="dash__analytics-error">
v-else-if="analyticsError" Could not load analytics. Try refreshing the page.
feature="Analytics" </div>
description="Upgrade to Aurora Admin Pro to unlock first-party visitor analytics for this dashboard."
/>
<div v-else class="dash__grid"> <div v-else class="dash__grid">
<div class="dash__cell" style="grid-column: span 8"> <div class="dash__cell" style="grid-column: span 8">
@@ -209,6 +206,12 @@ onMounted(() => reload(range.value[0], range.value[1]));
padding: 3px; padding: 3px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.dash__analytics-error {
padding: 48px 24px;
text-align: center;
color: var(--aurora-text-muted);
font-size: 0.875rem;
}
.dash__tab { .dash__tab {
border: none; background: none; cursor: pointer; border: none; background: none; cursor: pointer;
padding: 6px 16px; border-radius: 8px; padding: 6px 16px; border-radius: 8px;
@@ -1,202 +0,0 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import AppListShell from '../_shared/AppListShell.vue';
import AppListItem from '../_shared/AppListItem.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const restBase = `${props.serverData.restUrl}aurora-admin/v1`;
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce };
const api = (path, opts = {}) =>
fetch(`${restBase}${path}`, { ...opts, headers }).then(async (r) => {
const body = await r.json();
if (!r.ok) throw new Error(body?.message || 'Request failed');
return body;
});
const tables = ref([]);
const search = ref('');
const selectedTable = ref(null);
const tableData = ref(null);
const tablePage = ref(1);
const loadingTable = ref(false);
const sqlMode = ref(false);
const sqlQuery = ref('SELECT * FROM ' + (props.serverData.tablePrefix || 'wp_') + 'options LIMIT 10');
const sqlResult = ref(null);
const sqlError = ref('');
const sqlRunning = ref(false);
onMounted(async () => {
tables.value = await api('/db-tables');
});
const filtered = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return tables.value;
return tables.value.filter((t) => t.name.toLowerCase().includes(q));
});
const formatSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
};
const loadTable = async (name, page = 1) => {
sqlMode.value = false;
selectedTable.value = name;
tablePage.value = page;
loadingTable.value = true;
try {
tableData.value = await api(`/db-tables/${encodeURIComponent(name)}?page=${page}`);
} finally {
loadingTable.value = false;
}
};
watch(tablePage, (p) => {
if (selectedTable.value) loadTable(selectedTable.value, p);
});
const openSqlEditor = () => {
sqlMode.value = true;
selectedTable.value = null;
};
const runQuery = async () => {
sqlRunning.value = true;
sqlError.value = '';
sqlResult.value = null;
try {
sqlResult.value = await api('/db-query', { method: 'POST', body: JSON.stringify({ query: sqlQuery.value }) });
} catch (e) {
sqlError.value = e.message;
} finally {
sqlRunning.value = false;
}
};
const hasSelection = computed(() => !!selectedTable.value || sqlMode.value);
</script>
<template>
<AppListShell
v-model:search="search"
title="Database Explorer"
search-placeholder="Search tables…"
:count-label="`${filtered.length} TABLES`"
detail-icon="dashicons-database"
detail-title="Table Details"
detail-text="Select a table from the list to view its structure and data."
:has-selection="hasSelection"
>
<template #header-actions>
<button type="button" @click="openSqlEditor">
<span class="dashicons dashicons-editor-code" /> SQL
</button>
</template>
<template #detail-empty-actions>
<button type="button" class="is-primary" style="margin-top: 14px;" @click="openSqlEditor">
Open SQL Editor
</button>
</template>
<template #list>
<AppListItem
v-for="t in filtered"
:key="t.name"
icon="dashicons-database"
:title="t.name"
:subtitle="`${t.rows} rows · ${formatSize(t.sizeBytes)}`"
:active="t.name === selectedTable"
@click="loadTable(t.name)"
/>
</template>
<template #detail>
<div v-if="sqlMode" class="db-sql">
<h2>SQL Editor</h2>
<p class="db-sql__hint">Only SELECT / SHOW / DESCRIBE / EXPLAIN queries are allowed.</p>
<textarea v-model="sqlQuery" rows="4" spellcheck="false" />
<div class="db-sql__actions">
<button type="button" class="is-primary" :disabled="sqlRunning" @click="runQuery">
{{ sqlRunning ? 'Running…' : 'Run query' }}
</button>
</div>
<p v-if="sqlError" class="db-sql__error">{{ sqlError }}</p>
<div v-if="sqlResult" class="db-table-wrap">
<p class="db-table__count">{{ sqlResult.count }} rows</p>
<table v-if="sqlResult.rows.length" class="db-table">
<thead>
<tr><th v-for="k in Object.keys(sqlResult.rows[0])" :key="k">{{ k }}</th></tr>
</thead>
<tbody>
<tr v-for="(row, i) in sqlResult.rows" :key="i">
<td v-for="(v, k) in row" :key="k">{{ v }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="selectedTable" class="db-table-detail">
<h2>{{ selectedTable }}</h2>
<div v-if="loadingTable">Loading…</div>
<template v-else-if="tableData">
<div class="db-table-wrap">
<table class="db-table">
<thead>
<tr><th v-for="c in tableData.columns" :key="c.name">{{ c.name }}</th></tr>
</thead>
<tbody>
<tr v-for="(row, i) in tableData.rows" :key="i">
<td v-for="c in tableData.columns" :key="c.name">{{ row[c.name] }}</td>
</tr>
</tbody>
</table>
</div>
<div class="db-table__pager">
<span>Page {{ tableData.page }} of {{ tableData.totalPages || 1 }} · {{ tableData.total }} rows</span>
<button type="button" :disabled="tablePage <= 1" @click="tablePage--">‹</button>
<button type="button" :disabled="tablePage >= tableData.totalPages" @click="tablePage++">›</button>
</div>
</template>
</div>
</template>
</AppListShell>
</template>
<style scoped>
.db-sql h2, .db-table-detail h2 { margin: 0 0 10px; font-size: 1.1rem; color: var(--aurora-text); }
.db-sql__hint { font-size: 0.75rem; color: var(--aurora-text-muted); margin: 0 0 10px; }
.db-sql textarea {
width: 100%; box-sizing: border-box; font-family: monospace; font-size: 0.8125rem;
padding: 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.db-sql__actions { display: flex; justify-content: flex-end; margin: 10px 0; }
.db-sql__actions button, .db-table__pager button {
border: none; border-radius: 8px; padding: 8px 16px; font-size: 0.8125rem; cursor: pointer;
background: var(--aurora-accent); color: #fff;
}
.db-sql__actions button:disabled { opacity: 0.5; cursor: default; }
.db-sql__error { color: #ef4444; font-size: 0.8125rem; }
.db-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; border: 1px solid var(--aurora-frame-border); border-radius: 8px; }
.db-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; }
.db-table th, .db-table td {
padding: 6px 10px; text-align: left; white-space: nowrap;
border-bottom: 1px solid var(--aurora-frame-border); color: var(--aurora-text);
}
.db-table th { background: var(--aurora-bg-subtle); position: sticky; top: 0; }
.db-table__count { font-size: 0.75rem; color: var(--aurora-text-muted); margin: 8px 0; }
.db-table__pager {
display: flex; align-items: center; gap: 8px; margin-top: 12px;
font-size: 0.75rem; color: var(--aurora-text-muted);
}
.db-table__pager button { background: var(--aurora-bg-subtle); color: var(--aurora-text); padding: 4px 10px; }
</style>
-649
View File
@@ -1,649 +0,0 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const path = ref('');
const breadcrumb = ref([]);
const items = ref([]);
const loading = ref(true);
const busy = ref(false);
const error = ref('');
const notice = ref('');
const search = ref('');
const fileInput = ref(null);
// Text editor state — a single file open at a time.
const editing = ref(null); // { path, name, content, isWritable }
const editorContent = ref('');
const savingEdit = ref(false);
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
const request = async (path, options = {}) => {
const response = await fetch(`${apiBase.value}${path}`, {
credentials: 'same-origin',
...options,
headers: {
'X-WP-Nonce': props.serverData.restNonce,
...(options.headers || {}),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.message || data.error || 'Request failed');
}
return data;
};
const load = async (targetPath = path.value) => {
loading.value = true;
error.value = '';
try {
const params = new URLSearchParams({ path: targetPath });
const data = await request(`/file-manager/list?${params.toString()}`);
path.value = data.path || '';
breadcrumb.value = data.breadcrumb || [];
items.value = data.items || [];
} catch (err) {
error.value = err.message || 'Could not load this folder';
} finally {
loading.value = false;
}
};
onMounted(() => load(''));
const filteredItems = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return items.value;
return items.value.filter((item) => item.name.toLowerCase().includes(q));
});
const openFolder = (item) => {
if (item.type !== 'dir') return;
load(item.path);
};
const goToCrumb = (crumbPath) => load(crumbPath);
const goToRoot = () => load('');
const newFolder = async () => {
const name = window.prompt('New folder name:');
if (!name) return;
busy.value = true;
error.value = '';
notice.value = '';
try {
const data = await request('/file-manager/folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path.value, name }),
});
notice.value = data.message || 'Folder created.';
await load();
} catch (err) {
error.value = err.message || 'Could not create folder';
} finally {
busy.value = false;
}
};
const newFile = async () => {
const name = window.prompt('New file name:');
if (!name) return;
busy.value = true;
error.value = '';
notice.value = '';
try {
const data = await request('/file-manager/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path.value, name }),
});
notice.value = data.message || 'File created.';
await load();
} catch (err) {
error.value = err.message || 'Could not create file';
} finally {
busy.value = false;
}
};
const triggerUpload = () => fileInput.value?.click();
const onFileSelected = async (event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
busy.value = true;
error.value = '';
notice.value = '';
try {
const formData = new FormData();
formData.append('path', path.value);
formData.append('file', file);
const data = await request('/file-manager/upload', {
method: 'POST',
body: formData,
});
notice.value = data.message || 'File uploaded.';
await load();
} catch (err) {
error.value = err.message || 'Upload failed';
} finally {
busy.value = false;
}
};
const renameItem = async (item) => {
const newName = window.prompt('Rename to:', item.name);
if (!newName || newName === item.name) return;
busy.value = true;
error.value = '';
notice.value = '';
try {
const data = await request('/file-manager/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: item.path, newName }),
});
notice.value = data.message || 'Renamed.';
await load();
} catch (err) {
error.value = err.message || 'Rename failed';
} finally {
busy.value = false;
}
};
const deleteItem = async (item) => {
const warning =
item.type === 'dir'
? `Permanently delete the folder "${item.name}" and everything inside it? This cannot be undone.`
: `Permanently delete "${item.name}"? This cannot be undone.`;
if (!window.confirm(warning)) return;
busy.value = true;
error.value = '';
notice.value = '';
try {
const data = await request('/file-manager/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: item.path }),
});
notice.value = data.message || 'Deleted.';
await load();
} catch (err) {
error.value = err.message || 'Delete failed';
} finally {
busy.value = false;
}
};
const downloadUrl = (item) => {
const params = new URLSearchParams({
path: item.path,
_wpnonce: props.serverData.restNonce,
});
return `${apiBase.value}/file-manager/download?${params.toString()}`;
};
const openEditor = async (item) => {
error.value = '';
try {
const params = new URLSearchParams({ path: item.path });
const data = await request(`/file-manager/read?${params.toString()}`);
editing.value = { path: item.path, name: item.name, isWritable: data.isWritable };
editorContent.value = data.content;
} catch (err) {
error.value = err.message || 'Could not open this file';
}
};
const closeEditor = () => {
editing.value = null;
editorContent.value = '';
};
const saveEditor = async () => {
if (!editing.value) return;
savingEdit.value = true;
error.value = '';
notice.value = '';
try {
const data = await request('/file-manager/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: editing.value.path, content: editorContent.value }),
});
notice.value = data.message || 'Saved.';
} catch (err) {
error.value = err.message || 'Save failed';
} finally {
savingEdit.value = false;
}
};
const icon = (item) => {
if (item.type === 'dir') return 'dashicons-portfolio';
const ext = item.extension;
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp'].includes(ext)) return 'dashicons-format-image';
if (['zip', 'gz', 'tar', 'rar', '7z'].includes(ext)) return 'dashicons-media-archive';
if (['mp3', 'wav', 'ogg'].includes(ext)) return 'dashicons-format-audio';
if (['mp4', 'mov', 'avi', 'webm'].includes(ext)) return 'dashicons-format-video';
if (['php', 'js', 'css', 'json', 'html', 'htm', 'xml', 'sql', 'yml', 'yaml'].includes(ext)) return 'dashicons-media-code';
return 'dashicons-media-default';
};
</script>
<template>
<main class="fm-page">
<header class="fm-page__header">
<div>
<p class="fm-page__eyebrow">Tools</p>
<h1>File Manager</h1>
</div>
<div class="fm-page__actions">
<button type="button" class="icon-button" title="New folder" :disabled="busy" @click="newFolder">
<span class="dashicons dashicons-plus-alt2" /> Folder
</button>
<button type="button" class="icon-button" title="New file" :disabled="busy" @click="newFile">
<span class="dashicons dashicons-plus-alt2" /> File
</button>
<button type="button" class="icon-button" title="Upload file" :disabled="busy" @click="triggerUpload">
<span class="dashicons dashicons-upload" /> Upload
</button>
<input ref="fileInput" type="file" hidden @change="onFileSelected" />
<button type="button" class="icon-button" title="Refresh" :disabled="loading" @click="load()">
<span class="dashicons dashicons-update" />
</button>
</div>
</header>
<nav class="fm-breadcrumb" aria-label="Folder path">
<button type="button" class="fm-breadcrumb__item" @click="goToRoot">WordPress root</button>
<template v-for="crumb in breadcrumb" :key="crumb.path">
<span class="fm-breadcrumb__sep">/</span>
<button type="button" class="fm-breadcrumb__item" @click="goToCrumb(crumb.path)">{{ crumb.label }}</button>
</template>
</nav>
<section class="fm-toolbar">
<label class="fm-search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Filter this folder..." />
</label>
</section>
<div v-if="notice" class="fm-notice fm-notice--success">{{ notice }}</div>
<div v-if="error" class="fm-notice fm-notice--error">{{ error }}</div>
<section class="fm-table" aria-label="Files and folders">
<div class="fm-table__head">
<div>Name</div>
<div>Size</div>
<div>Modified</div>
<div>Actions</div>
</div>
<div v-if="loading" class="fm-table__empty">Loading...</div>
<div v-else-if="!filteredItems.length" class="fm-table__empty">This folder is empty.</div>
<article v-for="item in filteredItems" v-else :key="item.name" class="fm-row">
<div
class="fm-row__name"
:class="{ 'fm-row__name--clickable': item.type === 'dir' }"
@click="openFolder(item)"
>
<span class="dashicons" :class="icon(item)" />
<span>{{ item.name }}</span>
</div>
<div class="fm-row__cell">{{ item.sizeLabel || '—' }}</div>
<div class="fm-row__cell">{{ item.modified }}</div>
<div class="fm-row__actions">
<button
v-if="item.isEditable"
type="button"
class="icon-button icon-button--inline"
title="Edit"
@click="openEditor(item)"
>
<span class="dashicons dashicons-edit" />
</button>
<a
v-if="item.type === 'file'"
class="icon-button icon-button--inline"
title="Download"
:href="downloadUrl(item)"
>
<span class="dashicons dashicons-download" />
</a>
<button type="button" class="icon-button icon-button--inline" title="Rename" @click="renameItem(item)">
<span class="dashicons dashicons-tag" />
</button>
<button
type="button"
class="icon-button icon-button--inline"
title="Delete"
:disabled="!item.isWritable"
@click="deleteItem(item)"
>
<span class="dashicons dashicons-trash" />
</button>
</div>
</article>
</section>
<!-- Text editor overlay -->
<div v-if="editing" class="fm-editor-backdrop" @click.self="closeEditor">
<div class="fm-editor">
<header class="fm-editor__header">
<strong>{{ editing.name }}</strong>
<span v-if="!editing.isWritable" class="fm-editor__readonly">Read-only (not writable on disk)</span>
<div class="fm-editor__actions">
<button
type="button"
class="button-primary"
:disabled="!editing.isWritable || savingEdit"
@click="saveEditor"
>
{{ savingEdit ? 'Saving...' : 'Save' }}
</button>
<button type="button" class="icon-button" @click="closeEditor">
<span class="dashicons dashicons-no-alt" />
</button>
</div>
</header>
<textarea
v-model="editorContent"
class="fm-editor__textarea"
spellcheck="false"
:readonly="!editing.isWritable"
/>
</div>
</div>
</main>
</template>
<style scoped>
.fm-page {
min-height: calc(100vh - 112px);
padding: 0;
color: var(--aurora-text);
background: transparent;
}
.fm-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 16px;
}
.fm-page__eyebrow {
margin: 0 0 7px;
color: var(--aurora-text-muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.fm-page h1 {
margin: 0;
color: var(--aurora-text);
font-size: 1.45rem;
line-height: 1.15;
}
.fm-page__actions {
display: flex;
align-items: center;
gap: 8px;
}
.icon-button {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 38px;
padding: 0 12px;
border: 1px solid var(--aurora-border);
border-radius: 8px;
color: var(--aurora-text);
background: var(--aurora-bg-subtle);
cursor: pointer;
text-decoration: none;
font-size: 0.8rem;
}
.icon-button:hover:not(:disabled) {
border-color: var(--aurora-text-muted);
}
.icon-button--inline {
width: 30px;
min-height: 30px;
padding: 0;
border: 0;
background: transparent;
color: var(--aurora-text-muted);
justify-content: center;
}
.icon-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.button-primary {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 14px;
border: 0;
border-radius: 8px;
background: var(--aurora-accent);
color: var(--aurora-accent-text);
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
}
.button-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.fm-breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
margin-bottom: 14px;
font-size: 0.8rem;
}
.fm-breadcrumb__item {
border: 0;
background: none;
color: var(--aurora-text-muted);
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
}
.fm-breadcrumb__item:hover {
color: var(--aurora-text);
background: var(--aurora-bg-subtle);
}
.fm-breadcrumb__sep {
color: var(--aurora-text-muted);
}
.fm-toolbar {
margin-bottom: 12px;
}
.fm-search {
width: min(320px, 100%);
min-height: 38px;
border: 1px solid var(--aurora-border);
border-radius: 8px;
background: var(--aurora-bg-subtle);
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
color: var(--aurora-text-muted);
}
.fm-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
box-shadow: none;
background: transparent;
color: var(--aurora-text);
font-size: 0.84rem;
}
.fm-notice {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 8px;
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
font-size: 0.78rem;
font-weight: 750;
}
.fm-table {
overflow: hidden;
border: 1px solid var(--aurora-frame-border);
border-radius: 8px;
background: var(--aurora-frame-bg);
}
.fm-table__head,
.fm-row {
display: grid;
grid-template-columns: minmax(200px, 1.8fr) 120px 180px 130px;
align-items: center;
}
.fm-table__head {
min-height: 44px;
border-bottom: 1px solid var(--aurora-border);
color: var(--aurora-text);
font-size: 0.78rem;
font-weight: 850;
}
.fm-table__head > div {
padding: 0 16px;
}
.fm-row {
min-height: 48px;
border-bottom: 1px solid var(--aurora-border);
}
.fm-row:hover {
background: var(--aurora-bg-subtle);
}
.fm-row__name {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
padding: 10px 16px;
color: var(--aurora-text);
font-size: 0.84rem;
font-weight: 650;
}
.fm-row__name span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fm-row__name .dashicons {
color: var(--aurora-text-muted);
flex-shrink: 0;
}
.fm-row__name--clickable {
cursor: pointer;
}
.fm-row__name--clickable:hover {
color: var(--aurora-accent);
}
.fm-row__cell {
min-width: 0;
padding: 10px 16px;
color: var(--aurora-text-muted);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fm-row__actions {
display: flex;
align-items: center;
gap: 4px;
padding: 10px 16px;
}
.fm-table__empty {
padding: 38px 18px;
color: var(--aurora-text-muted);
font-size: 0.85rem;
text-align: center;
}
.fm-editor-backdrop {
position: fixed;
inset: 0;
z-index: 100000;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 30px;
}
.fm-editor {
width: min(900px, 100%);
height: min(640px, 100%);
display: flex;
flex-direction: column;
border: 1px solid var(--aurora-frame-border);
border-radius: 10px;
background: var(--aurora-frame-bg);
overflow: hidden;
}
.fm-editor__header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--aurora-border);
color: var(--aurora-text);
}
.fm-editor__readonly {
font-size: 0.75rem;
color: var(--aurora-text-muted);
}
.fm-editor__actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.fm-editor__textarea {
flex: 1;
border: 0;
outline: 0;
resize: none;
padding: 16px;
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.82rem;
line-height: 1.5;
}
@media (max-width: 900px) {
.fm-table__head {
display: none;
}
.fm-row {
grid-template-columns: 1fr;
padding: 6px 0;
}
.fm-row__cell,
.fm-row__actions {
padding: 4px 16px;
}
}
</style>
+148
View File
@@ -0,0 +1,148 @@
<script setup>
import { ref } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const files = ref([]);
const dragging = ref(false);
const fileInput = ref(null);
const addFiles = (fileList) => {
files.value.push(
...Array.from(fileList).map((file) => ({
file,
name: file.name,
status: 'pending', // pending | uploading | done | error
message: '',
url: '',
}))
);
};
const onDrop = (e) => {
dragging.value = false;
addFiles(e.dataTransfer.files);
};
const onPick = (e) => {
addFiles(e.target.files);
e.target.value = '';
};
const uploadOne = async (entry) => {
entry.status = 'uploading';
try {
const res = await fetch(`${props.serverData.restUrl}wp/v2/media`, {
method: 'POST',
headers: {
'X-WP-Nonce': props.serverData.restNonce,
'Content-Disposition': `attachment; filename="${entry.file.name}"`,
'Content-Type': entry.file.type || 'application/octet-stream',
},
body: entry.file,
});
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Upload failed.');
entry.status = 'done';
entry.url = data.source_url || '';
} catch (e) {
entry.status = 'error';
entry.message = e.message || 'Upload failed.';
}
};
const uploadAll = () => {
files.value.filter((f) => f.status === 'pending').forEach(uploadOne);
};
const clearDone = () => {
files.value = files.value.filter((f) => f.status !== 'done');
};
</script>
<template>
<main class="media-upload-page">
<header class="media-upload-page__header">
<div>
<p class="media-upload-page__eyebrow">Media</p>
<h1>Add Media File</h1>
</div>
<a class="button" :href="serverData.libraryUrl">Back to Media Library</a>
</header>
<div
class="media-upload-drop"
:class="{ 'media-upload-drop--active': dragging }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
@click="fileInput.click()"
>
<span class="dashicons dashicons-upload" />
<p>Drop files here, or click to select files</p>
<input ref="fileInput" type="file" multiple hidden @change="onPick" />
</div>
<section v-if="files.length" class="media-upload-list">
<div class="media-upload-list__actions">
<button type="button" class="button-primary" @click="uploadAll">Upload All</button>
<button type="button" class="button" @click="clearDone">Clear Completed</button>
</div>
<div v-for="entry in files" :key="entry.name + entry.file.lastModified" class="media-upload-item">
<span class="media-upload-item__name">{{ entry.name }}</span>
<span class="media-upload-item__status" :class="`media-upload-item__status--${entry.status}`">
<template v-if="entry.status === 'pending'">Ready</template>
<template v-else-if="entry.status === 'uploading'">Uploading…</template>
<template v-else-if="entry.status === 'done'">
Uploaded — <a :href="entry.url" target="_blank" rel="noopener">View</a>
</template>
<template v-else>{{ entry.message }}</template>
</span>
</div>
</section>
</main>
</template>
<style scoped>
.media-upload-page { max-width: 720px; margin: 0 auto; }
.media-upload-page__header {
display: flex; align-items: flex-start; justify-content: space-between;
margin-bottom: 20px;
}
.media-upload-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.media-upload-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.media-upload-drop {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px; padding: 48px 24px; text-align: center; cursor: pointer;
border: 2px dashed var(--aurora-border);
border-radius: 14px;
color: var(--aurora-text-muted);
transition: border-color 0.15s, color 0.15s;
}
.media-upload-drop .dashicons { font-size: 32px; width: 32px; height: 32px; }
.media-upload-drop--active,
.media-upload-drop:hover { border-color: var(--aurora-accent); color: var(--aurora-text); }
.media-upload-list { margin-top: 20px; }
.media-upload-list__actions { display: flex; gap: 8px; margin-bottom: 12px; }
.media-upload-item {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-radius: 8px;
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
margin-bottom: 6px; font-size: 0.875rem;
}
.media-upload-item__name { color: var(--aurora-text); }
.media-upload-item__status { color: var(--aurora-text-muted); }
.media-upload-item__status--done { color: #22c55e; }
.media-upload-item__status--error { color: #ef4444; }
.media-upload-item__status a { color: inherit; text-decoration: underline; }
</style>
+386 -67
View File
@@ -1,10 +1,14 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import MediaFolderTree from './MediaFolderTree.vue';
const props = defineProps({ const props = defineProps({
serverData: { type: Object, required: true }, serverData: { type: Object, required: true },
}); });
// Media Folders is a core feature — always on, no gating.
const foldersAvailable = true;
const items = ref([]); const items = ref([]);
const stats = ref({ all: 0, image: 0, audio: 0, video: 0, application: 0, unattached: 0 }); const stats = ref({ all: 0, image: 0, audio: 0, video: 0, application: 0, unattached: 0 });
const pagination = ref({ page: 1, perPage: 24, total: 0, totalPages: 1 }); const pagination = ref({ page: 1, perPage: 24, total: 0, totalPages: 1 });
@@ -15,6 +19,16 @@ const notice = ref('');
const search = ref(''); const search = ref('');
const type = ref('all'); const type = ref('all');
// Media Folders (paid) state — folderId 0 = All Media, -1 = Uncategorized,
// matching MediaFolders::ALL_MEDIA / ::UNCATEGORIZED on the PHP side.
const folderId = ref(0);
const folderTree = ref([]);
const allMediaCount = ref(0);
const uncategorizedCount = ref(0);
const foldersLoading = ref(false);
const expanded = reactive({});
const moveMenuFor = ref(0);
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`); const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
const request = async (path, options = {}) => { const request = async (path, options = {}) => {
@@ -44,6 +58,7 @@ const load = async () => {
page: String(pagination.value.page), page: String(pagination.value.page),
perPage: String(pagination.value.perPage), perPage: String(pagination.value.perPage),
}); });
params.set('folderId', String(folderId.value));
const data = await request(`/media?${params.toString()}`); const data = await request(`/media?${params.toString()}`);
items.value = data.items || []; items.value = data.items || [];
stats.value = data.stats || stats.value; stats.value = data.stats || stats.value;
@@ -55,7 +70,99 @@ const load = async () => {
} }
}; };
onMounted(load); const loadFolders = async () => {
foldersLoading.value = true;
try {
const data = await request('/media-folders');
folderTree.value = data.tree || [];
allMediaCount.value = data.allMediaCount || 0;
uncategorizedCount.value = data.uncategorizedCount || 0;
} catch (err) {
error.value = err.message || 'Could not load folders';
} finally {
foldersLoading.value = false;
}
};
const flattenFolders = (nodes, depth = 0, out = []) => {
for (const node of nodes) {
out.push({ id: node.id, name: node.name, depth });
if (node.children && node.children.length) flattenFolders(node.children, depth + 1, out);
}
return out;
};
const flatFolders = computed(() => flattenFolders(folderTree.value));
const selectFolder = (id) => {
folderId.value = id;
pagination.value.page = 1;
load();
};
const toggleFolder = (id) => {
expanded[id] = !expanded[id];
};
const createFolder = async (parent) => {
const name = window.prompt('New folder name:');
if (!name) return;
try {
await request('/media-folders', { method: 'POST', body: JSON.stringify({ name, parent }) });
if (parent) expanded[parent] = true;
await loadFolders();
} catch (err) {
error.value = err.message || 'Could not create folder';
}
};
const renameFolder = async (node) => {
const name = window.prompt('Rename folder:', node.name);
if (!name || name === node.name) return;
try {
await request(`/media-folders/${node.id}`, { method: 'POST', body: JSON.stringify({ name }) });
await loadFolders();
} catch (err) {
error.value = err.message || 'Could not rename folder';
}
};
const deleteFolder = async (node) => {
if (!window.confirm(`Delete "${node.name}"? Its media will become uncategorized.`)) return;
try {
await request(`/media-folders/${node.id}`, { method: 'DELETE' });
if (folderId.value === node.id) selectFolder(0);
else await loadFolders();
} catch (err) {
error.value = err.message || 'Could not delete folder';
}
};
const reparentFolder = async ({ id, parent }) => {
try {
await request(`/media-folders/${id}`, { method: 'POST', body: JSON.stringify({ parent }) });
await loadFolders();
} catch (err) {
error.value = err.message || 'Could not move folder';
}
};
const assignToFolder = async (folderIdTarget, attachmentIds) => {
try {
await request('/media-folders/assign', {
method: 'POST',
body: JSON.stringify({ folderId: folderIdTarget, attachmentIds }),
});
moveMenuFor.value = 0;
await Promise.all([load(), loadFolders()]);
} catch (err) {
error.value = err.message || 'Could not move media';
}
};
onMounted(() => {
load();
loadFolders();
});
let searchTimer = 0; let searchTimer = 0;
watch(search, () => { watch(search, () => {
@@ -136,77 +243,168 @@ const typeIcon = (mimeType) => {
</div> </div>
</header> </header>
<section class="media-page__toolbar" aria-label="Media filters and search"> <div class="media-page__body">
<div class="media-page__filters"> <aside class="media-page__sidebar">
<button <div class="mf-pinned">
v-for="filter in filters"
:key="filter.key"
type="button"
class="filter-pill"
:class="{ 'is-active': type === filter.key }"
@click="setType(filter.key)"
>
<span>{{ filter.label }}</span>
<strong>{{ filter.count }}</strong>
</button>
</div>
<label class="media-page__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Search media..." />
</label>
</section>
<div v-if="notice" class="media-notice media-notice--success">{{ notice }}</div>
<div v-if="error" class="media-notice media-notice--error">{{ error }}</div>
<div v-if="loading" class="media-grid__empty">Loading media...</div>
<div v-else-if="!items.length" class="media-grid__empty">No media items match your filters.</div>
<section v-else class="media-grid" aria-label="Media items">
<article v-for="item in items" :key="item.id" class="media-card">
<div class="media-card__preview">
<img v-if="item.isImage && item.thumbnailUrl" :src="item.thumbnailUrl" :alt="item.title" />
<span v-else class="dashicons media-card__icon" :class="typeIcon(item.mimeType)" />
<div class="media-card__overlay">
<a class="icon-button icon-button--inline" title="Edit" :href="item.editUrl">
<span class="dashicons dashicons-edit" />
</a>
<a
class="icon-button icon-button--inline"
title="View file"
:href="item.fileUrl"
target="_blank"
rel="noopener"
>
<span class="dashicons dashicons-visibility" />
</a>
<button <button
v-if="item.canDelete"
type="button" type="button"
class="icon-button icon-button--inline" class="mf-pinned__row"
title="Delete permanently" :class="{ 'mf-pinned__row--active': folderId === 0 }"
:disabled="busyId === item.id" @dragover.prevent
@click="deleteItem(item)" @drop.prevent="
(e) => {
const p = e.dataTransfer.getData('text/plain');
if (p.startsWith('aurora-attachments:')) assignToFolder(0, p.split(':')[1].split(',').map(Number));
}
"
@click="selectFolder(0)"
> >
<span class="dashicons dashicons-trash" /> <span class="dashicons dashicons-admin-media" />
<span>All Media</span>
<span class="mf-pinned__count">{{ allMediaCount }}</span>
</button>
<button
type="button"
class="mf-pinned__row"
:class="{ 'mf-pinned__row--active': folderId === -1 }"
@dragover.prevent
@drop.prevent="
(e) => {
const p = e.dataTransfer.getData('text/plain');
if (p.startsWith('aurora-attachments:')) assignToFolder(-1, p.split(':')[1].split(',').map(Number));
}
"
@click="selectFolder(-1)"
>
<span class="dashicons dashicons-editor-help" />
<span>Uncategorized</span>
<span class="mf-pinned__count">{{ uncategorizedCount }}</span>
</button> </button>
</div> </div>
</div>
<div class="media-card__meta">
<strong :title="item.title">{{ item.title }}</strong>
<span>{{ item.typeLabel }}<template v-if="item.dimensions"> · {{ item.dimensions }}</template></span>
<span v-if="item.attachedToTitle">
Attached to
<a v-if="item.attachedToEditUrl" :href="item.attachedToEditUrl">{{ item.attachedToTitle }}</a>
<template v-else>{{ item.attachedToTitle }}</template>
</span>
<span v-else class="media-card__unattached">Unattached</span>
<span>{{ item.date }}<template v-if="item.fileSize"> · {{ item.fileSize }}</template></span>
</div>
</article>
</section>
<footer v-if="!loading && items.length" class="media-page__footer"> <div class="mf-sidebar__header">
<span>Folders</span>
<button type="button" class="icon-button icon-button--tiny" title="New folder" @click="createFolder(0)">
<span class="dashicons dashicons-plus-alt2" />
</button>
</div>
<p v-if="foldersLoading" class="mf-sidebar__loading">Loading folders…</p>
<p v-else-if="!folderTree.length" class="mf-sidebar__empty">No folders yet.</p>
<MediaFolderTree
v-else
:nodes="folderTree"
:active-folder-id="folderId"
:expanded="expanded"
@select="selectFolder"
@toggle="toggleFolder"
@create-child="createFolder"
@rename="renameFolder"
@delete="deleteFolder"
@reparent="reparentFolder"
@assign="({ folderId: fid, attachmentIds }) => assignToFolder(fid, attachmentIds)"
/>
</aside>
<div class="media-page__main">
<section class="media-page__toolbar" aria-label="Media filters and search">
<div class="media-page__filters">
<button
v-for="filter in filters"
:key="filter.key"
type="button"
class="filter-pill"
:class="{ 'is-active': type === filter.key }"
@click="setType(filter.key)"
>
<span>{{ filter.label }}</span>
<strong>{{ filter.count }}</strong>
</button>
</div>
<label class="media-page__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Search media..." />
</label>
</section>
<div v-if="notice" class="media-notice media-notice--success">{{ notice }}</div>
<div v-if="error" class="media-notice media-notice--error">{{ error }}</div>
<div v-if="loading" class="media-grid__empty">Loading media...</div>
<div v-else-if="!items.length" class="media-grid__empty">No media items match your filters.</div>
<section v-else class="media-grid" aria-label="Media items">
<article
v-for="item in items"
:key="item.id"
class="media-card"
:draggable="foldersAvailable"
@dragstart="foldersAvailable && $event.dataTransfer.setData('text/plain', `aurora-attachments:${item.id}`)"
>
<div class="media-card__preview">
<img v-if="item.isImage && item.thumbnailUrl" :src="item.thumbnailUrl" :alt="item.title" />
<span v-else class="dashicons media-card__icon" :class="typeIcon(item.mimeType)" />
<div class="media-card__overlay">
<a class="icon-button icon-button--inline" title="Edit" :href="item.editUrl">
<span class="dashicons dashicons-edit" />
</a>
<a
class="icon-button icon-button--inline"
title="View file"
:href="item.fileUrl"
target="_blank"
rel="noopener"
>
<span class="dashicons dashicons-visibility" />
</a>
<button
v-if="foldersAvailable"
type="button"
class="icon-button icon-button--inline"
title="Move to folder"
@click.stop="moveMenuFor = moveMenuFor === item.id ? 0 : item.id"
>
<span class="dashicons dashicons-portfolio" />
</button>
<button
v-if="item.canDelete"
type="button"
class="icon-button icon-button--inline"
title="Delete permanently"
:disabled="busyId === item.id"
@click="deleteItem(item)"
>
<span class="dashicons dashicons-trash" />
</button>
</div>
<div v-if="moveMenuFor === item.id" class="media-card__move-menu" @click.stop>
<button type="button" @click="assignToFolder(0, [item.id])">Uncategorized</button>
<button
v-for="f in flatFolders"
:key="f.id"
type="button"
:style="{ paddingLeft: 10 + f.depth * 14 + 'px' }"
@click="assignToFolder(f.id, [item.id])"
>
{{ f.name }}
</button>
</div>
</div>
<div class="media-card__meta">
<strong :title="item.title">{{ item.title }}</strong>
<span>{{ item.typeLabel }}<template v-if="item.dimensions"> · {{ item.dimensions }}</template></span>
<span v-if="item.attachedToTitle">
Attached to
<a v-if="item.attachedToEditUrl" :href="item.attachedToEditUrl">{{ item.attachedToTitle }}</a>
<template v-else>{{ item.attachedToTitle }}</template>
</span>
<span v-else class="media-card__unattached">Unattached</span>
<span>{{ item.date }}<template v-if="item.fileSize"> · {{ item.fileSize }}</template></span>
</div>
</article>
</section>
<footer v-if="!loading && items.length" class="media-page__footer">
<div>Page Size: {{ pagination.perPage }}</div> <div>Page Size: {{ pagination.perPage }}</div>
<div>{{ rowRange }} of {{ pagination.total }}</div> <div>{{ rowRange }} of {{ pagination.total }}</div>
<div class="media-page__pager"> <div class="media-page__pager">
@@ -233,6 +431,8 @@ const typeIcon = (mimeType) => {
</button> </button>
</div> </div>
</footer> </footer>
</div>
</div>
</main> </main>
</template> </template>
@@ -515,5 +715,124 @@ const typeIcon = (mimeType) => {
.media-page__footer { .media-page__footer {
gap: 10px; gap: 10px;
} }
.media-page__body {
flex-direction: column;
}
.media-page__sidebar {
width: 100%;
}
}
.media-page__body {
display: flex;
align-items: flex-start;
gap: 20px;
}
.media-page__sidebar {
flex-shrink: 0;
width: 220px;
padding: 12px;
border: 1px solid var(--aurora-frame-border);
border-radius: 10px;
background: var(--aurora-frame-bg);
}
.media-page__main {
flex: 1;
min-width: 0;
}
.mf-pinned {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--aurora-border);
}
.mf-pinned__row {
display: flex;
align-items: center;
gap: 8px;
border: none;
border-radius: 6px;
background: none;
padding: 6px 6px;
color: var(--aurora-text);
font-size: 0.8125rem;
cursor: pointer;
text-align: left;
}
.mf-pinned__row:hover {
background: var(--aurora-bg-subtle);
}
.mf-pinned__row--active {
background: var(--aurora-accent-soft);
}
.mf-pinned__row .dashicons {
font-size: 15px;
width: 15px;
height: 15px;
color: var(--aurora-text-muted);
}
.mf-pinned__row span:nth-child(2) {
flex: 1;
}
.mf-pinned__count {
font-size: 0.7rem;
color: var(--aurora-text-muted);
}
.mf-sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
color: var(--aurora-text-muted);
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
}
.icon-button--tiny {
width: 22px;
min-height: 22px;
border: none;
background: none;
color: var(--aurora-text-muted);
}
.icon-button--tiny:hover {
color: var(--aurora-text);
}
.mf-sidebar__loading,
.mf-sidebar__empty {
margin: 0;
color: var(--aurora-text-muted);
font-size: 0.75rem;
}
.media-card__move-menu {
position: absolute;
z-index: 10;
right: 8px;
bottom: 8px;
max-height: 160px;
min-width: 150px;
overflow-y: auto;
border: 1px solid var(--aurora-border);
border-radius: 8px;
background: var(--aurora-frame-bg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
padding: 4px;
}
.media-card__move-menu button {
display: block;
width: 100%;
padding: 6px 10px;
border: none;
background: none;
border-radius: 6px;
color: var(--aurora-text);
font-size: 0.78rem;
text-align: left;
cursor: pointer;
}
.media-card__move-menu button:hover {
background: var(--aurora-bg-subtle);
} }
</style> </style>
+193
View File
@@ -0,0 +1,193 @@
<script setup>
import { ref } from 'vue';
const props = defineProps({
nodes: { type: Array, required: true },
activeFolderId: { type: Number, required: true },
expanded: { type: Object, required: true },
});
const emit = defineEmits(['select', 'toggle', 'create-child', 'rename', 'delete', 'reparent', 'assign']);
// Drag-and-drop reparenting (folder onto folder) and media-into-folder both
// go through the same 'text/plain' payload, prefixed so drop handlers can
// tell them apart — custom MIME types are unreliable to read during
// dragover in some browsers, plain text is not.
const dragOverId = ref(0);
const onDragStart = (event, node) => {
event.dataTransfer.setData('text/plain', `aurora-folder:${node.id}`);
event.dataTransfer.effectAllowed = 'move';
};
const onDragOver = (event, node) => {
event.preventDefault();
dragOverId.value = node.id;
};
const onDragLeave = (node) => {
if (dragOverId.value === node.id) dragOverId.value = 0;
};
const onDrop = (event, node) => {
event.preventDefault();
dragOverId.value = 0;
const payload = event.dataTransfer.getData('text/plain');
if (!payload) return;
if (payload.startsWith('aurora-folder:')) {
const draggedId = Number(payload.split(':')[1]);
if (draggedId && draggedId !== node.id) {
emit('reparent', { id: draggedId, parent: node.id });
}
} else if (payload.startsWith('aurora-attachments:')) {
const ids = payload
.split(':')[1]
.split(',')
.map(Number)
.filter(Boolean);
if (ids.length) {
emit('assign', { folderId: node.id, attachmentIds: ids });
}
}
};
const contextMenu = ref({ open: false, x: 0, y: 0, node: null });
const openMenu = (event, node) => {
contextMenu.value = { open: true, x: event.clientX, y: event.clientY, node };
setTimeout(() => document.addEventListener('click', closeMenu), 0);
};
const closeMenu = () => {
contextMenu.value.open = false;
document.removeEventListener('click', closeMenu);
};
const runFromMenu = (fn) => {
closeMenu();
fn();
};
</script>
<template>
<ul class="mf-tree">
<li v-for="node in nodes" :key="node.id">
<div
class="mf-tree__row"
:class="{ 'mf-tree__row--active': node.id === activeFolderId, 'mf-tree__row--dragover': dragOverId === node.id }"
draggable="true"
@dragstart="onDragStart($event, node)"
@dragover="onDragOver($event, node)"
@dragleave="onDragLeave(node)"
@drop="onDrop($event, node)"
@contextmenu.prevent="openMenu($event, node)"
>
<button
v-if="node.children && node.children.length"
type="button"
class="mf-tree__toggle"
@click="emit('toggle', node.id)"
>
<span class="dashicons dashicons-arrow-right-alt2" :class="{ 'mf-tree__toggle-icon--open': expanded[node.id] }" />
</button>
<span v-else class="mf-tree__toggle-spacer" />
<button type="button" class="mf-tree__label" @click="emit('select', node.id)">
<span class="dashicons dashicons-portfolio" :style="node.color ? { color: node.color } : {}" />
<span class="mf-tree__name">{{ node.name }}</span>
<span class="mf-tree__count">{{ node.count }}</span>
</button>
</div>
<MediaFolderTree
v-if="expanded[node.id] && node.children && node.children.length"
:nodes="node.children"
:active-folder-id="activeFolderId"
:expanded="expanded"
@select="emit('select', $event)"
@toggle="emit('toggle', $event)"
@create-child="emit('create-child', $event)"
@rename="emit('rename', $event)"
@delete="emit('delete', $event)"
@reparent="emit('reparent', $event)"
@assign="emit('assign', $event)"
/>
</li>
</ul>
<Teleport to="body">
<ul v-if="contextMenu.open" class="mf-context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }" @contextmenu.prevent>
<li><button type="button" @click="runFromMenu(() => emit('create-child', contextMenu.node.id))"><span class="dashicons dashicons-plus" /> New Subfolder</button></li>
<li><button type="button" @click="runFromMenu(() => emit('rename', contextMenu.node))"><span class="dashicons dashicons-edit" /> Rename</button></li>
<li class="mf-context-menu__sep" />
<li><button type="button" class="mf-context-menu__danger" @click="runFromMenu(() => emit('delete', contextMenu.node))"><span class="dashicons dashicons-trash" /> Delete</button></li>
</ul>
</Teleport>
</template>
<style scoped>
.mf-tree { list-style: none; margin: 0; padding-left: 14px; }
.mf-tree:first-of-type { padding-left: 0; }
.mf-tree__row {
display: flex; align-items: center; gap: 2px; border-radius: 6px;
}
.mf-tree__row:hover { background: var(--aurora-bg-subtle); }
.mf-tree__row--active { background: var(--aurora-accent-soft); }
.mf-tree__row--dragover { outline: 2px dashed var(--aurora-accent); outline-offset: -2px; }
.mf-tree__toggle,
.mf-tree__toggle-spacer {
width: 18px; height: 24px; flex-shrink: 0;
}
.mf-tree__toggle {
border: none; background: none; cursor: pointer;
color: var(--aurora-text-muted); display: inline-flex; align-items: center; justify-content: center;
}
.mf-tree__toggle .dashicons { font-size: 14px; width: 14px; height: 14px; transition: transform 0.15s; }
.mf-tree__toggle-icon--open { transform: rotate(90deg); }
.mf-tree__label {
flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px;
border: none; background: none; cursor: pointer; text-align: left;
padding: 3px 4px; color: var(--aurora-text); font-size: 0.8125rem;
}
.mf-tree__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.mf-tree__label .dashicons:first-child { color: var(--aurora-text-muted); flex-shrink: 0; font-size: 15px; width: 15px; height: 15px; }
.mf-tree__count { font-size: 0.7rem; color: var(--aurora-text-muted); flex-shrink: 0; }
</style>
<style>
.mf-context-menu {
position: fixed;
z-index: 100000;
min-width: 170px;
margin: 0;
padding: 4px;
list-style: none;
border: 1px solid var(--aurora-border);
border-radius: 8px;
background: var(--aurora-frame-bg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.mf-context-menu button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
background: none;
border-radius: 6px;
color: var(--aurora-text);
font-size: 0.8rem;
text-align: left;
cursor: pointer;
}
.mf-context-menu button:hover { background: var(--aurora-bg-subtle); }
.mf-context-menu button .dashicons { font-size: 16px; width: 16px; height: 16px; color: var(--aurora-text-muted); }
.mf-context-menu__danger { color: #ef4444 !important; }
.mf-context-menu__danger .dashicons { color: #ef4444 !important; }
.mf-context-menu__sep {
height: 1px;
margin: 4px 6px;
background: var(--aurora-border);
}
</style>
@@ -0,0 +1,215 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import SettingsField from '@/pages/settings/components/SettingsField.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
// Permalinks (rewrite rules) and Discussion (core's REST settings schema
// only exposes 2 of its ~15 real options — see DiscussionData.php) both
// have their own aurora-admin/v1 routes instead of going through
// wp/v2/settings like every other native-settings screen.
const isPermalinks = props.serverData.screen === 'permalinks';
const isDiscussion = props.serverData.screen === 'discussion';
const values = reactive({ ...(props.serverData.values || {}) });
// Mirrors options-permalink.php's own preset list — the actual reason
// people visit this screen; the "Custom Structure" text field alone
// (this screen's only original field) skipped the part of the native
// page virtually everyone actually uses instead of hand-writing tags.
const permalinkPresets = [
{ key: 'plain', label: 'Plain', structure: '', example: '?p=123' },
{
key: 'day',
label: 'Day and name',
structure: '/%year%/%monthnum%/%day%/%postname%/',
example: '/2026/01/28/sample-post/',
},
{
key: 'month',
label: 'Month and name',
structure: '/%year%/%monthnum%/%postname%/',
example: '/2026/01/sample-post/',
},
{
key: 'numeric',
label: 'Numeric',
structure: '/archives/%post_id%',
example: '/archives/123',
},
{
key: 'postname',
label: 'Post name',
structure: '/%postname%/',
example: '/sample-post/',
},
{ key: 'custom', label: 'Custom Structure', structure: null, example: '' },
];
// Fields can declare showIf: { key, equals } to only appear once another
// field's current value matches — e.g. the front-page/posts-page pickers
// only make sense once "Homepage Displays" is set to "A static page",
// matching the native Reading screen's own conditional reveal.
const visibleFields = computed(() =>
props.serverData.fields.filter((f) => !f.showIf || values[f.showIf.key] === f.showIf.equals)
);
const selectedPreset = computed(() => {
const match = permalinkPresets.find(
(p) => p.structure !== null && p.structure === (values.permalink_structure || '')
);
return match ? match.key : 'custom';
});
const choosePreset = (preset) => {
if (preset.key === 'custom') {
// Leave whatever custom value is already there (or empty) — this
// option just reveals the text field instead of assigning a value.
if (selectedPreset.value !== 'custom') values.permalink_structure = '';
return;
}
values.permalink_structure = preset.structure;
};
const saving = ref(false);
const savedMsg = ref('');
const save = async () => {
saving.value = true;
savedMsg.value = '';
try {
const url = isPermalinks
? `${props.serverData.restUrl}aurora-admin/v1/permalinks`
: isDiscussion
? `${props.serverData.restUrl}aurora-admin/v1/discussion-settings`
: `${props.serverData.restUrl}wp/v2/settings`;
const body = isPermalinks
? { structure: values.permalink_structure || '' }
: isDiscussion
? {
values: Object.fromEntries(
props.serverData.fields.filter((f) => !f.readonly).map((f) => [f.key, values[f.key]])
),
}
: Object.fromEntries(
props.serverData.fields
.filter((f) => !f.readonly)
.map((f) => [f.key, values[f.key]])
);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error();
savedMsg.value = 'Saved';
setTimeout(() => (savedMsg.value = ''), 2500);
} catch {
savedMsg.value = 'Could not save';
} finally {
saving.value = false;
}
};
</script>
<template>
<div class="native-settings">
<header class="native-settings__head">
<div>
<h2 class="native-settings__title">{{ serverData.title }}</h2>
<p class="native-settings__sub">{{ serverData.subtitle }}</p>
</div>
<div class="native-settings__save-wrap">
<span v-if="savedMsg" class="native-settings__saved">{{ savedMsg }}</span>
<button type="button" class="native-settings__save" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</header>
<div v-if="isPermalinks" class="native-settings__presets">
<label v-for="preset in permalinkPresets" :key="preset.key" class="native-settings__preset">
<input
type="radio"
name="permalink-preset"
:checked="selectedPreset === preset.key"
@change="choosePreset(preset)"
/>
<span class="native-settings__preset-label">{{ preset.label }}</span>
<code v-if="preset.example" class="native-settings__preset-example">{{ preset.example }}</code>
</label>
</div>
<div class="native-settings__fields">
<SettingsField
v-for="f in visibleFields"
:key="f.key"
:field="f"
v-model="values[f.key]"
/>
</div>
</div>
</template>
<style scoped>
.native-settings {
max-width: 720px;
margin: 0 auto;
}
.native-settings__head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 24px;
padding-bottom: 22px;
margin-bottom: 4px;
border-bottom: 1px solid var(--aurora-frame-border);
}
.native-settings__title { font-size: 1.6rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
.native-settings__sub { margin: 6px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
.native-settings__save-wrap { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.native-settings__saved { font-size: 0.8125rem; color: var(--aurora-text-muted); }
.native-settings__save {
background: var(--aurora-accent); color: #fff; border: none;
border-radius: 9px; padding: 9px 20px; font-size: 0.875rem; font-weight: 500; cursor: pointer;
}
.native-settings__save:disabled { opacity: 0.6; cursor: default; }
.native-settings__fields > :last-child { border-bottom: none; }
.native-settings__presets {
display: flex;
flex-direction: column;
gap: 4px;
padding: 18px 0;
border-bottom: 1px solid var(--aurora-frame-border);
}
.native-settings__preset {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
cursor: pointer;
font-size: 0.875rem;
color: var(--aurora-text);
}
.native-settings__preset input[type='radio'] {
accent-color: var(--aurora-accent);
width: 15px;
height: 15px;
flex-shrink: 0;
}
.native-settings__preset-label {
min-width: 150px;
flex-shrink: 0;
font-weight: 500;
}
.native-settings__preset-example {
color: var(--aurora-text-muted);
font-size: 0.8125rem;
background: var(--aurora-bg-subtle);
padding: 2px 8px;
border-radius: 5px;
}
</style>
+437
View File
@@ -0,0 +1,437 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path) => `${props.serverData.restUrl}aurora-admin/v1${path}`;
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
const menus = ref([]);
const activeMenuId = ref(null);
const items = ref([]);
const locations = ref([]);
const pickers = ref({ pages: [], posts: [], categories: [] });
const loading = ref(false);
const error = ref('');
const notice = ref('');
const activeMenu = computed(() => menus.value.find((m) => m.id === activeMenuId.value));
// Flat list, ordered so parents appear before children and children are
// visually indented — simpler than a tree render, same end result.
const orderedItems = computed(() => {
const byParent = {};
items.value.forEach((i) => {
(byParent[i.parent] ||= []).push(i);
});
Object.values(byParent).forEach((list) => list.sort((a, b) => a.order - b.order));
const walk = (parentId, depth) => {
const out = [];
(byParent[parentId] || []).forEach((item) => {
out.push({ ...item, depth });
out.push(...walk(item.id, depth + 1));
});
return out;
};
return walk(0, 0);
});
const loadMenus = async () => {
try {
const res = await fetch(api('/nav-menus'), { headers: headers() });
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not load menus.');
menus.value = data;
if (!activeMenuId.value && menus.value.length) {
activeMenuId.value = menus.value[0].id;
}
} catch (e) {
error.value = e.message || 'Could not load menus.';
}
};
const loadItems = async () => {
if (!activeMenuId.value) {
items.value = [];
return;
}
loading.value = true;
try {
const res = await fetch(api(`/nav-menus/${activeMenuId.value}/items`), { headers: headers() });
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not load menu items.');
items.value = data;
} catch (e) {
error.value = e.message || 'Could not load menu items.';
} finally {
loading.value = false;
}
};
const loadLocations = async () => {
try {
const res = await fetch(api('/nav-menus/locations'), { headers: headers() });
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not load locations.');
locations.value = data;
} catch (e) {
error.value = e.message || 'Could not load locations.';
}
};
const loadPickers = async () => {
try {
const res = await fetch(api('/nav-menus/pickers'), { headers: headers() });
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not load pickers.');
pickers.value = data;
} catch (e) {
error.value = e.message || 'Could not load pickers.';
}
};
onMounted(async () => {
await Promise.all([loadMenus(), loadLocations(), loadPickers()]);
await loadItems();
});
const selectMenu = async (id) => {
activeMenuId.value = id;
await loadItems();
};
// Create menu -----------------------------------------------------------
const newMenuName = ref('');
const creatingMenu = ref(false);
const createMenu = async () => {
if (!newMenuName.value.trim()) return;
creatingMenu.value = true;
try {
const res = await fetch(api('/nav-menus'), {
method: 'POST',
headers: headers(),
body: JSON.stringify({ name: newMenuName.value.trim() }),
});
const menu = await res.json();
if (!res.ok) throw new Error(menu?.message || 'Could not create menu.');
menus.value.push(menu);
newMenuName.value = '';
await selectMenu(menu.id);
} catch (e) {
error.value = e.message;
} finally {
creatingMenu.value = false;
}
};
const deleteMenu = async () => {
if (!activeMenu.value) return;
if (!confirm(`Delete menu "${activeMenu.value.name}"? This can't be undone.`)) return;
await fetch(api(`/nav-menus/${activeMenuId.value}`), { method: 'DELETE', headers: headers() });
menus.value = menus.value.filter((m) => m.id !== activeMenuId.value);
activeMenuId.value = menus.value[0]?.id || null;
await loadItems();
};
// Add items ---------------------------------------------------------------
const addTab = ref('pages');
const customLink = ref({ title: '', url: 'https://' });
const selectedIds = ref([]);
const toggleSelected = (id) => {
const i = selectedIds.value.indexOf(id);
if (i === -1) selectedIds.value.push(id);
else selectedIds.value.splice(i, 1);
};
const addSelectedItems = async () => {
if (!activeMenuId.value) return;
const list = pickers.value[addTab.value] || [];
const toAdd = list.filter((p) => selectedIds.value.includes(p.id));
for (const obj of toAdd) {
const type = addTab.value === 'pages' ? 'page' : addTab.value === 'posts' ? 'post' : 'category';
await fetch(api(`/nav-menus/${activeMenuId.value}/items`), {
method: 'POST',
headers: headers(),
body: JSON.stringify({ type, objectId: obj.id, title: obj.title, parent: 0, order: items.value.length + 1 }),
});
}
selectedIds.value = [];
await loadItems();
await loadMenus();
};
const addCustomLink = async () => {
if (!activeMenuId.value || !customLink.value.url) return;
await fetch(api(`/nav-menus/${activeMenuId.value}/items`), {
method: 'POST',
headers: headers(),
body: JSON.stringify({
type: 'custom',
url: customLink.value.url,
title: customLink.value.title || customLink.value.url,
parent: 0,
order: items.value.length + 1,
}),
});
customLink.value = { title: '', url: 'https://' };
await loadItems();
await loadMenus();
};
// Structure editing -------------------------------------------------------
const removeItem = async (id) => {
await fetch(api(`/nav-menus/items/${id}`), { method: 'DELETE', headers: headers() });
await loadItems();
await loadMenus();
};
const persistOrder = async () => {
const payload = orderedItems.value.map((it, idx) => ({ id: it.id, parent: it.parent, order: idx + 1 }));
await fetch(api(`/nav-menus/${activeMenuId.value}/reorder`), {
method: 'POST',
headers: headers(),
body: JSON.stringify({ items: payload }),
});
};
const moveUp = async (item) => {
const idx = items.value.findIndex((i) => i.id === item.id);
const siblings = items.value.filter((i) => i.parent === item.parent).sort((a, b) => a.order - b.order);
const pos = siblings.findIndex((i) => i.id === item.id);
if (pos <= 0) return;
[siblings[pos - 1].order, siblings[pos].order] = [siblings[pos].order, siblings[pos - 1].order];
await persistOrder();
await loadItems();
};
const moveDown = async (item) => {
const siblings = items.value.filter((i) => i.parent === item.parent).sort((a, b) => a.order - b.order);
const pos = siblings.findIndex((i) => i.id === item.id);
if (pos === -1 || pos >= siblings.length - 1) return;
[siblings[pos + 1].order, siblings[pos].order] = [siblings[pos].order, siblings[pos + 1].order];
await persistOrder();
await loadItems();
};
const indent = async (item) => {
const siblings = items.value.filter((i) => i.parent === item.parent).sort((a, b) => a.order - b.order);
const pos = siblings.findIndex((i) => i.id === item.id);
if (pos <= 0) return;
item.parent = siblings[pos - 1].id;
await persistOrder();
await loadItems();
};
const outdent = async (item) => {
const parentItem = items.value.find((i) => i.id === item.parent);
if (!parentItem) return;
item.parent = parentItem.parent;
await persistOrder();
await loadItems();
};
// Locations ---------------------------------------------------------------
const assignLocation = async (slug, menuId) => {
await fetch(api('/nav-menus/locations'), {
method: 'POST',
headers: headers(),
body: JSON.stringify({ location: slug, menuId: menuId || 0 }),
});
notice.value = 'Location updated.';
setTimeout(() => (notice.value = ''), 2000);
await loadLocations();
};
</script>
<template>
<main class="nav-menus-page">
<header class="nav-menus-page__header">
<div>
<p class="nav-menus-page__eyebrow">Appearance</p>
<h1>Menus</h1>
</div>
</header>
<div v-if="notice" class="nav-menus-notice nav-menus-notice--success">{{ notice }}</div>
<div v-if="error" class="nav-menus-notice nav-menus-notice--error">{{ error }}</div>
<div class="nav-menus-layout">
<!-- Left: menu list + locations -->
<aside class="nav-menus-side">
<h2>Your Menus</h2>
<ul class="nav-menus-list">
<li v-for="menu in menus" :key="menu.id">
<button
type="button"
class="nav-menus-list__item"
:class="{ 'is-active': menu.id === activeMenuId }"
@click="selectMenu(menu.id)"
>
{{ menu.name }} <span>({{ menu.itemCount }})</span>
</button>
</li>
</ul>
<div class="nav-menus-create">
<input v-model="newMenuName" type="text" placeholder="New menu name" @keyup.enter="createMenu" />
<button type="button" class="button" :disabled="creatingMenu" @click="createMenu">Create</button>
</div>
<h2>Manage Locations</h2>
<div v-for="loc in locations" :key="loc.slug" class="nav-menus-location">
<label>{{ loc.description }}</label>
<select :value="loc.menuId || ''" @change="assignLocation(loc.slug, $event.target.value ? Number($event.target.value) : 0)">
<option value="">— Not assigned —</option>
<option v-for="menu in menus" :key="menu.id" :value="menu.id">{{ menu.name }}</option>
</select>
</div>
<p v-if="!locations.length" class="nav-menus-empty">This theme registered no menu locations.</p>
</aside>
<!-- Middle: add items -->
<section class="nav-menus-add">
<h2>Add Menu Items</h2>
<div class="nav-menus-add__tabs">
<button type="button" :class="{ 'is-active': addTab === 'pages' }" @click="addTab = 'pages'">Pages</button>
<button type="button" :class="{ 'is-active': addTab === 'posts' }" @click="addTab = 'posts'">Posts</button>
<button type="button" :class="{ 'is-active': addTab === 'categories' }" @click="addTab = 'categories'">Categories</button>
<button type="button" :class="{ 'is-active': addTab === 'custom' }" @click="addTab = 'custom'">Custom Link</button>
</div>
<div v-if="addTab !== 'custom'" class="nav-menus-add__list">
<label v-for="opt in pickers[addTab]" :key="opt.id" class="nav-menus-add__row">
<input type="checkbox" :checked="selectedIds.includes(opt.id)" @change="toggleSelected(opt.id)" />
<span>{{ opt.title }}</span>
</label>
<p v-if="!pickers[addTab]?.length" class="nav-menus-empty">Nothing to show.</p>
<button v-if="pickers[addTab]?.length" type="button" class="button-primary" :disabled="!activeMenuId" @click="addSelectedItems">
Add to Menu
</button>
</div>
<div v-else class="nav-menus-add__custom">
<input v-model="customLink.title" type="text" placeholder="Link Text" />
<input v-model="customLink.url" type="text" placeholder="https://example.com" />
<button type="button" class="button-primary" :disabled="!activeMenuId" @click="addCustomLink">Add to Menu</button>
</div>
</section>
<!-- Right: current structure -->
<section class="nav-menus-structure">
<div class="nav-menus-structure__head">
<h2>{{ activeMenu?.name || 'Select a menu' }}</h2>
<button v-if="activeMenu" type="button" class="icon-button" title="Delete menu" @click="deleteMenu">
<span class="dashicons dashicons-trash" />
</button>
</div>
<p v-if="loading" class="nav-menus-empty">Loading…</p>
<p v-else-if="!orderedItems.length" class="nav-menus-empty">No items in this menu yet — add some from the left.</p>
<div
v-for="item in orderedItems"
:key="item.id"
class="nav-menus-item"
:style="{ marginLeft: `${item.depth * 24}px` }"
>
<span class="nav-menus-item__title">{{ item.title }}</span>
<span class="nav-menus-item__actions">
<button type="button" title="Move up" @click="moveUp(item)"><span class="dashicons dashicons-arrow-up-alt2" /></button>
<button type="button" title="Move down" @click="moveDown(item)"><span class="dashicons dashicons-arrow-down-alt2" /></button>
<button type="button" title="Indent (make sub-item)" @click="indent(item)"><span class="dashicons dashicons-arrow-right-alt2" /></button>
<button type="button" title="Outdent" :disabled="!item.parent" @click="outdent(item)"><span class="dashicons dashicons-arrow-left-alt2" /></button>
<button type="button" title="Remove" @click="removeItem(item.id)"><span class="dashicons dashicons-no-alt" /></button>
</span>
</div>
</section>
</div>
</main>
</template>
<style scoped>
.nav-menus-page { max-width: 1200px; margin: 0 auto; }
.nav-menus-page__header { margin-bottom: 16px; }
.nav-menus-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.nav-menus-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.nav-menus-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
.nav-menus-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.nav-menus-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.nav-menus-layout { display: grid; grid-template-columns: 260px 300px 1fr; gap: 20px; align-items: start; }
.nav-menus-side, .nav-menus-add, .nav-menus-structure {
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
border-radius: 14px; padding: 18px;
}
.nav-menus-side h2, .nav-menus-add h2, .nav-menus-structure h2 {
font-size: 0.9375rem; margin: 0 0 10px; color: var(--aurora-text);
}
.nav-menus-list { list-style: none; margin: 0 0 14px; padding: 0; }
.nav-menus-list__item {
width: 100%; text-align: left; padding: 8px 10px; border-radius: 8px;
border: none; background: none; color: var(--aurora-text-muted); cursor: pointer;
font-size: 0.875rem;
}
.nav-menus-list__item span { color: var(--aurora-text-muted); }
.nav-menus-list__item:hover { background: var(--aurora-bg); }
.nav-menus-list__item.is-active { background: var(--aurora-accent-soft); color: var(--aurora-accent); }
.nav-menus-create { display: flex; gap: 6px; margin-bottom: 20px; }
.nav-menus-create input {
flex: 1; padding: 7px 10px; border-radius: 7px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.8125rem;
}
.nav-menus-location { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.nav-menus-location label { font-size: 0.8125rem; color: var(--aurora-text-muted); }
.nav-menus-location select,
.nav-menus-add__custom input {
padding: 7px 10px; border-radius: 7px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.8125rem;
}
.nav-menus-add__tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.nav-menus-add__tabs button {
padding: 6px 10px; border-radius: 7px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg); color: var(--aurora-text-muted); font-size: 0.75rem; cursor: pointer;
}
.nav-menus-add__tabs button.is-active { background: var(--aurora-accent-soft); color: var(--aurora-accent); border-color: transparent; }
.nav-menus-add__list { max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
.nav-menus-add__row {
display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px;
font-size: 0.8125rem; color: var(--aurora-text);
}
.nav-menus-add__row:hover { background: var(--aurora-bg); }
.nav-menus-add__list .button-primary { margin-top: 10px; width: 100%; }
.nav-menus-add__custom { display: flex; flex-direction: column; gap: 8px; }
.nav-menus-structure__head { display: flex; align-items: center; justify-content: space-between; }
.nav-menus-item {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; border-radius: 8px; margin-bottom: 4px;
background: var(--aurora-bg); border: 1px solid var(--aurora-border);
font-size: 0.8125rem; color: var(--aurora-text);
}
.nav-menus-item__actions { display: flex; gap: 2px; }
.nav-menus-item__actions button {
border: none; background: none; color: var(--aurora-text-muted); cursor: pointer;
width: 26px; height: 26px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center;
}
.nav-menus-item__actions button:hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
.nav-menus-item__actions button:disabled { opacity: 0.35; cursor: default; }
.nav-menus-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
@media (max-width: 1100px) {
.nav-menus-layout { grid-template-columns: 1fr; }
}
</style>
+205
View File
@@ -0,0 +1,205 @@
<script setup>
import { reactive, ref, onMounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
const meUrl = `${props.serverData.restUrl}wp/v2/users/me?context=edit`;
const form = reactive({
first_name: '',
last_name: '',
nickname: '',
email: '',
url: '',
description: '',
});
const loading = ref(true);
const saving = ref(false);
const notice = ref('');
const error = ref('');
const load = async () => {
loading.value = true;
try {
const res = await fetch(meUrl, { headers: headers() });
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not load your profile.');
form.first_name = data.first_name || '';
form.last_name = data.last_name || '';
form.nickname = data.nickname || '';
form.email = data.email || '';
form.url = data.url || '';
form.description = data.description || '';
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
};
onMounted(load);
const save = async () => {
saving.value = true;
error.value = '';
notice.value = '';
try {
const res = await fetch(meUrl, {
method: 'POST',
headers: headers(),
body: JSON.stringify(form),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not save your profile.');
notice.value = 'Profile updated.';
setTimeout(() => (notice.value = ''), 2500);
} catch (e) {
error.value = e.message;
} finally {
saving.value = false;
}
};
// Password ----------------------------------------------------------------
const newPassword = ref('');
const changingPassword = ref(false);
const passwordNotice = ref('');
const passwordError = ref('');
const generatePassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
newPassword.value = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
};
const changePassword = async () => {
passwordError.value = '';
passwordNotice.value = '';
if (!newPassword.value) return;
changingPassword.value = true;
try {
const res = await fetch(`${props.serverData.restUrl}aurora-admin/v1/profile/password`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ password: newPassword.value }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not change your password.');
passwordNotice.value = 'Password updated.';
newPassword.value = '';
setTimeout(() => (passwordNotice.value = ''), 2500);
} catch (e) {
passwordError.value = e.message;
} finally {
changingPassword.value = false;
}
};
</script>
<template>
<main class="profile-page">
<header class="profile-page__header">
<div>
<p class="profile-page__eyebrow">Accounts</p>
<h1>Your Profile</h1>
</div>
</header>
<div v-if="notice" class="profile-notice profile-notice--success">{{ notice }}</div>
<div v-if="error" class="profile-notice profile-notice--error">{{ error }}</div>
<p v-if="loading" class="profile-empty">Loading…</p>
<form v-else class="profile-form" @submit.prevent="save">
<label class="profile-field">
<span>First Name</span>
<input v-model="form.first_name" type="text" />
</label>
<label class="profile-field">
<span>Last Name</span>
<input v-model="form.last_name" type="text" />
</label>
<label class="profile-field">
<span>Nickname</span>
<input v-model="form.nickname" type="text" />
</label>
<label class="profile-field">
<span>Email</span>
<input v-model="form.email" type="email" />
</label>
<label class="profile-field">
<span>Website</span>
<input v-model="form.url" type="text" />
</label>
<label class="profile-field">
<span>Biographical Info</span>
<textarea v-model="form.description" rows="4" />
</label>
<div class="profile-form__actions">
<button type="submit" class="button-primary" :disabled="saving">
{{ saving ? 'Saving…' : 'Update Profile' }}
</button>
</div>
</form>
<section class="profile-password">
<h2>Change Password</h2>
<div v-if="passwordNotice" class="profile-notice profile-notice--success">{{ passwordNotice }}</div>
<div v-if="passwordError" class="profile-notice profile-notice--error">{{ passwordError }}</div>
<div class="profile-password__row">
<input v-model="newPassword" type="text" placeholder="New password" />
<button type="button" class="button" @click="generatePassword">Generate</button>
<button type="button" class="button-primary" :disabled="changingPassword || !newPassword" @click="changePassword">
{{ changingPassword ? 'Updating…' : 'Change Password' }}
</button>
</div>
</section>
</main>
</template>
<style scoped>
.profile-page { max-width: 640px; margin: 0 auto; }
.profile-page__header { margin-bottom: 16px; }
.profile-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.profile-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.profile-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
.profile-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
.profile-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.profile-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.profile-notice--info { background: var(--aurora-bg-subtle); color: var(--aurora-text-muted); }
.profile-notice--info a { color: var(--aurora-accent); }
.profile-form, .profile-password {
display: flex; flex-direction: column; gap: 18px;
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
border-radius: 14px;
padding: 24px;
margin-bottom: 20px;
}
.profile-field { display: flex; flex-direction: column; gap: 6px; }
.profile-field span { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-text); }
.profile-field input,
.profile-field textarea {
padding: 9px 12px; border-radius: 8px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.875rem;
font-family: inherit; resize: vertical;
}
.profile-form__actions { margin-top: 4px; }
.profile-password h2 { margin: 0 0 4px; font-size: 0.9375rem; color: var(--aurora-text); }
.profile-password__row { display: flex; gap: 8px; }
.profile-password__row input {
flex: 1; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.875rem;
}
</style>
+60 -6
View File
@@ -2,6 +2,7 @@
import { ref, reactive, computed, onMounted } from 'vue'; import { ref, reactive, computed, onMounted } from 'vue';
import AppListShell from '../_shared/AppListShell.vue'; import AppListShell from '../_shared/AppListShell.vue';
import AppListItem from '../_shared/AppListItem.vue'; import AppListItem from '../_shared/AppListItem.vue';
import { describeCapability } from './capabilityDescriptions.js';
const props = defineProps({ const props = defineProps({
serverData: { type: Object, required: true }, serverData: { type: Object, required: true },
@@ -145,10 +146,16 @@ const remove = async () => {
<div class="role-editor__section"> <div class="role-editor__section">
<h3>Capabilities</h3> <h3>Capabilities</h3>
<div class="role-editor__caps"> <div class="role-editor__caps">
<label v-for="cap in allCapabilities" :key="cap" class="role-editor__cap"> <div v-for="cap in allCapabilities" :key="cap" class="role-editor__cap">
<input type="checkbox" :checked="draft.capabilities.includes(cap)" @change="toggleCap(cap)" /> <label>
{{ cap }} <input type="checkbox" :checked="draft.capabilities.includes(cap)" @change="toggleCap(cap)" />
</label> {{ cap }}
</label>
<span class="role-editor__cap-info" tabindex="0">
<span class="dashicons dashicons-info-outline" />
<span class="role-editor__tooltip" role="tooltip">{{ describeCapability(cap) }}</span>
</span>
</div>
</div> </div>
</div> </div>
@@ -186,10 +193,57 @@ const remove = async () => {
color: var(--aurora-text-muted); margin: 0 0 8px; color: var(--aurora-text-muted); margin: 0 0 8px;
} }
.role-editor__caps { .role-editor__caps {
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px 16px; max-height: 380px; overflow-y: auto; gap: 8px 16px; max-height: 380px; overflow-y: auto;
/* Tooltips (position: absolute, below) need room to render outside the
grid cell without getting clipped by the scroll container. */
padding-bottom: 8px;
}
.role-editor__cap {
display: flex; align-items: center; justify-content: space-between; gap: 6px;
font-size: 0.8125rem; color: var(--aurora-text);
}
.role-editor__cap label { display: flex; align-items: center; gap: 6px; min-width: 0; }
.role-editor__cap label span,
.role-editor__cap label {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.role-editor__cap-info {
position: relative;
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; flex-shrink: 0;
color: var(--aurora-text-muted); cursor: help;
}
.role-editor__cap-info .dashicons { font-size: 15px; width: 15px; height: 15px; }
.role-editor__cap-info:hover { color: var(--aurora-accent); }
.role-editor__tooltip {
position: absolute;
bottom: calc(100% + 8px);
right: -8px;
z-index: 20;
width: 260px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--aurora-frame-border);
background: var(--aurora-frame-bg);
color: var(--aurora-text);
font-size: 0.75rem;
font-weight: 400;
line-height: 1.5;
white-space: normal;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
visibility: hidden;
opacity: 0;
transition: opacity 0.1s ease;
pointer-events: none;
}
.role-editor__cap-info:hover .role-editor__tooltip,
.role-editor__cap-info:focus .role-editor__tooltip {
visibility: visible;
opacity: 1;
} }
.role-editor__cap { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; color: var(--aurora-text); }
.role-editor__actions { display: flex; justify-content: flex-end; gap: 8px; } .role-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
.role-editor__actions button { .role-editor__actions button {
border: none; border-radius: 8px; padding: 9px 18px; font-size: 0.875rem; cursor: pointer; border: none; border-radius: 8px; padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
@@ -0,0 +1,101 @@
/**
* Human-readable descriptions for WordPress's capability system, shown as
* an info tooltip next to each capability checkbox in Role Editor.
*
* Two groups: the ~60 named capabilities WordPress core actually checks
* (edit_posts, manage_options, etc.), and the legacy level_0…level_10
* "user level" system from WordPress 1.x/early 2.x. Levels predate named
* capabilities and are deprecated, but WordPress still creates them on
* every default role for backward compatibility with old themes/plugins
* that call current_user_can('level_X') or check $user->user_level
* directly instead of a named capability — core itself hasn't checked a
* level anywhere for years. They don't map to anything meaningful beyond
* that compatibility shim, which is exactly why they look unexplained.
*/
const LEVEL_MATCH = /^level_(\d{1,2})$/;
const CORE_CAPABILITIES = {
read: 'The baseline capability every logged-in user needs just to access wp-admin at all.',
edit_posts: 'Edit their own posts.',
edit_others_posts: "Edit posts authored by other users.",
edit_private_posts: 'Edit private posts (not publicly visible).',
edit_published_posts: 'Edit posts that have already been published.',
publish_posts: 'Publish posts, rather than only saving them as drafts.',
delete_posts: 'Delete their own posts.',
delete_others_posts: "Delete posts authored by other users.",
delete_private_posts: 'Delete private posts.',
delete_published_posts: 'Delete posts that have already been published.',
read_private_posts: "View private posts that aren't publicly visible.",
edit_pages: 'Edit their own pages.',
edit_others_pages: "Edit pages authored by other users.",
edit_private_pages: 'Edit private pages.',
edit_published_pages: 'Edit pages that have already been published.',
publish_pages: 'Publish pages, rather than only saving them as drafts.',
delete_pages: 'Delete their own pages.',
delete_others_pages: "Delete pages authored by other users.",
delete_private_pages: 'Delete private pages.',
delete_published_pages: 'Delete pages that have already been published.',
read_private_pages: "View private pages that aren't publicly visible.",
moderate_comments: 'Approve, edit, and delete comments awaiting moderation.',
manage_categories: 'Create, edit, and delete post categories and tags.',
manage_links: 'Manage the legacy Links (blogroll) feature.',
upload_files: 'Upload files to the Media Library.',
list_users: 'View the list of registered users.',
create_users: 'Create new user accounts.',
edit_users: "Edit other users' profiles and roles.",
delete_users: 'Delete other user accounts.',
promote_users: "Change other users' roles.",
remove_users: 'Remove other users from a site (multisite: from this one site, not delete the account entirely).',
manage_options: "Change site-wide settings — one of the broadest capabilities short of being a full administrator.",
edit_theme_options: 'Change theme settings such as menus, widgets, and the Customizer.',
switch_themes: 'Activate a different installed theme.',
install_themes: 'Install new themes from the WordPress theme directory or by file upload.',
update_themes: 'Update installed themes to a newer version.',
delete_themes: 'Delete theme files from the site.',
edit_themes: 'Edit theme source code directly from the admin — high risk, equivalent to direct server file access.',
activate_plugins: 'Activate and deactivate plugins.',
install_plugins: 'Install new plugins from the WordPress plugin directory or by file upload.',
update_plugins: 'Update installed plugins to a newer version.',
delete_plugins: 'Delete plugin files from the site.',
edit_plugins: 'Edit plugin source code directly from the admin — high risk, equivalent to direct server file access.',
edit_files: 'Edit plugin and theme files directly from the admin (Theme/Plugin Editor) — high risk, equivalent to direct server file access.',
edit_dashboard: "Customize the admin dashboard's own widgets.",
update_core: 'Update WordPress core to a newer version.',
import: 'Import content into the site from an external source (Tools › Import).',
export: 'Export site content to a file (Tools › Export).',
customize: 'Access the Theme Customizer.',
unfiltered_html: 'Post raw, unfiltered HTML and JavaScript in content — a real cross-site-scripting (XSS) risk if granted to anyone other than a fully trusted administrator.',
unfiltered_upload: "Upload any file type without WordPress's normal file-type restrictions — a real security risk; only Administrators have this by default, and only on single-site installs.",
manage_network: '(Multisite) Access the network admin dashboard.',
manage_sites: '(Multisite) Create, edit, and delete individual sites on the network.',
manage_network_users: '(Multisite) Manage user accounts network-wide.',
manage_network_plugins: '(Multisite) Manage plugins network-wide.',
manage_network_themes: '(Multisite) Manage themes network-wide.',
manage_network_options: '(Multisite) Change network-wide settings.',
delete_site: '(Multisite) Delete this entire site.',
create_sites: '(Multisite) Create new sites on the network.',
};
export const describeCapability = (cap) => {
const levelMatch = cap.match(LEVEL_MATCH);
if (levelMatch) {
const n = Number(levelMatch[1]);
return (
`Legacy WordPress "user level" ${n} (0–10 scale, 10 highest) from the old pre-2.0 permissions ` +
"system. Deprecated in favor of the named capabilities below, but WordPress still assigns the " +
"full set of levels a role qualifies for, purely so old themes/plugins that check " +
`current_user_can('level_${n}') or a user's numeric level directly still work. Core itself hasn't ` +
'checked a level in years — safe to leave alone; removing it only affects compatibility with very old code.'
);
}
return CORE_CAPABILITIES[cap] || 'Custom capability — not part of WordPress core. Likely added by a plugin or theme.';
};
+67 -13
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, reactive, computed, watch } from 'vue'; import { ref, reactive, computed, watch, nextTick } from 'vue';
import { settingsCategories } from './settingsConfig.js'; import { settingsCategories } from './settingsConfig.js';
import SettingsField from './components/SettingsField.vue'; import SettingsField from './components/SettingsField.vue';
import { applyThemePalette, applyFontFamily } from './applyLiveTheme.js'; import { applyThemePalette, applyFontFamily } from './applyLiveTheme.js';
@@ -8,9 +8,27 @@ const props = defineProps({
serverData: { type: Object, required: true }, serverData: { type: Object, required: true },
}); });
const visibleCategories = settingsCategories;
// Shared REST fetcher for field types that need their own API calls beyond
// the generic wp/v2/settings save flow (currently just 'companion-plugin').
const api = (path, options = {}) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
...options,
headers: {
'X-WP-Nonce': props.serverData.restNonce,
'Content-Type': 'application/json',
...(options.headers || {}),
},
}).then(async (r) => {
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.message || 'Request failed');
return body;
});
// Working copy of the settings, seeded from the server + field defaults. // Working copy of the settings, seeded from the server + field defaults.
const settings = reactive({ ...(props.serverData.settings || {}) }); const settings = reactive({ ...(props.serverData.settings || {}) });
settingsCategories.forEach((cat) => visibleCategories.forEach((cat) =>
cat.fields.forEach((f) => { cat.fields.forEach((f) => {
if (settings[f.key] === undefined && f.default !== undefined) { if (settings[f.key] === undefined && f.default !== undefined) {
settings[f.key] = f.default; settings[f.key] = f.default;
@@ -21,25 +39,37 @@ settingsCategories.forEach((cat) =>
// Active category, synced with the URL ?category= param. // Active category, synced with the URL ?category= param.
const initialCat = new URLSearchParams(window.location.search).get('category'); const initialCat = new URLSearchParams(window.location.search).get('category');
const activeId = ref( const activeId = ref(
settingsCategories.some((c) => c.id === initialCat) ? initialCat : settingsCategories[0].id visibleCategories.some((c) => c.id === initialCat) ? initialCat : visibleCategories[0].id
); );
const activeCategory = computed( const activeCategory = computed(
() => settingsCategories.find((c) => c.id === activeId.value) || settingsCategories[0] () => visibleCategories.find((c) => c.id === activeId.value) || visibleCategories[0]
); );
// The content panel scrolls independently of the nav (see .settings__content
// below) so picking a category from a scrolled-down spot in the nav doesn't
// require scrolling the whole page back up to see it. But the panel is one
// persistent DOM node across category switches — its scrollTop doesn't reset
// on its own unless the new content happens to be shorter, so without this
// a long category could inherit a scrolled-down position from the previous
// one and land on the same "content is off-screen" problem this was meant
// to fix.
const contentEl = ref(null);
const selectCategory = (id) => { const selectCategory = (id) => {
activeId.value = id; activeId.value = id;
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set('category', id); url.searchParams.set('category', id);
window.history.replaceState({}, '', url); window.history.replaceState({}, '', url);
nextTick(() => {
if (contentEl.value) contentEl.value.scrollTop = 0;
});
}; };
// Search filter over the nav. // Search filter over the nav.
const query = ref(''); const query = ref('');
const filteredCategories = computed(() => { const filteredCategories = computed(() => {
const q = query.value.trim().toLowerCase(); const q = query.value.trim().toLowerCase();
if (!q) return settingsCategories; if (!q) return visibleCategories;
return settingsCategories.filter( return visibleCategories.filter(
(c) => (c) =>
c.title.toLowerCase().includes(q) || c.subtitle.toLowerCase().includes(q) c.title.toLowerCase().includes(q) || c.subtitle.toLowerCase().includes(q)
); );
@@ -161,7 +191,7 @@ const importSettings = (e) => {
</aside> </aside>
<!-- Right content panel --> <!-- Right content panel -->
<section class="settings__content"> <section ref="contentEl" class="settings__content">
<header class="settings__content-head"> <header class="settings__content-head">
<div> <div>
<h2 class="settings__content-title">{{ activeCategory.title }}</h2> <h2 class="settings__content-title">{{ activeCategory.title }}</h2>
@@ -181,7 +211,8 @@ const importSettings = (e) => {
:key="f.key" :key="f.key"
:field="f" :field="f"
:options="optionsFor(f)" :options="optionsFor(f)"
:license-status="f.key === 'license_key' ? serverData.licenseStatus : null" :disabled="!!f.dependsOn && !settings[f.dependsOn]"
:api="api"
v-model="settings[f.key]" v-model="settings[f.key]"
/> />
</div> </div>
@@ -194,20 +225,31 @@ const importSettings = (e) => {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 20px; gap: 20px;
min-height: calc(100vh - var(--aurora-toolbar-h) - 60px); /* height, not min-height — the outer #wpbody-content card (shell-
frame.css) already caps itself to the viewport and scrolls as a
whole. Filling it exactly (rather than growing taller than it) is
what lets .settings__nav and .settings__content become their own
independent scroll regions below instead of both scrolling together
as one long page. */
height: calc(100vh - var(--aurora-toolbar-h) - 60px);
margin: -32px; /* cancel the frame padding */ margin: -32px; /* cancel the frame padding */
padding: 22px; /* inset the two boxes; the frame's right gutter adds the rest */ padding: 22px; /* inset the two boxes; the frame's right gutter adds the rest */
box-sizing: border-box; box-sizing: border-box;
overflow: hidden;
/* No background here — html/body already paint the page gradient /* No background here — html/body already paint the page gradient
(shell-frame.css); repainting the same var(--aurora-app-bg) here was (shell-frame.css); repainting the same var(--aurora-app-bg) here was
just a redundant duplicate layer on top of it. */ just a redundant duplicate layer on top of it. */
} }
/* Left nav — no background/border of its own; the page gradient shows /* Left nav — no background/border of its own; the page gradient shows
straight through so it reads as one surface instead of a boxed panel. */ straight through so it reads as one surface instead of a boxed panel.
Scrolls independently of the content panel (own height + overflow)
so its scroll position is preserved when you switch categories. */
.settings__nav { .settings__nav {
width: 340px; width: 340px;
flex-shrink: 0; flex-shrink: 0;
height: 100%;
overflow-y: auto;
border-radius: 16px; border-radius: 16px;
padding: 24px 20px; padding: 24px 20px;
box-sizing: border-box; box-sizing: border-box;
@@ -261,11 +303,19 @@ const importSettings = (e) => {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
/* Right content — no background/border of its own, same reasoning. */ /* Right content — no background/border of its own, same reasoning.
Own height + overflow (independent of .settings__nav's) is the actual
fix: clicking a category always shows its content from the top,
whatever the nav's own scroll position is — resetScrollTop on
category switch (see selectCategory() above) handles the case where
this panel itself was already scrolled down. */
.settings__content { .settings__content {
flex: 1; min-width: 0; flex: 1; min-width: 0;
height: 100%;
overflow-y: auto;
border-radius: 16px; border-radius: 16px;
padding: 28px 32px; padding: 28px 32px;
box-sizing: border-box;
} }
.settings__content-head { .settings__content-head {
display: flex; align-items: flex-start; justify-content: space-between; display: flex; align-items: flex-start; justify-content: space-between;
@@ -287,7 +337,11 @@ const importSettings = (e) => {
.settings__fields > :last-child { border-bottom: none; } .settings__fields > :last-child { border-bottom: none; }
@media (max-width: 1000px) { @media (max-width: 1000px) {
.settings { flex-direction: column; } /* Independent-scroll panes only make sense side-by-side — stacked on a
.settings__nav { width: auto; } narrow screen, revert to one natural-height column so the page
scrolls as a whole the normal way, same as before this change. */
.settings { flex-direction: column; height: auto; overflow: visible; }
.settings__nav { width: auto; height: auto; overflow: visible; }
.settings__content { height: auto; overflow: visible; }
} }
</style> </style>
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, reactive, ref } from 'vue'; import { computed, ref } from 'vue';
import Toggle from '@/components/Toggle.vue'; import Toggle from '@/components/Toggle.vue';
import { themePresets, buildPalette } from '@/pages/settings/themePresets.js'; import { themePresets, buildPalette } from '@/pages/settings/themePresets.js';
import { googleFonts } from '@/pages/settings/googleFonts.js'; import { googleFonts } from '@/pages/settings/googleFonts.js';
@@ -11,24 +11,15 @@ const props = defineProps({
modelValue: { default: undefined }, modelValue: { default: undefined },
// Options for multiselect fields (resolved by the page from optionsSource). // Options for multiselect fields (resolved by the page from optionsSource).
options: { type: Array, default: () => [] }, options: { type: Array, default: () => [] },
// License::status()'s cached result, only passed for the license_key field. // True when this field's `dependsOn` key is falsy — greys out the
licenseStatus: { type: Object, default: null }, // control without hiding it, so the relationship stays visible.
disabled: { type: Boolean, default: false },
// Shared REST fetcher, only used by field types that need their own API
// calls beyond the generic wp/v2/settings save flow (companion-plugin).
api: { type: Function, default: null },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
const licenseStatusText = computed(() => {
const s = props.licenseStatus;
if (!s || s.status === 'none') return null;
if (s.status === 'active') {
const plan = s.plan ? ` (${s.plan} plan)` : '';
return { tone: 'ok', text: `License active${plan}${s.expires_at ? ` — renews/expires ${s.expires_at}` : ''}` };
}
if (s.status === 'unreachable') {
return { tone: 'warn', text: s.message || 'Could not reach the license server — will retry automatically.' };
}
return { tone: 'error', text: s.message || 'License invalid.' };
});
const val = computed({ const val = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (v) => emit('update:modelValue', v), set: (v) => emit('update:modelValue', v),
@@ -54,21 +45,6 @@ const toggleValue = (value) => {
emit('update:modelValue', next); emit('update:modelValue', next);
}; };
// Remote sites helpers -------------------------------------------------------
const sites = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []));
const newSite = reactive({ url: '', username: '', app_password: '' });
const addSite = () => {
if (!newSite.url.trim()) return;
emit('update:modelValue', [
...sites.value,
{ url: newSite.url.trim(), username: newSite.username.trim(), app_password: newSite.app_password },
]);
newSite.url = '';
newSite.username = '';
newSite.app_password = '';
};
const removeSite = (i) => emit('update:modelValue', sites.value.filter((_, idx) => idx !== i));
// Media picker (image fields) --------------------------------------------- // Media picker (image fields) ---------------------------------------------
const pickImage = () => { const pickImage = () => {
const wp = window.wp; const wp = window.wp;
@@ -103,16 +79,47 @@ const openFontList = () => {
fontQuery.value = props.modelValue || ''; fontQuery.value = props.modelValue || '';
fontOpen.value = true; fontOpen.value = true;
}; };
// Companion plugin (Aurora File Manager) install card -----------------------
const companionStatus = ref(null); // { installed, active } | null while loading
const companionBusy = ref(false);
const companionError = ref('');
const loadCompanionStatus = async () => {
if (props.field.type !== 'companion-plugin' || !props.api) return;
try {
companionStatus.value = await props.api(props.field.statusEndpoint);
} catch {
companionStatus.value = { installed: false, active: false };
}
};
if (props.field.type === 'companion-plugin') {
loadCompanionStatus();
}
const installCompanion = async () => {
if (!props.api) return;
companionBusy.value = true;
companionError.value = '';
try {
const result = await props.api(props.field.installEndpoint, { method: 'POST' });
if (!result.success) {
companionError.value = result.message || 'Installation failed.';
}
await loadCompanionStatus();
} catch (e) {
companionError.value = e.message || 'Installation failed.';
} finally {
companionBusy.value = false;
}
};
</script> </script>
<template> <template>
<div class="field" :id="field.key === 'license_key' ? 'license' : null"> <div class="field">
<div class="field__info"> <div class="field__info">
<div class="field__label">{{ field.label }}</div> <div class="field__label">{{ field.label }}</div>
<p v-if="field.description" class="field__desc">{{ field.description }}</p> <p v-if="field.description" class="field__desc">{{ field.description }}</p>
<p v-if="licenseStatusText" class="field__license-status" :class="`field__license-status--${licenseStatusText.tone}`">
{{ licenseStatusText.text }}
</p>
</div> </div>
<div class="field__control"> <div class="field__control">
@@ -122,14 +129,39 @@ const openFontList = () => {
type="text" type="text"
class="field__input" class="field__input"
:placeholder="field.placeholder || ''" :placeholder="field.placeholder || ''"
:readonly="!!field.readonly"
:value="val || ''" :value="val || ''"
@input="val = $event.target.value" @input="val = $event.target.value"
/> />
<!-- Textarea (multi-line plain text — moderation/blocklist fields) -->
<textarea
v-else-if="field.type === 'textarea'"
class="field__input field__textarea"
:placeholder="field.placeholder || ''"
:rows="field.rows || 6"
:value="val || ''"
@input="val = $event.target.value"
></textarea>
<!-- Select (plain dropdown, distinct from the multiselect checkbox list below) -->
<select
v-else-if="field.type === 'select'"
class="field__input"
:value="val ?? ''"
:disabled="disabled"
@change="val = $event.target.value"
>
<option v-for="opt in (field.options || options)" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<!-- Toggle --> <!-- Toggle -->
<Toggle <Toggle
v-else-if="field.type === 'toggle'" v-else-if="field.type === 'toggle'"
:model-value="!!val" :model-value="!!val"
:disabled="disabled"
@update:model-value="val = $event" @update:model-value="val = $event"
/> />
@@ -186,28 +218,6 @@ const openFontList = () => {
</button> </button>
</div> </div>
<!-- Remote sites -->
<div v-else-if="field.type === 'remote-sites'" class="field__sites">
<div class="field__sites-add">
<input v-model="newSite.url" type="text" class="field__input" placeholder="Site URL (e.g., https://example.com)" />
<div class="field__sites-row">
<input v-model="newSite.username" type="text" class="field__input" placeholder="Username" />
<input v-model="newSite.app_password" type="password" class="field__input" placeholder="Application Password" />
<button type="button" class="field__newitem" @click="addSite">Add Site</button>
</div>
</div>
<div class="field__sites-list">
<div v-if="!sites.length" class="field__sites-empty">No remote sites configured yet</div>
<div v-for="(site, i) in sites" :key="i" class="field__sites-item">
<span class="field__sites-url">{{ site.url }}</span>
<span class="field__sites-user">{{ site.username }}</span>
<button type="button" class="field__repeater-remove" @click="removeSite(i)">×</button>
</div>
</div>
<p class="field__sites-help">
Application passwords can be created in WordPress under Users &gt; Profile &gt; Application Passwords
</p>
</div>
<!-- Theme picker (10 fixed color presets, whole-UI preview) --> <!-- Theme picker (10 fixed color presets, whole-UI preview) -->
<div v-else-if="field.type === 'theme-picker'" class="field__theme"> <div v-else-if="field.type === 'theme-picker'" class="field__theme">
@@ -262,6 +272,31 @@ const openFontList = () => {
</div> </div>
</div> </div>
<!-- Companion plugin install card -->
<div v-else-if="field.type === 'companion-plugin'" class="field__companion">
<div v-if="!companionStatus" class="field__companion-status">Checking…</div>
<template v-else>
<a
v-if="companionStatus.active"
class="field__companion-manage"
:href="field.manageUrl"
>
Installed &amp; active — open File Manager →
</a>
<template v-else>
<button
type="button"
class="field__companion-install"
:disabled="companionBusy"
@click="installCompanion"
>
{{ companionBusy ? 'Installing…' : (companionStatus.installed ? 'Activate' : 'Install & Activate') }}
</button>
<p v-if="companionError" class="field__companion-error">{{ companionError }}</p>
</template>
</template>
</div>
<!-- Repeater --> <!-- Repeater -->
<div v-else-if="field.type === 'repeater'" class="field__repeater"> <div v-else-if="field.type === 'repeater'" class="field__repeater">
<div v-for="(row, i) in rows" :key="i" class="field__repeater-row"> <div v-for="(row, i) in rows" :key="i" class="field__repeater-row">
@@ -298,10 +333,6 @@ const openFontList = () => {
} }
.field__label { font-size: 0.95rem; font-weight: 600; color: var(--aurora-text); } .field__label { font-size: 0.95rem; font-weight: 600; color: var(--aurora-text); }
.field__desc { margin: 6px 0 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.5; } .field__desc { margin: 6px 0 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.5; }
.field__license-status { margin: 8px 0 0; font-size: 0.8125rem; font-weight: 600; }
.field__license-status--ok { color: #34d399; }
.field__license-status--warn { color: #fbbf24; }
.field__license-status--error { color: #f87171; }
.field__control { display: flex; justify-content: flex-start; } .field__control { display: flex; justify-content: flex-start; }
.field__input { .field__input {
@@ -314,6 +345,7 @@ const openFontList = () => {
font-size: 0.875rem; font-size: 0.875rem;
} }
.field__input:focus { outline: none; border-color: var(--aurora-accent); } .field__input:focus { outline: none; border-color: var(--aurora-accent); }
.field__textarea { resize: vertical; font-family: inherit; line-height: 1.5; }
.field__color { display: flex; align-items: center; gap: 10px; } .field__color { display: flex; align-items: center; gap: 10px; }
.field__color input[type='color'] { .field__color input[type='color'] {
@@ -379,33 +411,6 @@ const openFontList = () => {
} }
.field__seg--active { background: var(--aurora-accent); color: #fff !important; } .field__seg--active { background: var(--aurora-accent); color: #fff !important; }
/* Remote sites */
.field__sites { width: 100%; }
.field__sites-add .field__input { margin-bottom: 8px; }
.field__sites-row { display: flex; gap: 8px; }
.field__sites-row .field__input { margin-bottom: 0; }
.field__sites-row .field__newitem { flex-shrink: 0; white-space: nowrap; }
.field__sites-list {
margin-top: 12px;
border: 1px solid var(--aurora-border);
border-radius: 10px;
overflow: hidden;
}
.field__sites-empty {
padding: 18px; text-align: center;
font-size: 0.8125rem; color: var(--aurora-text-muted);
}
.field__sites-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--aurora-border);
font-size: 0.8125rem;
}
.field__sites-item:last-child { border-bottom: none; }
.field__sites-url { color: var(--aurora-text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.field__sites-user { color: var(--aurora-text-muted); }
.field__sites-help { margin: 10px 0 0; font-size: 0.75rem; color: var(--aurora-text-muted); }
/* Theme picker */ /* Theme picker */
.field__theme { display: flex; flex-wrap: wrap; gap: 10px; width: 100%; } .field__theme { display: flex; flex-wrap: wrap; gap: 10px; width: 100%; }
.field__theme-card { .field__theme-card {
@@ -449,6 +454,20 @@ const openFontList = () => {
.field__font-item:hover { background: var(--aurora-bg-subtle); } .field__font-item:hover { background: var(--aurora-bg-subtle); }
.field__font-item--active { color: var(--aurora-accent); } .field__font-item--active { color: var(--aurora-accent); }
/* Companion plugin install card */
.field__companion-status { font-size: 0.8125rem; color: var(--aurora-text-muted); }
.field__companion-install {
border: none; border-radius: 8px; background: var(--aurora-accent); color: #fff;
padding: 8px 16px; font-size: 0.8125rem; font-weight: 600; cursor: pointer;
}
.field__companion-install:disabled { opacity: 0.6; cursor: default; }
.field__companion-manage {
display: inline-block; font-size: 0.8125rem; font-weight: 600;
color: var(--aurora-accent); text-decoration: none;
}
.field__companion-manage:hover { text-decoration: underline; }
.field__companion-error { margin: 8px 0 0; font-size: 0.8125rem; color: #e5484d; }
@media (max-width: 782px) { @media (max-width: 782px) {
.field { grid-template-columns: 1fr; gap: 12px; } .field { grid-template-columns: 1fr; gap: 12px; }
} }
+96 -24
View File
@@ -8,16 +8,9 @@ export const settingsCategories = [
{ {
id: 'general', id: 'general',
title: 'General', title: 'General',
subtitle: 'Core plugin settings and license management', subtitle: 'Core plugin settings',
icon: 'dashicons-admin-settings', icon: 'dashicons-admin-settings',
fields: [ fields: [
{
key: 'license_key',
type: 'text',
label: 'License key',
description: 'Enter your license key to enable automatic updates.',
placeholder: 'AA-XXXX-XXXX-XXXX',
},
{ {
key: 'logo', key: 'logo',
type: 'image', type: 'image',
@@ -31,20 +24,6 @@ export const settingsCategories = [
description: description:
"Set an alternative logo to use in dark mode. If one isn't set it will fall back to the standard logo.", "Set an alternative logo to use in dark mode. If one isn't set it will fall back to the standard logo.",
}, },
{
key: 'remote_sites',
type: 'remote-sites',
label: 'Remote Sites',
description: 'Configure remote WordPress sites for cross-site operations.',
},
{
key: 'remote_site_switcher_capability',
type: 'text',
label: 'Remote Site Switcher Capability',
description:
'Pro feature — requires a license. WordPress capability required to see the remote site switcher in the toolbar. Defaults to "manage_options" (admin users). Users with this capability or below will see the switcher.',
placeholder: 'manage_options',
},
{ {
key: 'disable_theme', key: 'disable_theme',
type: 'multiselect', type: 'multiselect',
@@ -111,7 +90,7 @@ export const settingsCategories = [
{ {
id: 'whitelabel', id: 'whitelabel',
title: 'White Label', title: 'White Label',
subtitle: 'Rebrand and customize interface text and icons (Pro feature — requires a license)', subtitle: 'Rebrand and customize interface text and icons',
icon: 'dashicons-tag', icon: 'dashicons-tag',
fields: [ fields: [
{ {
@@ -215,7 +194,7 @@ export const settingsCategories = [
{ {
id: 'analytics', id: 'analytics',
title: 'Analytics', title: 'Analytics',
subtitle: 'Built-in analytics and tracking settings (Pro feature — requires a license)', subtitle: 'Built-in analytics and tracking settings',
icon: 'dashicons-chart-bar', icon: 'dashicons-chart-bar',
fields: [ fields: [
{ {
@@ -284,4 +263,97 @@ export const settingsCategories = [
}, },
], ],
}, },
{
id: 'image-formats',
title: 'Modern Image Formats',
subtitle: 'Automatically generate WebP/AVIF versions of uploaded images',
icon: 'dashicons-format-image',
fields: [
{
key: 'image_formats_enabled',
type: 'toggle',
label: 'Enable modern image formats',
description:
'Automatically generate a WebP or AVIF copy of every size of a newly uploaded JPEG or PNG image, and serve it to browsers that support it. Only affects new uploads — existing library images are unaffected until re-uploaded or regenerated with a tool like Regenerate Thumbnails.',
default: true,
},
{
key: 'image_formats_format',
type: 'select',
label: 'Output format',
description:
'Automatic prefers AVIF (smaller files, wider quality range) when the server supports it, falling back to WebP. If your server supports neither, generation is silently skipped — ask your host about enabling GD or Imagick WebP/AVIF support.',
dependsOn: 'image_formats_enabled',
default: 'auto',
options: [
{ value: 'auto', label: 'Automatic (AVIF, falling back to WebP)' },
{ value: 'webp', label: 'WebP only' },
{ value: 'avif', label: 'AVIF only' },
],
},
{
key: 'image_formats_picture_element',
type: 'toggle',
label: 'Serve via <picture> element',
description:
'Rewrite <img> tags (post content and post thumbnails) into a <picture> element offering the modern format with the original JPEG/PNG as a fallback. Turn this off if it conflicts with your theme’s image styling, while still generating the files for other tools to use.',
dependsOn: 'image_formats_enabled',
default: true,
},
],
},
{
id: 'add-ons',
title: 'Add-ons',
subtitle: 'Standalone companion plugins',
icon: 'dashicons-admin-plugins',
fields: [
{
key: 'file_manager_addon',
type: 'companion-plugin',
label: 'Aurora File Manager',
description:
'Browse, edit, and manage your site’s files (themes, plugins, uploads, and more) directly from wp-admin. Distributed as a separate plugin rather than bundled here, since WordPress.org’s Plugin Directory policy doesn’t allow this category of functionality in a directory-listed plugin — restrictions or not.',
statusEndpoint: '/companion-plugins/file-manager/status',
installEndpoint: '/companion-plugins/file-manager/install',
manageUrl: 'admin.php?page=aurora-file-manager',
},
{
key: 'database_explorer_addon',
type: 'companion-plugin',
label: 'Aurora Database Explorer',
description:
'A Supabase-style database browser inside wp-admin — see every table with row counts and sizes, browse paginated data, and run your own read-only SELECT/SHOW/DESCRIBE/EXPLAIN queries. Distributed as a separate plugin rather than bundled here, since WordPress.org’s Plugin Directory review treats direct database-query tooling the same way it treats file-management functionality.',
statusEndpoint: '/companion-plugins/database-explorer/status',
installEndpoint: '/companion-plugins/database-explorer/install',
manageUrl: 'admin.php?page=aurora-database-explorer',
},
{
key: 'site_backup_addon',
type: 'companion-plugin',
label: 'Aurora Site Backup',
description:
'Full site backup (database + files) and restore-in-place, on demand or on a recurring schedule, stored locally or on your own S3-compatible or Google Drive remote storage. Distributed as a separate plugin rather than bundled here, to keep Aurora Admin’s own footprint away from arbitrary file-write functionality.',
statusEndpoint: '/companion-plugins/site-backup/status',
installEndpoint: '/companion-plugins/site-backup/install',
manageUrl: 'admin.php?page=aurora-site-backup',
},
],
},
{
id: 'admin-takeover',
title: 'Admin Takeover',
subtitle: 'Replace the remaining native WordPress admin screens with Aurora',
icon: 'dashicons-shield',
fields: [
{
key: 'full_admin_takeover',
type: 'toggle',
label: 'Full admin takeover',
description:
'Replace Media Upload, Add User, Profile, Appearance, Tools, and native Settings screens with Aurora equivalents. Off by default — these cover more ground than Posts/Pages/Media/Users/Comments, so it’s opt-in rather than on by default.',
default: false,
},
],
},
]; ];
+162
View File
@@ -0,0 +1,162 @@
<script setup>
import { ref, onMounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path) => `${props.serverData.restUrl}aurora-admin/v1${path}`;
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
const themes = ref([]);
const loading = ref(true);
const error = ref('');
const busy = ref('');
const load = async () => {
loading.value = true;
try {
const res = await fetch(api('/themes'), { headers: headers() });
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not load themes.');
themes.value = data;
} catch (e) {
error.value = e.message || 'Could not load themes.';
} finally {
loading.value = false;
}
};
onMounted(load);
const activate = async (theme) => {
busy.value = theme.stylesheet;
try {
const res = await fetch(api('/themes/activate'), {
method: 'POST',
headers: headers(),
body: JSON.stringify({ stylesheet: theme.stylesheet }),
});
if (!res.ok) throw new Error('Could not activate theme.');
await load();
} catch (e) {
error.value = e.message;
} finally {
busy.value = '';
}
};
const remove = async (theme) => {
if (!confirm(`Delete "${theme.name}"? This can't be undone.`)) return;
busy.value = theme.stylesheet;
try {
const res = await fetch(api(`/themes/${encodeURIComponent(theme.stylesheet)}`), {
method: 'DELETE',
headers: headers(),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not delete theme.');
await load();
} catch (e) {
error.value = e.message;
setTimeout(() => (error.value = ''), 3000);
} finally {
busy.value = '';
}
};
</script>
<template>
<main class="themes-page">
<header class="themes-page__header">
<div>
<p class="themes-page__eyebrow">Appearance</p>
<h1>Themes</h1>
</div>
<div class="themes-page__actions">
<a v-if="serverData.addThemeUrl" class="button-primary" :href="serverData.addThemeUrl">
Add New Theme
</a>
<a class="button" :href="serverData.customizeUrl">Customize</a>
</div>
</header>
<div v-if="error" class="themes-notice themes-notice--error">{{ error }}</div>
<p v-if="loading" class="themes-empty">Loading…</p>
<div v-else class="themes-grid">
<article v-for="theme in themes" :key="theme.stylesheet" class="theme-card" :class="{ 'theme-card--active': theme.active }">
<div class="theme-card__shot">
<img v-if="theme.screenshot" :src="theme.screenshot" :alt="theme.name" />
<span v-else class="dashicons dashicons-admin-appearance" />
</div>
<div class="theme-card__body">
<h3>{{ theme.name }} <span>{{ theme.version }}</span></h3>
<p>{{ theme.description }}</p>
</div>
<div class="theme-card__actions">
<span v-if="theme.active" class="theme-card__badge">Active</span>
<button
v-else
type="button"
class="button-primary"
:disabled="busy === theme.stylesheet"
@click="activate(theme)"
>
{{ busy === theme.stylesheet ? 'Activating…' : 'Activate' }}
</button>
<button
v-if="!theme.active"
type="button"
class="icon-button"
title="Delete theme"
:disabled="busy === theme.stylesheet"
@click="remove(theme)"
>
<span class="dashicons dashicons-trash" />
</button>
</div>
</article>
</div>
</main>
</template>
<style scoped>
.themes-page { max-width: 1100px; margin: 0 auto; }
.themes-page__header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; }
.themes-page__actions { display: flex; align-items: center; gap: 8px; }
.themes-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.themes-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.themes-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
.themes-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
.themes-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.themes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; }
.theme-card {
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
border-radius: 14px; overflow: hidden; display: flex; flex-direction: column;
}
.theme-card--active { border-color: var(--aurora-accent); }
.theme-card__shot {
aspect-ratio: 4 / 3; background: var(--aurora-bg);
display: flex; align-items: center; justify-content: center;
color: var(--aurora-text-muted);
}
.theme-card__shot img { width: 100%; height: 100%; object-fit: cover; }
.theme-card__shot .dashicons { font-size: 40px; width: 40px; height: 40px; }
.theme-card__body { padding: 14px 16px 8px; flex: 1; }
.theme-card__body h3 { margin: 0 0 6px; font-size: 0.9375rem; color: var(--aurora-text); }
.theme-card__body h3 span { font-weight: 400; font-size: 0.75rem; color: var(--aurora-text-muted); margin-left: 6px; }
.theme-card__body p {
margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.theme-card__actions { display: flex; align-items: center; gap: 8px; padding: 12px 16px 16px; }
.theme-card__badge {
font-size: 0.75rem; font-weight: 600; color: var(--aurora-accent);
background: var(--aurora-accent-soft); padding: 4px 10px; border-radius: 999px;
}
</style>
+152
View File
@@ -0,0 +1,152 @@
<script setup>
import { reactive, ref } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const form = reactive({
username: '',
email: '',
first_name: '',
last_name: '',
password: '',
roles: [props.serverData.defaultRole || 'subscriber'],
});
const saving = ref(false);
const error = ref('');
const success = ref('');
const generatePassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
form.password = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
};
const submit = async () => {
error.value = '';
success.value = '';
if (!form.username || !form.email) {
error.value = 'Username and email are required.';
return;
}
saving.value = true;
try {
const res = await fetch(`${props.serverData.restUrl}wp/v2/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce },
body: JSON.stringify({ ...form, password: form.password || undefined }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not create the user.');
success.value = `User "${data.username || form.username}" created.`;
form.username = '';
form.email = '';
form.first_name = '';
form.last_name = '';
form.password = '';
} catch (e) {
error.value = e.message || 'Could not create the user.';
} finally {
saving.value = false;
}
};
</script>
<template>
<main class="add-user-page">
<header class="add-user-page__header">
<div>
<p class="add-user-page__eyebrow">Accounts</p>
<h1>Add New User</h1>
</div>
<a class="button" :href="serverData.usersUrl">Back to Users</a>
</header>
<div v-if="success" class="add-user-notice add-user-notice--success">{{ success }}</div>
<div v-if="error" class="add-user-notice add-user-notice--error">{{ error }}</div>
<form class="add-user-form" @submit.prevent="submit">
<label class="add-user-field">
<span>Username <em>(required)</em></span>
<input v-model="form.username" type="text" autocomplete="off" />
</label>
<label class="add-user-field">
<span>Email <em>(required)</em></span>
<input v-model="form.email" type="email" autocomplete="off" />
</label>
<label class="add-user-field">
<span>First Name</span>
<input v-model="form.first_name" type="text" />
</label>
<label class="add-user-field">
<span>Last Name</span>
<input v-model="form.last_name" type="text" />
</label>
<label class="add-user-field">
<span>Password</span>
<div class="add-user-field__row">
<input v-model="form.password" type="text" placeholder="Leave blank to auto-generate" />
<button type="button" class="button" @click="generatePassword">Generate</button>
</div>
</label>
<label class="add-user-field">
<span>Role</span>
<select v-model="form.roles[0]">
<option v-for="role in serverData.roles" :key="role.value" :value="role.value">{{ role.label }}</option>
</select>
</label>
<div class="add-user-form__actions">
<button type="submit" class="button-primary" :disabled="saving">
{{ saving ? 'Adding User…' : 'Add New User' }}
</button>
</div>
</form>
</main>
</template>
<style scoped>
.add-user-page { max-width: 640px; margin: 0 auto; }
.add-user-page__header {
display: flex; align-items: flex-start; justify-content: space-between;
margin-bottom: 20px;
}
.add-user-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.add-user-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.add-user-notice {
padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem;
}
.add-user-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.add-user-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.add-user-form {
display: flex; flex-direction: column; gap: 18px;
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
border-radius: 14px;
padding: 24px;
}
.add-user-field { display: flex; flex-direction: column; gap: 6px; }
.add-user-field span { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-text); }
.add-user-field em { font-weight: 400; font-style: normal; color: var(--aurora-text-muted); }
.add-user-field input,
.add-user-field select {
padding: 9px 12px; border-radius: 8px; border: 1px solid var(--aurora-border);
background: var(--aurora-bg); color: var(--aurora-text); font-size: 0.875rem;
}
.add-user-field__row { display: flex; gap: 8px; }
.add-user-field__row input { flex: 1; }
.add-user-form__actions { margin-top: 4px; }
</style>
+144
View File
@@ -0,0 +1,144 @@
<script setup>
import { ref, onMounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path) => `${props.serverData.restUrl}aurora-admin/v1${path}`;
const headers = () => ({ 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce });
const sidebars = ref([]);
const loading = ref(true);
const error = ref('');
const load = async () => {
loading.value = true;
try {
const res = await fetch(api('/widgets'), { headers: headers() });
const data = await res.json();
if (!res.ok) throw new Error(data?.message || 'Could not load widgets.');
sidebars.value = data.sidebars || [];
} catch (e) {
error.value = e.message || 'Could not load widgets.';
} finally {
loading.value = false;
}
};
onMounted(load);
const move = async (widget, fromSidebar, toSidebar, order = -1) => {
await fetch(api('/widgets/move'), {
method: 'POST',
headers: headers(),
body: JSON.stringify({ widgetId: widget.id, fromSidebar, toSidebar, order }),
});
await load();
};
const moveUp = (widget, sidebarId) => {
if (widget.order <= 0) return;
move(widget, sidebarId, sidebarId, widget.order - 1);
};
const moveDown = (widget, sidebarId, count) => {
if (widget.order >= count - 1) return;
move(widget, sidebarId, sidebarId, widget.order + 1);
};
const remove = async (widget) => {
if (!confirm(`Delete "${widget.name}"? This can't be undone.`)) return;
const res = await fetch(api(`/widgets/${encodeURIComponent(widget.id)}`), { method: 'DELETE', headers: headers() });
const data = await res.json();
if (!res.ok) {
error.value = data?.message || 'Could not delete widget.';
setTimeout(() => (error.value = ''), 3000);
return;
}
await load();
};
</script>
<template>
<main class="widgets-page">
<header class="widgets-page__header">
<div>
<p class="widgets-page__eyebrow">Appearance</p>
<h1>Widgets</h1>
<p class="widgets-page__sub">
Move widgets between your theme's sidebars, reorder them, or deactivate them. Adding a brand new
widget type still uses WordPress's own block-based widgets screen.
</p>
</div>
</header>
<div v-if="error" class="widgets-notice widgets-notice--error">{{ error }}</div>
<p v-if="loading" class="widgets-empty">Loading…</p>
<div v-else class="widgets-grid">
<section v-for="sidebar in sidebars" :key="sidebar.id" class="widgets-sidebar">
<h2>{{ sidebar.name }}</h2>
<p v-if="!sidebar.widgets.length" class="widgets-empty">No widgets here.</p>
<div v-for="widget in sidebar.widgets" :key="widget.id" class="widgets-item">
<div class="widgets-item__info">
<strong>{{ widget.name }}</strong>
<span v-if="widget.title">{{ widget.title }}</span>
</div>
<div class="widgets-item__actions">
<button type="button" title="Move up" @click="moveUp(widget, sidebar.id)"><span class="dashicons dashicons-arrow-up-alt2" /></button>
<button type="button" title="Move down" @click="moveDown(widget, sidebar.id, sidebar.widgets.length)"><span class="dashicons dashicons-arrow-down-alt2" /></button>
<select :value="sidebar.id" @change="move(widget, sidebar.id, $event.target.value)">
<option v-for="s in sidebars" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
<button v-if="sidebar.id === 'wp_inactive_widgets'" type="button" title="Delete" @click="remove(widget)">
<span class="dashicons dashicons-trash" />
</button>
</div>
</div>
</section>
</div>
</main>
</template>
<style scoped>
.widgets-page { max-width: 1100px; margin: 0 auto; }
.widgets-page__header { margin-bottom: 20px; }
.widgets-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.widgets-page h1 { margin: 0 0 6px; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.widgets-page__sub { margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); max-width: 640px; }
.widgets-notice { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
.widgets-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.widgets-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }
.widgets-sidebar {
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
border-radius: 14px; padding: 18px;
}
.widgets-sidebar h2 { font-size: 0.9375rem; margin: 0 0 10px; color: var(--aurora-text); }
.widgets-empty { color: var(--aurora-text-muted); font-size: 0.8125rem; }
.widgets-item {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 8px 10px; border-radius: 8px; margin-bottom: 6px;
background: var(--aurora-bg); border: 1px solid var(--aurora-border);
font-size: 0.8125rem;
}
.widgets-item__info { display: flex; flex-direction: column; min-width: 0; }
.widgets-item__info strong { color: var(--aurora-text); }
.widgets-item__info span { color: var(--aurora-text-muted); font-size: 0.75rem; }
.widgets-item__actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.widgets-item__actions button {
border: none; background: none; color: var(--aurora-text-muted); cursor: pointer;
width: 26px; height: 26px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center;
}
.widgets-item__actions button:hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
.widgets-item__actions select {
font-size: 0.75rem; padding: 4px 6px; border-radius: 6px;
border: 1px solid var(--aurora-border); background: var(--aurora-bg); color: var(--aurora-text);
}
</style>
+1
View File
@@ -63,6 +63,7 @@ onMounted(() => {
:menu="serverData.menu" :menu="serverData.menu"
:aurora-nav="serverData.auroraNav" :aurora-nav="serverData.auroraNav"
:current-url="serverData.currentUrl" :current-url="serverData.currentUrl"
:active-parent-file="serverData.activeParentFile || ''"
:logo="serverData.settings?.logo || ''" :logo="serverData.settings?.logo || ''"
:dark-logo="serverData.settings?.dark_logo || ''" :dark-logo="serverData.settings?.dark_logo || ''"
:collapsed="collapsed" :collapsed="collapsed"
+11 -16
View File
@@ -11,6 +11,10 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
activeParentFile: {
type: String,
default: '',
},
logo: { logo: {
type: String, type: String,
default: '', default: '',
@@ -60,7 +64,13 @@ const isItemActive = (item) => {
const isSectionActive = (item) => { const isSectionActive = (item) => {
if (isItemActive(item)) return true; if (isItemActive(item)) return true;
return (item.submenu || []).some((sub) => normalize(sub.url) === current.value); if ((item.submenu || []).some((sub) => normalize(sub.url) === current.value)) return true;
// Covers native "hidden child" screens WP core itself doesn't list as a
// submenu link but still treats as belonging to this section for
// highlighting purposes (theme-install.php under Appearance, media-new.php
// under Media, user-new.php under Users, ...) — see Shell::print_mount()'s
// activeParentFile, sourced from WP core's own $parent_file global.
return !!props.activeParentFile && item.id === props.activeParentFile;
}; };
// Accordion: only one section's submenu open at a time — the WP-native // Accordion: only one section's submenu open at a time — the WP-native
@@ -239,9 +249,6 @@ const filteredAuroraNav = computed(() => {
:class="{ 'sidebar__sublink--active': isAuroraActive(item) }" :class="{ 'sidebar__sublink--active': isAuroraActive(item) }"
> >
{{ item.title }} {{ item.title }}
<span v-if="item.locked" class="sidebar__pro-badge" title="Pro feature">
<span class="dashicons dashicons-lock" />
</span>
</a> </a>
</li> </li>
</ul> </ul>
@@ -474,18 +481,6 @@ const filteredAuroraNav = computed(() => {
font-weight: 600; font-weight: 600;
} }
.sidebar__pro-badge {
display: flex;
align-items: center;
color: var(--aurora-text-muted);
flex-shrink: 0;
}
.sidebar__pro-badge .dashicons {
font-size: 13px;
width: 13px;
height: 13px;
}
/* --- Aurora branded nav (right under the native menu's own divider) ---- */ /* --- Aurora branded nav (right under the native menu's own divider) ---- */
.sidebar__aurora { .sidebar__aurora {
padding-bottom: 4px; padding-bottom: 4px;
+1 -105
View File
@@ -12,36 +12,12 @@ const props = defineProps({
const emit = defineEmits(['toggle-menu']); const emit = defineEmits(['toggle-menu']);
const searchOpen = ref(false); const searchOpen = ref(false);
const sitesOpen = ref(false);
// Settings > General > "Disable global search" hides the search entirely. // Settings > General > "Disable global search" hides the search entirely.
const searchDisabled = computed( const searchDisabled = computed(
() => !!props.serverData.settings?.disable_global_search () => !!props.serverData.settings?.disable_global_search
); );
// Remote sites (secrets already stripped server-side). The switcher only
// shows for users holding the configured capability (canSwitchSites).
const remoteSites = computed(() => {
const sites = props.serverData.settings?.remote_sites;
return Array.isArray(sites) ? sites.filter((s) => s && s.url) : [];
});
const showSwitcher = computed(
() => !!props.serverData.canSwitchSites && remoteSites.value.length > 0
);
const siteAdminUrl = (site) => {
let url = (site.url || '').trim();
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
return url.replace(/\/+$/, '') + '/wp-admin/';
};
const siteHost = (site) => {
try {
return new URL(siteAdminUrl(site)).host;
} catch {
return site.url;
}
};
const onKeydown = (e) => { const onKeydown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
if (searchDisabled.value) return; if (searchDisabled.value) return;
@@ -88,35 +64,6 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown));
</button> </button>
</div> </div>
<div class="toolbar__right">
<div v-if="showSwitcher" class="toolbar__sites">
<button
type="button"
class="toolbar__icon-btn"
title="Switch site"
@click="sitesOpen = !sitesOpen"
>
<span class="dashicons dashicons-admin-multisite" />
</button>
<div v-if="sitesOpen" class="toolbar__dropdown">
<div class="toolbar__dropdown-header">
<div class="toolbar__dropdown-name">Remote sites</div>
</div>
<a
v-for="(site, i) in remoteSites"
:key="i"
:href="siteAdminUrl(site)"
target="_blank"
rel="noopener noreferrer"
class="toolbar__dropdown-item"
>
{{ siteHost(site) }}
<span v-if="site.username" class="toolbar__site-user">{{ site.username }}</span>
</a>
</div>
</div>
</div>
<SearchModal <SearchModal
:server-data="serverData" :server-data="serverData"
:open="searchOpen" :open="searchOpen"
@@ -143,8 +90,7 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown));
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
} }
.toolbar__left, .toolbar__left {
.toolbar__right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -218,54 +164,4 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown));
color: var(--aurora-text); color: var(--aurora-text);
} }
.toolbar__sites {
position: relative;
}
.toolbar__site-user {
color: var(--aurora-text-muted);
font-size: 0.75rem;
margin-left: 8px;
}
.toolbar__dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 200px;
background: var(--aurora-bg);
border: 1px solid var(--aurora-border);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
overflow: hidden;
z-index: 100000;
}
.toolbar__dropdown-header {
padding: 12px 14px;
border-bottom: 1px solid var(--aurora-border);
}
.toolbar__dropdown-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--aurora-text);
}
.toolbar__dropdown-email {
font-size: 0.75rem;
color: var(--aurora-text-muted);
}
.toolbar__dropdown-item {
display: block;
padding: 10px 14px;
text-decoration: none;
color: var(--aurora-text);
font-size: 0.875rem;
}
.toolbar__dropdown-item:hover {
background: var(--aurora-bg-subtle);
}
</style> </style>
+85
View File
@@ -292,6 +292,80 @@ html.aurora-has-taskbar #site-editor {
bottom: calc(24px + var(--aurora-taskbar-h) + var(--aurora-sidebar-gap) * 2) !important; bottom: calc(24px + var(--aurora-taskbar-h) + var(--aurora-sidebar-gap) * 2) !important;
} }
/* Theme installer preview ("Add Themes" > click a theme > full "Preview &
Install" overlay, theme-install.php). WP core's own .wp-full-overlay
component (shared with the Customizer, but only .theme-install-overlay
specifically is touched here — customize.php is out of scope) is a
true-viewport fixed overlay by design, so left unstyled it covers
Aurora's sidebar/toolbar entirely. Reframed the same way the block
editor is above: shrink the overlay's own box to the frame's insets,
then independently reposition its two POSITION:FIXED children (sidebar,
main) to match — fixed descendants are NOT relative to an ancestor's
padding box the way absolutely-positioned ones are, so the overlay's own
inset alone doesn't move them; each needs the same left/top/bottom math
applied directly. margin-left on .expanded/.collapsed mirrors WP core's
own three breakpoints (themes.css) with Aurora's frame offset added on
top, so the sidebar collapse/expand slide animation keeps working
unmodified — only the resting position changed, not the transition.
One more WP core mechanism to counteract: opening the overlay adds
body.full-overlay-active, and core's own CSS sets
`body.full-overlay-active { visibility: hidden }` with only
`.theme-install-overlay` (and #customize-container) set back to
visible — intended to hide the native admin chrome underneath, which
normally doesn't matter since core's own sidebar/toolbar are redundant
once covered anyway. Aurora's shell inherits that hidden visibility
too (it's nested in body), which is exactly what silently erased the
sidebar/toolbar entirely instead of just having them covered. Since
descendants can locally override an inherited `visibility: hidden`
back to visible, one rule undoes it for Aurora's own root only —
everything else under body stays hidden, harmless since it's covered
by the overlay's insets anyway. */
body.full-overlay-active #aurora-admin-shell-root {
visibility: visible !important;
}
html.aurora-ready .theme-install-overlay {
top: calc(var(--aurora-toolbar-h) + 16px) !important;
right: var(--aurora-gutter-r) !important;
bottom: 24px !important;
/* WP core's own .wp-full-overlay sets height: 100% — combined with top
AND bottom both set here, that's the exact same over-constrained box
as .interface-interface-skeleton above: per spec 'bottom' silently
loses to the explicit height, so the box ran past the true viewport
edge instead of stopping at this inset. Same fix. */
height: auto !important;
}
html.aurora-ready .theme-install-overlay.expanded {
margin-left: calc(300px + var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
}
html.aurora-ready .theme-install-overlay.collapsed {
margin-left: calc(var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
}
@media screen and (min-width: 1667px) {
html.aurora-ready .theme-install-overlay.expanded {
margin-left: calc(18% + var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
}
}
@media screen and (min-width: 3333px) {
html.aurora-ready .theme-install-overlay.expanded {
margin-left: calc(600px + var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
}
}
html.aurora-ready .theme-install-overlay .wp-full-overlay-sidebar {
left: calc(var(--aurora-sidebar-w) + var(--aurora-sidebar-gap) * 2 + 24px) !important;
top: calc(var(--aurora-toolbar-h) + 16px + var(--aurora-editor-winbar-h)) !important;
bottom: 24px !important;
height: auto !important;
border-top-left-radius: var(--aurora-radius-lg);
}
html.aurora-ready .theme-install-overlay .wp-full-overlay-main {
top: var(--aurora-editor-winbar-h) !important;
border-top-right-radius: var(--aurora-radius-lg);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
/* macOS-style title bar (windowChrome.js). Most pages get it injected as /* macOS-style title bar (windowChrome.js). Most pages get it injected as
the first child of #wpbody-content — negative margins cancel the 32px the first child of #wpbody-content — negative margins cancel the 32px
padding above so it spans edge-to-edge like a real window title bar. */ padding above so it spans edge-to-edge like a real window title bar. */
@@ -325,6 +399,17 @@ html.aurora-has-taskbar #site-editor {
border-top-left-radius: var(--aurora-radius-lg); border-top-left-radius: var(--aurora-radius-lg);
border-top-right-radius: var(--aurora-radius-lg); border-top-right-radius: var(--aurora-radius-lg);
} }
.aurora-winbar__crumb {
margin-left: 8px;
font-size: 0.75rem;
font-weight: 500;
color: var(--aurora-text-muted);
text-decoration: none;
white-space: nowrap;
}
.aurora-winbar__crumb:hover {
color: var(--aurora-text);
}
.aurora-winbar__dot { .aurora-winbar__dot {
width: 12px; width: 12px;
height: 12px; height: 12px;
+70
View File
@@ -75,7 +75,67 @@ function isBlockEditorPage() {
); );
} }
// WP core adds a "post-type-{slug}" class to <body> on every post edit
// screen — the same signal used here to build a "back to the list" link,
// since Posts ("edit.php") and Pages ("edit.php?post_type=page") are now
// hijacked in place at their native URLs (see HijackedNativePages.php) and
// there's no single admin.php?page=... URL to hardcode anymore.
//
// The Site Editor (site-editor.php) also carries the "block-editor-page"
// body class isBlockEditorPage() matches on, but has no "post-type-*"
// class (it edits templates/template parts, not a post) — returning null
// here rather than guessing "Posts" is what keeps the crumb from showing
// up somewhere it doesn't make sense.
function editorBackLink(adminUrl) {
const match = [...document.body.classList].find((c) => c.startsWith('post-type-'));
if (!match) {
return null;
}
const postType = match.replace('post-type-', '');
const base = adminUrl || '/wp-admin/';
if (postType === 'post') {
return { label: 'Posts', href: base + 'edit.php' };
}
if (postType === 'page') {
return { label: 'Pages', href: base + 'edit.php?post_type=page' };
}
return { label: postType, href: base + 'edit.php?post_type=' + encodeURIComponent(postType) };
}
// theme-install.php's "Preview & Install" overlay (WP core's
// .wp-full-overlay component) needs a permanently-running observer, not a
// one-shot check, for two reasons: (1) core's own Backbone view creates it
// lazily the first time a theme is clicked, so it's missing from the
// initial DOM at initWindowChrome()'s call time; (2) every subsequent
// theme selection re-renders the overlay's contents from scratch (its own
// view.render() replaces all children to show the newly-selected theme's
// details), wiping out anything inserted into it — confirmed live: the bar
// was there after the very first click, gone after picking a second theme.
// Kept running for the page's whole lifetime rather than disconnecting
// after the first successful insert. Independent of the rest of this
// file's dedupe guard (a normal page winbar already exists in
// #wpbody-content here too; this is a second, additional bar for the
// overlay specifically, not a replacement for it).
function watchForThemeOverlay(adminUrl) {
const ensureInserted = () => {
const overlay = document.querySelector('.theme-install-overlay');
if (overlay && !overlay.querySelector('.aurora-winbar')) {
const bar = buildBar(adminUrl, false);
bar.classList.add('aurora-winbar--editor');
overlay.insertBefore(bar, overlay.firstChild);
}
};
ensureInserted();
new MutationObserver(ensureInserted).observe(document.body, { childList: true, subtree: true });
}
export function initWindowChrome(adminUrl) { export function initWindowChrome(adminUrl) {
if (document.body.classList.contains('theme-install-php')) {
watchForThemeOverlay(adminUrl);
}
if (document.querySelector('.aurora-winbar')) { if (document.querySelector('.aurora-winbar')) {
return; return;
} }
@@ -103,6 +163,16 @@ export function initWindowChrome(adminUrl) {
if (isBlockEditorPage()) { if (isBlockEditorPage()) {
const bar = buildBar(adminUrl, false); const bar = buildBar(adminUrl, false);
bar.classList.add('aurora-winbar--editor'); bar.classList.add('aurora-winbar--editor');
const back = editorBackLink(adminUrl);
if (back) {
const crumb = document.createElement('a');
crumb.className = 'aurora-winbar__crumb';
crumb.href = back.href;
crumb.textContent = '← ' + back.label;
bar.appendChild(crumb);
}
document.body.appendChild(bar); document.body.appendChild(bar);
return; return;
} }
+8 -3
View File
@@ -27,15 +27,20 @@ export default defineConfig({
'admin-notices': fileURLToPath(new URL('./src/entries/admin-notices.js', import.meta.url)), 'admin-notices': fileURLToPath(new URL('./src/entries/admin-notices.js', import.meta.url)),
'role-editor': fileURLToPath(new URL('./src/entries/role-editor.js', import.meta.url)), 'role-editor': fileURLToPath(new URL('./src/entries/role-editor.js', import.meta.url)),
'activity-log': fileURLToPath(new URL('./src/entries/activity-log.js', import.meta.url)), 'activity-log': fileURLToPath(new URL('./src/entries/activity-log.js', import.meta.url)),
'database-explorer': fileURLToPath(new URL('./src/entries/database-explorer.js', import.meta.url)),
plugins: fileURLToPath(new URL('./src/entries/plugins.js', import.meta.url)), plugins: fileURLToPath(new URL('./src/entries/plugins.js', import.meta.url)),
comments: fileURLToPath(new URL('./src/entries/comments.js', import.meta.url)), comments: fileURLToPath(new URL('./src/entries/comments.js', import.meta.url)),
posts: fileURLToPath(new URL('./src/entries/posts.js', import.meta.url)), posts: fileURLToPath(new URL('./src/entries/posts.js', import.meta.url)),
pages: fileURLToPath(new URL('./src/entries/pages.js', import.meta.url)), pages: fileURLToPath(new URL('./src/entries/pages.js', import.meta.url)),
media: fileURLToPath(new URL('./src/entries/media.js', import.meta.url)), media: fileURLToPath(new URL('./src/entries/media.js', import.meta.url)),
users: fileURLToPath(new URL('./src/entries/users.js', import.meta.url)), users: fileURLToPath(new URL('./src/entries/users.js', import.meta.url)),
'file-manager': fileURLToPath(new URL('./src/entries/file-manager.js', import.meta.url)), 'native-settings': fileURLToPath(new URL('./src/entries/native-settings.js', import.meta.url)),
'locked-feature': fileURLToPath(new URL('./src/entries/locked-feature.js', import.meta.url)), 'media-upload': fileURLToPath(new URL('./src/entries/media-upload.js', import.meta.url)),
'add-user': fileURLToPath(new URL('./src/entries/add-user.js', import.meta.url)),
'nav-menus': fileURLToPath(new URL('./src/entries/nav-menus.js', import.meta.url)),
'themes': fileURLToPath(new URL('./src/entries/themes.js', import.meta.url)),
'widgets': fileURLToPath(new URL('./src/entries/widgets.js', import.meta.url)),
'profile': fileURLToPath(new URL('./src/entries/profile.js', import.meta.url)),
'bug-report': fileURLToPath(new URL('./src/entries/bug-report.js', import.meta.url)),
}, },
output: { output: {
entryFileNames: '[name].[hash].js', entryFileNames: '[name].[hash].js',
+21 -9
View File
@@ -1,27 +1,26 @@
<?php <?php
/* /*
* Plugin Name: Aurora Admin * Plugin Name: Aurora Admin
* Plugin URI: https://aurora.co * Plugin URI: https://aurora.auroradragon.studio
* Description: A modern, fast WordPress admin interface. * Description: A modern, fast WordPress admin interface.
* Version: 2.7.2 * Version: 3.6.1
* Author: Aurora * Author: Aurora Dragon Studio
* Author URI: https://auroradragon.studio
* Text Domain: aurora-admin * Text Domain: aurora-admin
* Domain Path: /languages/
* Requires PHP: 7.4 * Requires PHP: 7.4
* Requires at least: 5.5 * Requires at least: 5.5
* License: GPLv2 or later * License: GPLv2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
*/ */
defined("ABSPATH") || exit(); defined("ABSPATH") || exit();
define("AURORA_ADMIN_VERSION", "2.7.2"); define("AURORA_ADMIN_VERSION", "3.6.1");
define("AURORA_ADMIN_PATH", plugin_dir_path(__FILE__)); define("AURORA_ADMIN_PATH", plugin_dir_path(__FILE__));
define("AURORA_ADMIN_URL", plugins_url("aurora-admin/")); define("AURORA_ADMIN_URL", plugins_url("aurora-admin/"));
require AURORA_ADMIN_PATH . "admin/src/App/AuroraAdmin.php"; require AURORA_ADMIN_PATH . "admin/src/App/AuroraAdmin.php";
require AURORA_ADMIN_PATH . "admin/src/Options/Settings.php"; require AURORA_ADMIN_PATH . "admin/src/Options/Settings.php";
require AURORA_ADMIN_PATH . "admin/src/Licensing/Integrity.php";
require AURORA_ADMIN_PATH . "admin/src/Licensing/License.php";
require AURORA_ADMIN_PATH . "admin/src/Utility/Assets.php"; require AURORA_ADMIN_PATH . "admin/src/Utility/Assets.php";
require AURORA_ADMIN_PATH . "admin/src/Shell/MenuSerializer.php"; require AURORA_ADMIN_PATH . "admin/src/Shell/MenuSerializer.php";
require AURORA_ADMIN_PATH . "admin/src/Shell/Shell.php"; require AURORA_ADMIN_PATH . "admin/src/Shell/Shell.php";
@@ -36,10 +35,19 @@ require AURORA_ADMIN_PATH . "admin/src/Rest/PostsData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/PagesData.php"; require AURORA_ADMIN_PATH . "admin/src/Rest/PagesData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/MediaData.php"; require AURORA_ADMIN_PATH . "admin/src/Rest/MediaData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/UsersData.php"; require AURORA_ADMIN_PATH . "admin/src/Rest/UsersData.php";
require AURORA_ADMIN_PATH . "admin/src/FileManager/FileManagerData.php";
require AURORA_ADMIN_PATH . "admin/src/Posts/ScheduledDayFilter.php"; require AURORA_ADMIN_PATH . "admin/src/Posts/ScheduledDayFilter.php";
require AURORA_ADMIN_PATH . "admin/src/WhiteLabel/WhiteLabel.php"; require AURORA_ADMIN_PATH . "admin/src/WhiteLabel/WhiteLabel.php";
require AURORA_ADMIN_PATH . "admin/src/Pages/SubApps.php"; require AURORA_ADMIN_PATH . "admin/src/Pages/SubApps.php";
require AURORA_ADMIN_PATH . "admin/src/Pages/ScreenHijack.php";
require AURORA_ADMIN_PATH . "admin/src/Pages/HijackedNativePages.php";
require AURORA_ADMIN_PATH . "admin/src/Pages/NativeSettingsPages.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/PermalinksData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/DiscussionData.php";
require AURORA_ADMIN_PATH . "admin/src/Pages/AppearancePages.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/NavMenusData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/ThemesData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/WidgetsData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/ProfileData.php";
require AURORA_ADMIN_PATH . "admin/src/MenuCreator/MenuCreator.php"; require AURORA_ADMIN_PATH . "admin/src/MenuCreator/MenuCreator.php";
require AURORA_ADMIN_PATH . "admin/src/MenuCreator/MenuCreatorData.php"; require AURORA_ADMIN_PATH . "admin/src/MenuCreator/MenuCreatorData.php";
require AURORA_ADMIN_PATH . "admin/src/Notices/Notices.php"; require AURORA_ADMIN_PATH . "admin/src/Notices/Notices.php";
@@ -47,8 +55,12 @@ require AURORA_ADMIN_PATH . "admin/src/Notices/NoticesData.php";
require AURORA_ADMIN_PATH . "admin/src/Roles/RolesData.php"; require AURORA_ADMIN_PATH . "admin/src/Roles/RolesData.php";
require AURORA_ADMIN_PATH . "admin/src/ActivityLog/ActivityLog.php"; require AURORA_ADMIN_PATH . "admin/src/ActivityLog/ActivityLog.php";
require AURORA_ADMIN_PATH . "admin/src/ActivityLog/ActivityLogData.php"; require AURORA_ADMIN_PATH . "admin/src/ActivityLog/ActivityLogData.php";
require AURORA_ADMIN_PATH . "admin/src/DatabaseExplorer/DatabaseExplorerData.php";
require AURORA_ADMIN_PATH . "admin/src/Login/LoginStyle.php"; require AURORA_ADMIN_PATH . "admin/src/Login/LoginStyle.php";
require AURORA_ADMIN_PATH . "admin/src/Media/SvgUploads.php"; require AURORA_ADMIN_PATH . "admin/src/Media/SvgUploads.php";
require AURORA_ADMIN_PATH . "admin/src/Media/MediaFolders.php";
require AURORA_ADMIN_PATH . "admin/src/ImageFormats/ImageFormats.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/MediaFolderData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/BugReportData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/CompanionPluginData.php";
new AuroraAdmin\App\AuroraAdmin(); new AuroraAdmin\App\AuroraAdmin();
-50
View File
@@ -1,50 +0,0 @@
<?php
/**
* Generates admin/src/Licensing/integrity-manifest.json — sha256 hashes of
* the files that implement the free/paid gate (see Integrity.php), used to
* detect tampering in distributed copies of the plugin.
*
* Run against the STAGING directory right before zipping, not against the
* git checkout — the manifest is a build artifact (same "regenerate, don't
* version" pattern as app/dist) and must never ship in a git checkout,
* since Integrity::is_tampered() treats "no manifest present" as "this is
* a dev checkout, don't lock anyone out."
*
* Usage: php bin/build-integrity-manifest.php /path/to/staging/aurora-admin
*/
$root = $argv[1] ?? null;
if (!$root || !is_dir($root)) {
fwrite(STDERR, "Usage: php build-integrity-manifest.php <path-to-staged-plugin-root>\n");
exit(1);
}
$root = rtrim($root, "/") . "/";
// Keep in sync with Integrity::is_tampered()'s callers — every file that
// implements or directly enforces the license gate.
$protected = [
"aurora-admin.php",
"admin/src/App/AuroraAdmin.php",
"admin/src/Licensing/License.php",
"admin/src/Licensing/Integrity.php",
"admin/src/Pages/SubApps.php",
"admin/src/Analytics/Analytics.php",
"admin/src/Rest/AnalyticsData.php",
"admin/src/WhiteLabel/WhiteLabel.php",
"admin/src/Shell/Shell.php",
];
$manifest = [];
foreach ($protected as $relative) {
$full = $root . $relative;
if (!file_exists($full)) {
fwrite(STDERR, "Missing protected file, aborting: {$relative}\n");
exit(1);
}
$manifest[$relative] = hash_file("sha256", $full);
}
$out_path = $root . "admin/src/Licensing/integrity-manifest.json";
file_put_contents($out_path, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
echo "Wrote " . count($manifest) . " hashes to {$out_path}\n";
+135
View File
@@ -0,0 +1,135 @@
=== Aurora Admin ===
Contributors: TODO-WPORG-USERNAME
Tags: admin, dashboard, admin theme, dark mode, admin redesign
Requires at least: 5.5
Tested up to: 7.0
Requires PHP: 7.4
Stable tag: 3.6.1
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
A modern, fast redesign of the WordPress admin — real replacement screens for Posts, Pages, Media, Users, and more, plus a full dark mode, all free.
== Description ==
Aurora Admin replaces the default WordPress admin with a faster, cleaner interface — without touching how WordPress core actually works underneath. Every screen is a real, functional replacement (not a CSS reskin over the same slow markup): the same data, the same capabilities, the same URLs, just rebuilt.
**Real replacement screens**
* Posts, Pages, Media Library, Users, and Comments — full list/edit/delete flows
* Nav Menus, Widgets, Themes, and the core Settings screens (General, Writing, Reading, Discussion, Permalinks)
* Your profile page and the block editor get the same modern frame
**Power tools**
* Role Editor — visual capability editor for every role, with plain-English tooltips explaining what each capability actually does
* Activity Log — see who changed what, and when
* Media Folders — organize your Media Library into folders, drag-and-drop included
* Menu Creator — build and customize your own wp-admin sidebar menu
* Admin Notices — control which plugin nag notices you actually see
* White Label — rename/rebrand the admin for client sites
**Look and feel**
* A real dark mode covering the entire admin, not just a few screens
* 10 color presets plus 2 flat neutrals
* macOS-style window chrome for the block editor and other full-screen views
Everything in Aurora Admin is free — there's no license key, no locked features, and no upsell.
**Aurora File Manager, Aurora Database Explorer, and Aurora Site Backup (separate plugins)**
File Manager (browse/edit/manage your site's files from wp-admin), Database Explorer (browse your database tables and run read-only queries), and Site Backup (full site backup/restore, optionally to your own S3-compatible or Google Drive storage) previously shipped bundled inside Aurora Admin. All three are now distributed as their own standalone plugins — [Aurora File Manager](https://aurora.auroradragon.studio/file-manager), [Aurora Database Explorer](https://aurora.auroradragon.studio/database-explorer), and [Aurora Site Backup](https://aurora.auroradragon.studio/site-backup) — each installable with one click from Aurora's Settings → Add-ons screen. File-management/code-editing and direct database-query functionality of this kind isn't accepted into the WordPress.org Plugin Directory (see the [Plugin Developer FAQ](https://developer.wordpress.org/plugins/wordpress-org/plugin-developer-faq/#are-there-plugins-you-dont-accept)); Site Backup was split out proactively for the same class of reason, since restoring a backup means writing arbitrary files back into the site. Aurora Admin itself never contains any of that functionality.
= Privacy / external service =
The optional "Report a Bug" screen sends your bug description and basic diagnostics (plugin version, WordPress version, PHP version, active theme name, and your site's domain) to a feedback endpoint on auroraplugins.com when you choose to submit it. Nothing is sent automatically or in the background — this only happens if you fill out and submit that form yourself.
Settings → Add-ons' "Install & Activate" buttons for Aurora File Manager, Aurora Database Explorer, and Aurora Site Backup download that plugin's zip file from aurora.auroradragon.studio using WordPress's own plugin-installer API, the same mechanism the native Plugins → Add New screen uses for WordPress.org-hosted plugins. This only happens when you click one of those buttons — nothing is downloaded automatically or in the background. (Aurora Site Backup's own remote-storage options, if you install and configure it, are documented in that plugin's own readme.)
== Installation ==
1. Upload the plugin files to `/wp-content/plugins/aurora-admin`, or install it directly through the Plugins screen in WordPress.
2. Activate the plugin through the 'Plugins' screen in WordPress.
3. Visit the new "Aurora" menu item in your admin sidebar to explore settings and enable/disable individual replacement screens.
== Frequently Asked Questions ==
= Does this replace WordPress core, or is it a theme? =
Neither. Aurora Admin is a plugin that replaces individual wp-admin screens in place — the URLs don't change, and disabling the plugin returns you to the native WordPress screens exactly as they were.
= Is this really free, no paid tiers? =
Yes. Every feature listed above is available with no license key and no restrictions.
= Will this work with my other plugins? =
Aurora Admin only replaces the built-in WordPress admin screens it explicitly targets (Posts, Pages, Media, Users, Comments, Nav Menus, Widgets, Themes, core Settings screens, Profile, the block editor frame). Screens added by other plugins are untouched. Each replacement can also be toggled off individually in Aurora's settings if you'd rather keep the native screen for something specific.
= Where do I report a bug? =
Use the "Report a Bug" item in the Aurora sidebar menu, or open a thread in this plugin's support forum.
= Where did File Manager, Database Explorer, and Site Backup go? =
File Manager is now Aurora File Manager, Database Explorer is now Aurora Database Explorer, and Site Backup is now Aurora Site Backup — three separate standalone plugins, each installable with one click from Settings → Add-ons in Aurora Admin, or downloadable directly from [aurora.auroradragon.studio/file-manager](https://aurora.auroradragon.studio/file-manager), [aurora.auroradragon.studio/database-explorer](https://aurora.auroradragon.studio/database-explorer), and [aurora.auroradragon.studio/site-backup](https://aurora.auroradragon.studio/site-backup). File Manager and Database Explorer are distributed separately because WordPress.org's Plugin Directory policy doesn't allow file-management/code-editing or direct database-query functionality in a directory-listed plugin; Site Backup was split out proactively for the same class of reason (restoring a backup writes arbitrary files back into the site).
== Screenshots ==
1. The Aurora dashboard.
2. Role Editor with capability tooltips.
3. Media Library with folders, dark mode across the admin.
== Changelog ==
= 3.6.1 =
* Fixed another round of Plugin Check findings the 3.5.1 fix didn't fully cover: `WordPress.DB.DirectDatabaseQuery.DirectQuery`/`NoCaching` are attributed by PHPCS to the *outer* `$wpdb->get_results()`/`get_row()` call line, while `PreparedSQL.NotPrepared` is attributed to whichever *inner* line the interpolated table name appears on — two different lines in a multi-line call, so a single `phpcs:ignore` comment can only ever cover one of them. Collapsed the remaining multi-line calls in Menu Creator and Admin Notices to single physical lines so one ignore comment covers both sniffs' attributed lines at once. Re-verified with an automated scan plus live REST-dispatch round-trips (menu create/list/get/delete, notice create/render/delete).
= 3.6.0 =
* Site Backup has moved to its own standalone plugin, Aurora Site Backup — install it with one click from the Settings → Add-ons screen, alongside Aurora File Manager and Aurora Database Explorer. Restoring a backup means writing arbitrary files back into the site, the same class of risk that's kept file-management functionality out of Aurora Admin proper, so it now lives in a separately-distributed companion plugin.
= 3.5.1 =
* Fixed a fresh WordPress.org Plugin Check report: the previous compliance pass's `phpcs:ignore` annotations were placed above the *opening* line of several multi-line `$wpdb->get_results()`/`get_row()`/`get_var()` calls, but PHPCS attributes violations to the exact physical line where the interpolated tokens appear — often one or more lines further down inside the call — so those suppressions were never actually taking effect. Corrected across Menu Creator, Admin Notices, Site Backup, Media Folders, Analytics, and Media Library. Also fixed a real, unrelated bug found during verification: creating a menu in Menu Creator returned "Menu not found" instead of the newly created menu, because of how the internal follow-up request object was constructed.
* Compressed files, hidden files, and non-standard markdown files flagged by Plugin Check are scanner noise from scanning the whole development repository (build zips, `.gitignore`, `.claude/`, `PROJECT_STATUS.md`) — confirmed none of these exist in the actual submission zip.
= 3.5.0 =
* Database Explorer has moved to its own standalone plugin, Aurora Database Explorer — install it with one click from the Settings → Add-ons screen, alongside Aurora File Manager. Direct database-query tooling is treated by WordPress.org's Plugin Directory review the same way as file-management functionality, so rather than remove the capability outright, it now lives in a separately-distributed companion plugin, same as File Manager did in 3.4.0.
= 3.4.0 =
* File Manager has moved to its own standalone plugin, Aurora File Manager — install it with one click from the new Settings → Add-ons screen. WordPress.org's Plugin Directory policy doesn't allow file-management/code-editing functionality in a directory-listed plugin regardless of the safeguards in place, so rather than remove the capability outright, it now lives in a separately-distributed companion plugin.
= 3.3.2 =
* Compliance pass against the official WordPress.org Plugin Check tool: fixed a readme.txt version mismatch, a missing input-sanitization call, and annotated ~230 custom-database-table query warnings with the justification the review process expects (Aurora's backup/analytics/activity-log/menu/notices/media-folders features all use their own tables, which necessarily means direct, non-core-wrapped database access).
= 3.3.1 =
* Fixed the Settings page: the category list and the settings panel now scroll independently, so picking a category from a scrolled-down spot in the list no longer leaves the panel showing stale, off-screen content above the fold.
= 3.3.0 =
* Added Modern Image Formats: automatically generates a WebP or AVIF copy of every uploaded JPEG/PNG image and serves it via a <picture> element with the original as a fallback, configurable under Settings.
= 3.2.1 =
* Security hardening: File Manager no longer allows opening or saving executable file types (PHP and similar) for a file that already exists — only creating, uploading, and renaming to those types were previously blocked. File Manager now also honors the DISALLOW_FILE_EDIT and DISALLOW_FILE_MODS constants, the same host-level lockdown WordPress core's own file-editing and update/install flows respect.
= 3.2.0 =
* Added Google Drive as a second remote storage option for Site Backup, alongside S3-compatible storage. Connect your own Google Cloud OAuth client in Settings to store backups in your own Google Drive account.
= 3.1.0 =
* Added Site Backup: full site backup (database + files) and restore-in-place, on demand or on a recurring daily/weekly/monthly schedule, stored locally or on your own S3-compatible remote storage (AWS S3, Backblaze B2, Cloudflare R2, DigitalOcean Spaces, and similar).
= 3.0.2 =
* Fixed a plugin-header validation error: Plugin URI and Author URI were identical, which the WordPress.org submission checker requires to be distinct. Plugin URI now points to the plugin's own site (aurora.auroradragon.studio); Author URI stays on the studio's site.
= 3.0.1 =
* Compliance and hardening pass ahead of the WordPress.org submission: replaced a forbidden core function in the Widgets screen, converted File Manager's file operations to use the WP_Filesystem API, added nonce/sanitization coverage to a few read paths, fixed missing translator comments, and annotated custom-table SQL queries for the plugin review process.
= 3.0.0 =
* Aurora Admin is now completely free — the entire licensing system was removed, and every previously-paid feature (Role Editor, Activity Log, Database Explorer, File Manager, Analytics, White Label, Full Admin Takeover, Media Folders) is now available to everyone.
= 2.27.0 and earlier =
* Pre-release beta versions, not distributed on WordPress.org.
== Upgrade Notice ==
= 3.0.0 =
Aurora Admin is now 100% free. If you were on an earlier beta with a license key, it's no longer needed — all features are unlocked automatically.