Add real replacement pages for Pages, Media Library, and Users (v2.6.0)

Completes the "skinned vs. replaced" conversion started with Posts —
every native list-table screen the user asked about now has a genuine
custom Vue page behind it instead of dark-themed CSS over the native
markup. All three follow the same pattern as Posts: own REST controller
in admin/src/Rest/, own Vue page matching the Comments layout, native
screen redirected away (direct visits too, not just the sidebar link),
gated by its own Settings toggle defaulting to on.

- Pages (PagesData.php + Pages.vue): same as Posts, parent page shown
  instead of categories. edit.php?post_type=page redirects; other post
  types untouched.
- Media Library (MediaData.php + Media.vue): thumbnail grid with type
  filters (Images/Audio/Video/Documents/Unattached). Replaces the
  browse/library experience only — media-new.php (the actual upload
  screen) is untouched, same reasoning as leaving the block editor
  alone for Posts/Pages.
- Users (UsersData.php + Users.vue): filterable by role. Deleting a
  user always reassigns their content to whoever performs the deletion
  rather than exposing WP core's reassign-or-delete-everything choice
  as a one-click action — deliberately simplified to avoid a surprising,
  highly destructive default. user-new.php untouched.

Verified live: Media's grid renders real thumbnails/dimensions/file
sizes with working filters and search; Users shows real accounts with
role filters and correctly hides delete for the current user; both
redirect correctly from their native URLs.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
nixpanel
2026-07-05 06:40:22 -05:00
co-authored by Claude Sonnet 5
parent 56b6674527
commit 204c4365e2
18 changed files with 2542 additions and 44 deletions
+211
View File
@@ -0,0 +1,211 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's custom Media Library page — same list-table
* pattern as Posts/Pages/Comments. Deliberately doesn't rebuild the
* upload flow (drag-drop, chunked uploads, the grid picker used inside
* post editors) — "Add Media File" still opens the native media-new.php,
* same way Posts'/Pages' "Add" buttons still open the native, already
* themed block editor. This replaces the *library/browse* experience,
* not uploading.
*/
class MediaData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/media", [
"methods" => "GET",
"callback" => [self::class, "list_media"],
"permission_callback" => function () {
return current_user_can("upload_files");
},
"args" => [
"type" => ["type" => "string", "required" => false],
"search" => ["type" => "string", "required" => false],
"page" => ["type" => "integer", "required" => false],
"perPage" => ["type" => "integer", "required" => false],
],
]);
register_rest_route("aurora-admin/v1", "/media/(?P<id>\d+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_media"],
"permission_callback" => [self::class, "can_delete_route_media"],
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
}
public static function can_delete_route_media($request)
{
$id = (int) $request["id"];
return $id && current_user_can("delete_post", $id);
}
public static function list_media($request)
{
$type = self::query_mime_type($request->get_param("type"));
$page = max(1, (int) ($request->get_param("page") ?: 1));
$per_page = min(100, max(10, (int) ($request->get_param("perPage") ?: 24)));
$search = sanitize_text_field((string) $request->get_param("search"));
$args = [
"post_type" => "attachment",
"post_status" => "inherit",
"posts_per_page" => $per_page,
"paged" => $page,
"orderby" => "date",
"order" => "DESC",
];
if ($type === "unattached") {
$args["post_parent"] = 0;
} elseif ($type !== "") {
$args["post_mime_type"] = $type;
}
if ($search !== "") {
$args["s"] = $search;
}
$query = new \WP_Query($args);
return new \WP_REST_Response([
"items" => array_map([self::class, "serialize_attachment"], $query->posts),
"stats" => self::stats(),
"pagination" => [
"page" => $page,
"perPage" => $per_page,
"total" => (int) $query->found_posts,
"totalPages" => max(1, (int) $query->max_num_pages),
],
], 200);
}
public static function delete_media($request)
{
$id = (int) $request["id"];
$post = get_post($id);
if (!$post || $post->post_type !== "attachment") {
return new \WP_Error("aurora_media_invalid", __("Invalid media item.", "aurora-admin"), ["status" => 400]);
}
$result = wp_delete_attachment($id, true);
if (!$result) {
return new \WP_Error("aurora_media_delete_failed", __("Could not delete the media item.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Media item deleted.", "aurora-admin")], 200);
}
private static function serialize_attachment($post)
{
$author = get_userdata($post->post_author);
$mime = $post->post_mime_type;
$is_image = wp_attachment_is_image($post->ID);
$meta = wp_get_attachment_metadata($post->ID);
$file_path = get_attached_file($post->ID);
$parent_title = "";
$parent_edit_url = "";
if ($post->post_parent) {
$parent_title = get_the_title($post->post_parent);
$parent_edit_url = get_edit_post_link($post->post_parent, "raw") ?: "";
}
return [
"id" => (int) $post->ID,
"title" => get_the_title($post) ?: basename($file_path),
"filename" => basename($file_path),
"mimeType" => $mime,
"typeLabel" => self::type_label($mime),
"isImage" => $is_image,
"thumbnailUrl" => $is_image ? (wp_get_attachment_image_url($post->ID, "thumbnail") ?: "") : "",
"fileUrl" => wp_get_attachment_url($post->ID) ?: "",
"dimensions" => $is_image && !empty($meta["width"]) ? "{$meta['width']} × {$meta['height']}" : "",
"fileSize" => !empty($meta["filesize"]) ? size_format($meta["filesize"]) : "",
"author" => $author ? $author->display_name : __("Unknown", "aurora-admin"),
"attachedToTitle" => $parent_title,
"attachedToEditUrl" => $parent_edit_url,
"date" => get_the_date("M j, Y", $post),
"editUrl" => get_edit_post_link($post->ID, "raw") ?: "",
"canDelete" => current_user_can("delete_post", $post->ID),
];
}
private static function stats()
{
global $wpdb;
$counts = [
"all" => (int) wp_count_posts("attachment")->inherit,
"image" => 0,
"audio" => 0,
"video" => 0,
"application" => 0,
"unattached" => 0,
];
$rows = $wpdb->get_results(
"SELECT post_mime_type, COUNT(*) AS c FROM {$wpdb->posts}
WHERE post_type = 'attachment' AND post_status = 'inherit'
GROUP BY post_mime_type"
);
foreach ($rows as $row) {
$group = strtok((string) $row->post_mime_type, "/");
if (isset($counts[$group])) {
$counts[$group] += (int) $row->c;
} else {
$counts["application"] += (int) $row->c;
}
}
$counts["unattached"] = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->posts}
WHERE post_type = 'attachment' AND post_status = 'inherit' AND post_parent = 0"
);
return $counts;
}
private static function query_mime_type($type)
{
switch ($type) {
case "image":
case "audio":
case "video":
return $type;
case "application":
return "application";
case "unattached":
return "unattached";
case "all":
default:
return "";
}
}
private static function type_label($mime)
{
$group = strtok((string) $mime, "/");
switch ($group) {
case "image":
return __("Image", "aurora-admin");
case "audio":
return __("Audio", "aurora-admin");
case "video":
return __("Video", "aurora-admin");
default:
return __("Document", "aurora-admin");
}
}
}
+230
View File
@@ -0,0 +1,230 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's custom Pages page — same pattern as
* PostsData.php, adapted for the "page" post type (no categories, but
* shows the parent page instead).
*/
class PagesData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/pages", [
"methods" => "GET",
"callback" => [self::class, "list_pages"],
"permission_callback" => function () {
return current_user_can("edit_pages");
},
"args" => [
"status" => ["type" => "string", "required" => false],
"search" => ["type" => "string", "required" => false],
"page" => ["type" => "integer", "required" => false],
"perPage" => ["type" => "integer", "required" => false],
],
]);
register_rest_route("aurora-admin/v1", "/pages/trash", [
"methods" => "POST",
"callback" => [self::class, "trash_page"],
"permission_callback" => [self::class, "can_edit_param_page"],
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/pages/restore", [
"methods" => "POST",
"callback" => [self::class, "restore_page"],
"permission_callback" => [self::class, "can_edit_param_page"],
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/pages/(?P<id>\d+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_page"],
"permission_callback" => [self::class, "can_delete_route_page"],
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
}
public static function can_edit_param_page($request)
{
$id = (int) $request->get_param("id");
return $id && current_user_can("edit_page", $id);
}
public static function can_delete_route_page($request)
{
$id = (int) $request["id"];
return $id && current_user_can("delete_page", $id);
}
public static function list_pages($request)
{
$status = self::query_status($request->get_param("status"));
$page = max(1, (int) ($request->get_param("page") ?: 1));
$per_page = min(100, max(10, (int) ($request->get_param("perPage") ?: 20)));
$search = sanitize_text_field((string) $request->get_param("search"));
$args = [
"post_type" => "page",
"post_status" => $status,
"posts_per_page" => $per_page,
"paged" => $page,
"orderby" => "title",
"order" => "ASC",
"ignore_sticky_posts" => true,
];
if ($search !== "") {
$args["s"] = $search;
}
$query = new \WP_Query($args);
return new \WP_REST_Response([
"items" => array_map([self::class, "serialize_page"], $query->posts),
"stats" => self::stats(),
"pagination" => [
"page" => $page,
"perPage" => $per_page,
"total" => (int) $query->found_posts,
"totalPages" => max(1, (int) $query->max_num_pages),
],
], 200);
}
public static function trash_page($request)
{
$id = (int) $request->get_param("id");
$post = get_post($id);
if (!$post || $post->post_type !== "page") {
return new \WP_Error("aurora_page_invalid", __("Invalid page.", "aurora-admin"), ["status" => 400]);
}
$result = wp_trash_post($id);
if (!$result) {
return new \WP_Error("aurora_page_trash_failed", __("Could not trash the page.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Page moved to trash.", "aurora-admin")], 200);
}
public static function restore_page($request)
{
$id = (int) $request->get_param("id");
$post = get_post($id);
if (!$post || $post->post_type !== "page") {
return new \WP_Error("aurora_page_invalid", __("Invalid page.", "aurora-admin"), ["status" => 400]);
}
$result = wp_untrash_post($id);
if (!$result) {
return new \WP_Error("aurora_page_restore_failed", __("Could not restore the page.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Page restored.", "aurora-admin")], 200);
}
public static function delete_page($request)
{
$id = (int) $request["id"];
$post = get_post($id);
if (!$post || $post->post_type !== "page") {
return new \WP_Error("aurora_page_invalid", __("Invalid page.", "aurora-admin"), ["status" => 400]);
}
$result = wp_delete_post($id, true);
if (!$result) {
return new \WP_Error("aurora_page_delete_failed", __("Could not delete the page.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Page deleted.", "aurora-admin")], 200);
}
private static function serialize_page($post)
{
$author = get_userdata($post->post_author);
$status = $post->post_status;
$parent_title = $post->post_parent ? get_the_title($post->post_parent) : "";
return [
"id" => (int) $post->ID,
"title" => get_the_title($post) ?: __("(No title)", "aurora-admin"),
"author" => $author ? $author->display_name : __("Unknown", "aurora-admin"),
"parentTitle" => $parent_title,
"commentCount" => (int) $post->comment_count,
"date" => get_the_date("M j, Y", $post),
"dateLabel" =>
$status === "publish"
? __("Published", "aurora-admin")
: ($status === "future"
? __("Scheduled", "aurora-admin")
: __("Last Modified", "aurora-admin")),
"status" => $status,
"statusLabel" => self::status_label($status),
"editUrl" => get_edit_post_link($post->ID, "raw") ?: "",
"viewUrl" => $status === "publish" ? get_permalink($post) : get_preview_post_link($post),
"canDelete" => current_user_can("delete_page", $post->ID),
];
}
private static function stats()
{
$counts = wp_count_posts("page");
return [
"all" => (int) $counts->publish + (int) $counts->draft + (int) $counts->pending + (int) $counts->private + (int) $counts->future,
"publish" => (int) $counts->publish,
"draft" => (int) $counts->draft,
"pending" => (int) $counts->pending,
"trash" => (int) ($counts->trash ?? 0),
];
}
private static function query_status($status)
{
switch ($status) {
case "publish":
case "draft":
case "pending":
case "trash":
return $status;
case "all":
default:
return ["publish", "draft", "pending", "private", "future"];
}
}
private static function status_label($status)
{
switch ($status) {
case "publish":
return __("Published", "aurora-admin");
case "draft":
return __("Draft", "aurora-admin");
case "pending":
return __("Pending", "aurora-admin");
case "future":
return __("Scheduled", "aurora-admin");
case "private":
return __("Private", "aurora-admin");
case "trash":
return __("Trash", "aurora-admin");
default:
return ucfirst($status);
}
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's custom Users page — same list-table pattern
* as Posts/Pages/Comments/Media. "Add New User" still opens the native
* user-new.php (account creation involves email verification/role
* assignment forms not worth rebuilding, same reasoning as leaving
* media-new.php/post-new.php alone for Media/Posts/Pages).
*
* Deleting a user reassigns their content to whichever admin performs the
* deletion, rather than presenting WP core's "delete or reassign to
* someone else" choice screen — a deliberate simplification, not an
* oversight: wp_delete_user() with a null $reassign deletes all of the
* user's content too, which is much more destructive and surprising as a
* one-click REST action than always reassigning safely.
*/
class UsersData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/users", [
"methods" => "GET",
"callback" => [self::class, "list_users"],
"permission_callback" => function () {
return current_user_can("list_users");
},
"args" => [
"role" => ["type" => "string", "required" => false],
"search" => ["type" => "string", "required" => false],
"page" => ["type" => "integer", "required" => false],
"perPage" => ["type" => "integer", "required" => false],
],
]);
register_rest_route("aurora-admin/v1", "/users/(?P<id>\d+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_user"],
"permission_callback" => function ($request) {
$id = (int) $request["id"];
return $id && current_user_can("delete_user", $id) && $id !== get_current_user_id();
},
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
}
public static function list_users($request)
{
$role = sanitize_key((string) $request->get_param("role"));
$page = max(1, (int) ($request->get_param("page") ?: 1));
$per_page = min(100, max(10, (int) ($request->get_param("perPage") ?: 20)));
$search = sanitize_text_field((string) $request->get_param("search"));
$args = [
"number" => $per_page,
"offset" => ($page - 1) * $per_page,
"orderby" => "display_name",
"order" => "ASC",
"count_total" => true,
];
if ($role !== "" && $role !== "all") {
$args["role"] = $role;
}
if ($search !== "") {
$args["search"] = "*{$search}*";
$args["search_columns"] = ["user_login", "user_email", "display_name"];
}
$query = new \WP_User_Query($args);
return new \WP_REST_Response([
"items" => array_map([self::class, "serialize_user"], $query->get_results()),
"stats" => self::stats(),
"pagination" => [
"page" => $page,
"perPage" => $per_page,
"total" => (int) $query->get_total(),
"totalPages" => max(1, (int) ceil($query->get_total() / $per_page)),
],
], 200);
}
public static function delete_user($request)
{
$id = (int) $request["id"];
$user = get_userdata($id);
if (!$user) {
return new \WP_Error("aurora_user_invalid", __("Invalid user.", "aurora-admin"), ["status" => 400]);
}
require_once ABSPATH . "wp-admin/includes/user.php";
$result = wp_delete_user($id, get_current_user_id());
if (!$result) {
return new \WP_Error("aurora_user_delete_failed", __("Could not delete the user.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("User deleted; their content was reassigned to you.", "aurora-admin")], 200);
}
private static function serialize_user($user)
{
$roles = array_map("translate_user_role", $user->roles);
return [
"id" => (int) $user->ID,
"username" => $user->user_login,
"displayName" => $user->display_name,
"email" => $user->user_email,
"avatarUrl" => get_avatar_url($user->ID, ["size" => 64]),
"roles" => array_values($roles),
"postCount" => (int) count_user_posts($user->ID, "post"),
"registeredDate" => mysql2date("M j, Y", $user->user_registered),
"editUrl" => get_edit_user_link($user->ID),
"canDelete" => current_user_can("delete_user", $user->ID) && $user->ID !== get_current_user_id(),
];
}
private static function stats()
{
$counts = count_users();
$by_role = [];
foreach ($counts["avail_roles"] as $role => $count) {
$by_role[$role] = (int) $count;
}
return [
"all" => (int) $counts["total_users"],
"byRole" => $by_role,
];
}
}