From 204c4365e2264462125b8a696b25a9fcaca2ad1c Mon Sep 17 00:00:00 2001 From: nixpanel Date: Sun, 5 Jul 2026 06:40:22 -0500 Subject: [PATCH] Add real replacement pages for Pages, Media Library, and Users (v2.6.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 27 + PROJECT_STATUS.md | 110 ++-- admin/src/App/AuroraAdmin.php | 6 + admin/src/Pages/SubApps.php | 59 +++ admin/src/Rest/MediaData.php | 211 ++++++++ admin/src/Rest/PagesData.php | 230 +++++++++ admin/src/Rest/UsersData.php | 142 ++++++ admin/src/Shell/MenuSerializer.php | 12 + app/package.json | 2 +- app/src/entries/media.js | 10 + app/src/entries/pages.js | 10 + app/src/entries/users.js | 10 + app/src/pages/media/Media.vue | 519 +++++++++++++++++++ app/src/pages/pages/Pages.vue | 622 +++++++++++++++++++++++ app/src/pages/settings/settingsConfig.js | 37 ++ app/src/pages/users/Users.vue | 569 +++++++++++++++++++++ app/vite.config.js | 3 + aurora-admin.php | 7 +- 18 files changed, 2542 insertions(+), 44 deletions(-) create mode 100644 admin/src/Rest/MediaData.php create mode 100644 admin/src/Rest/PagesData.php create mode 100644 admin/src/Rest/UsersData.php create mode 100644 app/src/entries/media.js create mode 100644 app/src/entries/pages.js create mode 100644 app/src/entries/users.js create mode 100644 app/src/pages/media/Media.vue create mode 100644 app/src/pages/pages/Pages.vue create mode 100644 app/src/pages/users/Users.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index ec8d48f..9636529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ version bump. Starts at 2.1.0 — earlier history (the theming rebuild, window chrome/taskbar, Site Editor support, etc.) lives in `git log` and `PROJECT_STATUS.md`, not reconstructed here. +## [2.6.0] - 2026-07-05 + +### Added +- Real replacement pages for Pages, Media Library, and Users — the + remaining native admin screens that were only dark-themed skinning, + now built the same way as Posts/Comments/Plugins (own REST controller, + own Vue page, native screen redirected away): + - **Pages** (`PagesData.php` + `Pages.vue`): same as Posts, but shows + the parent page instead of categories. `edit.php?post_type=page` + redirects; other post types are untouched. + - **Media Library** (`MediaData.php` + `Media.vue`): a thumbnail grid + (not a rebuilt drag-drop uploader) with type filters (Images/Audio/ + Video/Documents/Unattached), search, and delete. `upload.php` + (both grid and list mode) redirects; `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, with + edit/delete actions. 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 REST action — + deliberately simplified to avoid a surprising, highly destructive + default. `users.php` redirects; `user-new.php` (account creation) + is untouched. + - All four gated by their own Settings toggle (`modern_pages`, + `modern_media`, `modern_users` — `modern_posts` already existed), + each defaulting to on. + ## [2.5.0] - 2026-07-05 ### Added diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 8abe22b..252533c 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -1,6 +1,6 @@ # Aurora Admin v2 — Project Status -_Last updated: 2026-07-05 (latest session — real Posts replacement page, first of the "skinned vs. replaced" pages to actually get replaced)_ +_Last updated: 2026-07-05 (latest session — Pages/Media/Users replacement pages, completing the "skinned vs. replaced" conversion started with Posts)_ ## What this project is @@ -149,51 +149,79 @@ get back up to speed instead of relying on prior chat history. ## Skinned vs. replaced native pages (important distinction, ask before assuming) -Not every native WP admin page gets the same treatment, and the user has -explicitly said they don't want the "skinned" approach used more broadly -without a real replacement behind it — check this list before describing -a page as "already handled": +The user explicitly said they didn't want the "skinned" approach used +without a real replacement behind it. As of this session, **every native +list-table screen has been converted** — there's no more "just skinned" +category left among the pages that had one: - **Actually replaced** — the native page never renders; a real custom Vue page (own REST controller, own list/detail UI) takes over entirely: Comments, Installed Plugins, Menu Creator, Admin Notices, Role Editor, - Activity Log, Database Explorer, and now **Posts** (added this session - — see below). -- **Just skinned** — the real native WordPress page still renders; only - `admin/assets/native-dark.css` restyles it dark. Same underlying - markup, same native limitations, no new functionality: Pages, Media, - Users, and every other native screen not listed above. + Activity Log, Database Explorer, **Posts, Pages, Media Library, and + Users** (the last four all added this session, in that order — Posts + first as its own commit/version, then Pages/Media/Users together). +- **Deliberately still native** (not a "skinning" gap — these are specific + screens/actions, not list-tables, and rebuilding them wasn't in scope): + the block editor (`post.php`/`post-new.php` — dark-themed chrome only, + per "Window chrome" above), `media-new.php` (upload/drag-drop), + `user-new.php` (account creation), individual edit screens reached from + a list row (`user-edit.php`, comment/media edit), Settings/Profile + pages, the Site Editor. If a new "why isn't X replaced" question comes + up, check whether it's actually one of these deliberate exceptions + before treating it as a gap. -**Posts** (`admin/src/Rest/PostsData.php` + `app/src/pages/posts/Posts.vue`, -entry `posts.js`) was the first of these converted from skinned to -replaced, built directly off the Comments pattern: -- REST: GET `/posts` (status/search/pagination, matching native filters: - all/publish/draft/pending/trash), POST `/posts/trash`, POST - `/posts/restore`, DELETE `/posts/{id}` (permanent). Capability checks - mirror native (`edit_posts` to list, `edit_post`/`delete_post` per-item). -- `SubApps.php`: registered as `aurora-admin-posts`, parented under - `edit.php` (same pattern as Plugins under `plugins.php`) so it shares - the native "Posts" top-level menu's `hook_suffix` slot. -- `SubApps::redirect_native_pages()`: visiting `edit.php` directly (typed - URL, bookmark — not just the sidebar link) redirects to the Aurora page, - gated on **only** the default post type (`post_type=post` or absent) — - `edit.php?post_type=page` (Pages) and any other custom post type's list - screen is explicitly left alone, since only Posts has a replacement. -- `MenuSerializer.php`: the sidebar's own "Posts"/"All Posts" links - (both share the literal `edit.php` slug) get rewritten the same way - Plugins'/Comments' links already were. -- Gated by Settings > Posts' "Modern posts table" toggle — this is the - same toggle that got **removed** two commits ago for describing a - feature that didn't exist; it's back now that it does, defaulting to - on. `modern_plugins`/`modern_comments` also got a `default: true` UI - fix at the same time (they already defaulted true server-side, but the - Settings UI toggle showed off until explicitly saved — cosmetic - mismatch, now consistent). -- **Not done**: Pages, Media, Users remain skinned-only. Each would be a - comparable-sized build to Posts/Comments (own REST controller, own - list/detail UI) — ask the user before starting one, per the pattern - established this session (they picked "Posts first, then expand" over - doing all four at once). +All four of this session's pages (`PostsData.php`+`Posts.vue`, +`PagesData.php`+`Pages.vue`, `MediaData.php`+`Media.vue`, +`UsersData.php`+`Users.vue`) follow one shared pattern, established with +Posts and repeated for the other three: +- A REST controller in `admin/src/Rest/` with list (status/type/role + + search + pagination, matching native filter tabs) and action endpoints + (trash/restore/delete, or just delete for Media/Users, which don't have + a trash state in the same way). Capability checks mirror WP core's own + for that screen (`edit_posts`/`edit_pages`/`upload_files`/`list_users` + to view; per-item `edit_post`/`delete_post`/`delete_page`/`delete_user` + for actions). +- A Vue page in `app/src/pages//` + entry in `app/src/entries/` + + registered in `app/vite.config.js`, visually matching the Comments + layout (filter pills, search, table with inline row actions, + pagination) — Media is the one exception, using a thumbnail grid + instead of a table, since that's what a media library actually needs. +- `SubApps.php`: new `PAGES` entry, `parent_slug()`/`capability()` cases, + and a branch in `redirect_native_pages()` — the native screen's file + (`edit.php`, `edit.php?post_type=page`, `upload.php`, `users.php`) + redirects to the Aurora page on **direct visits** (typed URL/bookmark), + not just the sidebar link. +- `MenuSerializer.php`: a matching rewrite so the sidebar's own link + (which shares the same slug as the parent, per WP convention) points at + the Aurora page too. +- A Settings toggle (`modern_posts`/`modern_pages`/`modern_media`/ + `modern_users`, all `default: true`) gating the redirect+link-rewrite, + so the native screen can still be reached if turned off. `modern_pages`/ + `modern_media`/`modern_users` are new categories/fields this session; + `modern_posts` already existed (see below). All the `modern_*` toggles + now consistently set `default: true` in the Settings UI to match their + actual PHP default (previously some defaulted true server-side but + showed off in the UI until explicitly saved — cosmetic mismatch, fixed + across the board). + +Two deliberate scope decisions worth remembering if asked "why doesn't X +work": +- **Media**: replaces the *library/browse* experience only — uploading + (`media-new.php`, drag-drop/chunked uploads) was left alone entirely, + same reasoning as leaving the block editor alone for Posts/Pages. +- **Users**: deleting a user always reassigns their content to whoever + performs the deletion, rather than exposing WP core's "delete this + user's content too, or reassign to someone else" choice screen as a + one-click REST action — the null-reassign path in `wp_delete_user()` is + much more destructive than is safe to offer with one click, so it's not + offered at all. + +`modern_posts`/`modern_plugins`/`modern_comments` were themselves each +briefly **removed** earlier in this same session (when they had no +implementation behind them) and re-added once real pages existed — if +you ever see a settings field described as removed for "describing a +feature that didn't exist," check whether it's since been rebuilt before +assuming it's still missing. ## Theme system (rewrite — supersedes the old dark_mode 3-state model) diff --git a/admin/src/App/AuroraAdmin.php b/admin/src/App/AuroraAdmin.php index 174c332..26aebf3 100644 --- a/admin/src/App/AuroraAdmin.php +++ b/admin/src/App/AuroraAdmin.php @@ -12,6 +12,9 @@ use AuroraAdmin\Rest\AnalyticsData; use AuroraAdmin\Rest\PluginsData; use AuroraAdmin\Rest\CommentsData; use AuroraAdmin\Rest\PostsData; +use AuroraAdmin\Rest\PagesData; +use AuroraAdmin\Rest\MediaData; +use AuroraAdmin\Rest\UsersData; use AuroraAdmin\Posts\ScheduledDayFilter; use AuroraAdmin\WhiteLabel\WhiteLabel; use AuroraAdmin\Pages\SubApps; @@ -49,6 +52,9 @@ class AuroraAdmin new PluginsData(); new CommentsData(); new PostsData(); + new PagesData(); + new MediaData(); + new UsersData(); new ScheduledDayFilter(); new WhiteLabel(); new SubApps(); diff --git a/admin/src/Pages/SubApps.php b/admin/src/Pages/SubApps.php index ea8f70a..9f5eb1c 100644 --- a/admin/src/Pages/SubApps.php +++ b/admin/src/Pages/SubApps.php @@ -47,6 +47,9 @@ class SubApps "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 = []; @@ -82,6 +85,15 @@ class SubApps 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"; } @@ -93,6 +105,15 @@ class SubApps 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"; } @@ -146,6 +167,41 @@ class SubApps 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() @@ -178,6 +234,9 @@ class SubApps "roles" => $roles, "tablePrefix" => $wpdb->prefix, "newPostUrl" => esc_url_raw(admin_url("post-new.php")), + "newPageUrl" => esc_url_raw(admin_url("post-new.php?post_type=page")), + "newMediaUrl" => esc_url_raw(admin_url("media-new.php")), + "newUserUrl" => esc_url_raw(admin_url("user-new.php")), ]; $root_id = "aurora-admin-" . self::PAGES[$page]["entry"] . "-root"; ?> diff --git a/admin/src/Rest/MediaData.php b/admin/src/Rest/MediaData.php new file mode 100644 index 0000000..685c8a7 --- /dev/null +++ b/admin/src/Rest/MediaData.php @@ -0,0 +1,211 @@ + "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\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"); + } + } +} diff --git a/admin/src/Rest/PagesData.php b/admin/src/Rest/PagesData.php new file mode 100644 index 0000000..6d54eea --- /dev/null +++ b/admin/src/Rest/PagesData.php @@ -0,0 +1,230 @@ + "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\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); + } + } +} diff --git a/admin/src/Rest/UsersData.php b/admin/src/Rest/UsersData.php new file mode 100644 index 0000000..d7f8307 --- /dev/null +++ b/admin/src/Rest/UsersData.php @@ -0,0 +1,142 @@ + "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\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, + ]; + } +} diff --git a/admin/src/Shell/MenuSerializer.php b/admin/src/Shell/MenuSerializer.php index d05ec71..4c9827b 100644 --- a/admin/src/Shell/MenuSerializer.php +++ b/admin/src/Shell/MenuSerializer.php @@ -162,6 +162,18 @@ class MenuSerializer 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. if (preg_match('#^https?://#', $slug)) { return $slug; diff --git a/app/package.json b/app/package.json index 02c2ceb..fbe3784 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "aurora-admin-app", - "version": "2.5.0", + "version": "2.6.0", "private": true, "type": "module", "scripts": { diff --git a/app/src/entries/media.js b/app/src/entries/media.js new file mode 100644 index 0000000..ca26cc5 --- /dev/null +++ b/app/src/entries/media.js @@ -0,0 +1,10 @@ +import { createApp } from 'vue'; +import '@/shell/shell-frame.css'; +import Media from '@/pages/media/Media.vue'; + +const mountEl = document.getElementById('aurora-admin-media-root'); + +if (mountEl) { + const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}'); + createApp(Media, { serverData: data }).mount(mountEl); +} diff --git a/app/src/entries/pages.js b/app/src/entries/pages.js new file mode 100644 index 0000000..3f4b6d7 --- /dev/null +++ b/app/src/entries/pages.js @@ -0,0 +1,10 @@ +import { createApp } from 'vue'; +import '@/shell/shell-frame.css'; +import Pages from '@/pages/pages/Pages.vue'; + +const mountEl = document.getElementById('aurora-admin-pages-root'); + +if (mountEl) { + const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}'); + createApp(Pages, { serverData: data }).mount(mountEl); +} diff --git a/app/src/entries/users.js b/app/src/entries/users.js new file mode 100644 index 0000000..999f82e --- /dev/null +++ b/app/src/entries/users.js @@ -0,0 +1,10 @@ +import { createApp } from 'vue'; +import '@/shell/shell-frame.css'; +import Users from '@/pages/users/Users.vue'; + +const mountEl = document.getElementById('aurora-admin-users-root'); + +if (mountEl) { + const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}'); + createApp(Users, { serverData: data }).mount(mountEl); +} diff --git a/app/src/pages/media/Media.vue b/app/src/pages/media/Media.vue new file mode 100644 index 0000000..852908b --- /dev/null +++ b/app/src/pages/media/Media.vue @@ -0,0 +1,519 @@ + + + + + diff --git a/app/src/pages/pages/Pages.vue b/app/src/pages/pages/Pages.vue new file mode 100644 index 0000000..41efcdd --- /dev/null +++ b/app/src/pages/pages/Pages.vue @@ -0,0 +1,622 @@ + + + + + diff --git a/app/src/pages/settings/settingsConfig.js b/app/src/pages/settings/settingsConfig.js index 5004b4f..644e7d8 100644 --- a/app/src/pages/settings/settingsConfig.js +++ b/app/src/pages/settings/settingsConfig.js @@ -167,6 +167,21 @@ export const settingsCategories = [ }, ], }, + { + id: 'pages', + title: 'Pages', + subtitle: 'Page management and display preferences', + icon: 'dashicons-admin-page', + fields: [ + { + key: 'modern_pages', + type: 'toggle', + label: 'Modern pages table', + description: 'Use the Aurora pages management interface.', + default: true, + }, + ], + }, { id: 'plugins', title: 'Plugins', @@ -224,6 +239,13 @@ export const settingsCategories = [ subtitle: 'Media library and upload management settings', icon: 'dashicons-admin-media', fields: [ + { + key: 'modern_media', + type: 'toggle', + label: 'Modern media library', + description: 'Use the Aurora media library interface.', + default: true, + }, { key: 'enable_svg', type: 'toggle', @@ -232,6 +254,21 @@ export const settingsCategories = [ }, ], }, + { + id: 'users', + title: 'Users', + subtitle: 'User management and administration settings', + icon: 'dashicons-admin-users', + fields: [ + { + key: 'modern_users', + type: 'toggle', + label: 'Modern users page', + description: 'Use the Aurora users management interface.', + default: true, + }, + ], + }, { id: 'comments', title: 'Comments', diff --git a/app/src/pages/users/Users.vue b/app/src/pages/users/Users.vue new file mode 100644 index 0000000..05597ca --- /dev/null +++ b/app/src/pages/users/Users.vue @@ -0,0 +1,569 @@ + + + + + diff --git a/app/vite.config.js b/app/vite.config.js index 4d55e7a..3c9daa9 100644 --- a/app/vite.config.js +++ b/app/vite.config.js @@ -31,6 +31,9 @@ export default defineConfig({ plugins: fileURLToPath(new URL('./src/entries/plugins.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)), + pages: fileURLToPath(new URL('./src/entries/pages.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)), 'locked-feature': fileURLToPath(new URL('./src/entries/locked-feature.js', import.meta.url)), }, output: { diff --git a/aurora-admin.php b/aurora-admin.php index 40121ec..52050f0 100644 --- a/aurora-admin.php +++ b/aurora-admin.php @@ -3,7 +3,7 @@ * Plugin Name: Aurora Admin * Plugin URI: https://aurora.co * Description: A modern, fast WordPress admin interface. - * Version: 2.5.0 + * Version: 2.6.0 * Author: Aurora * Text Domain: aurora-admin * Domain Path: /languages/ @@ -14,7 +14,7 @@ defined("ABSPATH") || exit(); -define("AURORA_ADMIN_VERSION", "2.5.0"); +define("AURORA_ADMIN_VERSION", "2.6.0"); define("AURORA_ADMIN_PATH", plugin_dir_path(__FILE__)); define("AURORA_ADMIN_URL", plugins_url("aurora-admin/")); @@ -33,6 +33,9 @@ require AURORA_ADMIN_PATH . "admin/src/Rest/AnalyticsData.php"; require AURORA_ADMIN_PATH . "admin/src/Rest/PluginsData.php"; require AURORA_ADMIN_PATH . "admin/src/Rest/CommentsData.php"; 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/MediaData.php"; +require AURORA_ADMIN_PATH . "admin/src/Rest/UsersData.php"; require AURORA_ADMIN_PATH . "admin/src/Posts/ScheduledDayFilter.php"; require AURORA_ADMIN_PATH . "admin/src/WhiteLabel/WhiteLabel.php"; require AURORA_ADMIN_PATH . "admin/src/Pages/SubApps.php";