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
+27
View File
@@ -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 window chrome/taskbar, Site Editor support, etc.) lives in `git log` and
`PROJECT_STATUS.md`, not reconstructed here. `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 ## [2.5.0] - 2026-07-05
### Added ### Added
+69 -41
View File
@@ -1,6 +1,6 @@
# Aurora Admin v2 — Project Status # 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 ## 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) ## Skinned vs. replaced native pages (important distinction, ask before assuming)
Not every native WP admin page gets the same treatment, and the user has The user explicitly said they didn't want the "skinned" approach used
explicitly said they don't want the "skinned" approach used more broadly without a real replacement behind it. As of this session, **every native
without a real replacement behind it — check this list before describing list-table screen has been converted** — there's no more "just skinned"
a page as "already handled": category left among the pages that had one:
- **Actually replaced** — the native page never renders; a real custom Vue - **Actually replaced** — the native page never renders; a real custom Vue
page (own REST controller, own list/detail UI) takes over entirely: page (own REST controller, own list/detail UI) takes over entirely:
Comments, Installed Plugins, Menu Creator, Admin Notices, Role Editor, Comments, Installed Plugins, Menu Creator, Admin Notices, Role Editor,
Activity Log, Database Explorer, and now **Posts** (added this session Activity Log, Database Explorer, **Posts, Pages, Media Library, and
— see below). Users** (the last four all added this session, in that order — Posts
- **Just skinned** — the real native WordPress page still renders; only first as its own commit/version, then Pages/Media/Users together).
`admin/assets/native-dark.css` restyles it dark. Same underlying - **Deliberately still native** (not a "skinning" gap — these are specific
markup, same native limitations, no new functionality: Pages, Media, screens/actions, not list-tables, and rebuilding them wasn't in scope):
Users, and every other native screen not listed above. 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`, All four of this session's pages (`PostsData.php`+`Posts.vue`,
entry `posts.js`) was the first of these converted from skinned to `PagesData.php`+`Pages.vue`, `MediaData.php`+`Media.vue`,
replaced, built directly off the Comments pattern: `UsersData.php`+`Users.vue`) follow one shared pattern, established with
- REST: GET `/posts` (status/search/pagination, matching native filters: Posts and repeated for the other three:
all/publish/draft/pending/trash), POST `/posts/trash`, POST - A REST controller in `admin/src/Rest/` with list (status/type/role +
`/posts/restore`, DELETE `/posts/{id}` (permanent). Capability checks search + pagination, matching native filter tabs) and action endpoints
mirror native (`edit_posts` to list, `edit_post`/`delete_post` per-item). (trash/restore/delete, or just delete for Media/Users, which don't have
- `SubApps.php`: registered as `aurora-admin-posts`, parented under a trash state in the same way). Capability checks mirror WP core's own
`edit.php` (same pattern as Plugins under `plugins.php`) so it shares for that screen (`edit_posts`/`edit_pages`/`upload_files`/`list_users`
the native "Posts" top-level menu's `hook_suffix` slot. to view; per-item `edit_post`/`delete_post`/`delete_page`/`delete_user`
- `SubApps::redirect_native_pages()`: visiting `edit.php` directly (typed for actions).
URL, bookmark — not just the sidebar link) redirects to the Aurora page, - A Vue page in `app/src/pages/<name>/` + entry in `app/src/entries/` +
gated on **only** the default post type (`post_type=post` or absent) — registered in `app/vite.config.js`, visually matching the Comments
`edit.php?post_type=page` (Pages) and any other custom post type's list layout (filter pills, search, table with inline row actions,
screen is explicitly left alone, since only Posts has a replacement. pagination) — Media is the one exception, using a thumbnail grid
- `MenuSerializer.php`: the sidebar's own "Posts"/"All Posts" links instead of a table, since that's what a media library actually needs.
(both share the literal `edit.php` slug) get rewritten the same way - `SubApps.php`: new `PAGES` entry, `parent_slug()`/`capability()` cases,
Plugins'/Comments' links already were. and a branch in `redirect_native_pages()` — the native screen's file
- Gated by Settings > Posts' "Modern posts table" toggle — this is the (`edit.php`, `edit.php?post_type=page`, `upload.php`, `users.php`)
same toggle that got **removed** two commits ago for describing a redirects to the Aurora page on **direct visits** (typed URL/bookmark),
feature that didn't exist; it's back now that it does, defaulting to not just the sidebar link.
on. `modern_plugins`/`modern_comments` also got a `default: true` UI - `MenuSerializer.php`: a matching rewrite so the sidebar's own link
fix at the same time (they already defaulted true server-side, but the (which shares the same slug as the parent, per WP convention) points at
Settings UI toggle showed off until explicitly saved — cosmetic the Aurora page too.
mismatch, now consistent). - A Settings toggle (`modern_posts`/`modern_pages`/`modern_media`/
- **Not done**: Pages, Media, Users remain skinned-only. Each would be a `modern_users`, all `default: true`) gating the redirect+link-rewrite,
comparable-sized build to Posts/Comments (own REST controller, own so the native screen can still be reached if turned off. `modern_pages`/
list/detail UI) — ask the user before starting one, per the pattern `modern_media`/`modern_users` are new categories/fields this session;
established this session (they picked "Posts first, then expand" over `modern_posts` already existed (see below). All the `modern_*` toggles
doing all four at once). 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) ## Theme system (rewrite — supersedes the old dark_mode 3-state model)
+6
View File
@@ -12,6 +12,9 @@ use AuroraAdmin\Rest\AnalyticsData;
use AuroraAdmin\Rest\PluginsData; use AuroraAdmin\Rest\PluginsData;
use AuroraAdmin\Rest\CommentsData; use AuroraAdmin\Rest\CommentsData;
use AuroraAdmin\Rest\PostsData; use AuroraAdmin\Rest\PostsData;
use AuroraAdmin\Rest\PagesData;
use AuroraAdmin\Rest\MediaData;
use AuroraAdmin\Rest\UsersData;
use AuroraAdmin\Posts\ScheduledDayFilter; use AuroraAdmin\Posts\ScheduledDayFilter;
use AuroraAdmin\WhiteLabel\WhiteLabel; use AuroraAdmin\WhiteLabel\WhiteLabel;
use AuroraAdmin\Pages\SubApps; use AuroraAdmin\Pages\SubApps;
@@ -49,6 +52,9 @@ class AuroraAdmin
new PluginsData(); new PluginsData();
new CommentsData(); new CommentsData();
new PostsData(); new PostsData();
new PagesData();
new MediaData();
new UsersData();
new ScheduledDayFilter(); new ScheduledDayFilter();
new WhiteLabel(); new WhiteLabel();
new SubApps(); new SubApps();
+59
View File
@@ -47,6 +47,9 @@ class SubApps
"aurora-admin-plugins" => ["title" => "Installed Plugins", "entry" => "plugins", "feature" => null], "aurora-admin-plugins" => ["title" => "Installed Plugins", "entry" => "plugins", "feature" => null],
"aurora-admin-comments" => ["title" => "Comments", "entry" => "comments", "feature" => null], "aurora-admin-comments" => ["title" => "Comments", "entry" => "comments", "feature" => null],
"aurora-admin-posts" => ["title" => "Posts", "entry" => "posts", "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 = [];
@@ -82,6 +85,15 @@ class SubApps
if ($slug === "aurora-admin-posts") { if ($slug === "aurora-admin-posts") {
return "edit.php"; 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";
} }
@@ -93,6 +105,15 @@ class SubApps
if ($slug === "aurora-admin-comments" || $slug === "aurora-admin-posts") { if ($slug === "aurora-admin-comments" || $slug === "aurora-admin-posts") {
return "edit_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";
} }
@@ -146,6 +167,41 @@ class SubApps
wp_safe_redirect(admin_url("admin.php?page=aurora-admin-posts")); wp_safe_redirect(admin_url("admin.php?page=aurora-admin-posts"));
exit; 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()
@@ -178,6 +234,9 @@ class SubApps
"roles" => $roles, "roles" => $roles,
"tablePrefix" => $wpdb->prefix, "tablePrefix" => $wpdb->prefix,
"newPostUrl" => esc_url_raw(admin_url("post-new.php")), "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"; $root_id = "aurora-admin-" . self::PAGES[$page]["entry"] . "-root";
?> ?>
+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,
];
}
}
+12
View File
@@ -162,6 +162,18 @@ class MenuSerializer
return admin_url("admin.php?page=aurora-admin-posts"); 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;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aurora-admin-app", "name": "aurora-admin-app",
"version": "2.5.0", "version": "2.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+10
View File
@@ -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);
}
+10
View File
@@ -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);
}
+10
View File
@@ -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);
}
+519
View File
@@ -0,0 +1,519 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const items = ref([]);
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 loading = ref(true);
const busyId = ref(0);
const error = ref('');
const notice = ref('');
const search = ref('');
const type = ref('all');
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: {
'Content-Type': 'application/json',
'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 () => {
loading.value = true;
error.value = '';
try {
const params = new URLSearchParams({
type: type.value,
search: search.value.trim(),
page: String(pagination.value.page),
perPage: String(pagination.value.perPage),
});
const data = await request(`/media?${params.toString()}`);
items.value = data.items || [];
stats.value = data.stats || stats.value;
pagination.value = data.pagination || pagination.value;
} catch (err) {
error.value = err.message || 'Could not load media';
} finally {
loading.value = false;
}
};
onMounted(load);
let searchTimer = 0;
watch(search, () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
pagination.value.page = 1;
load();
}, 250);
});
const setType = (next) => {
type.value = next;
pagination.value.page = 1;
load();
};
const filters = computed(() => [
{ key: 'all', label: 'All', count: stats.value.all },
{ key: 'image', label: 'Images', count: stats.value.image },
{ key: 'audio', label: 'Audio', count: stats.value.audio },
{ key: 'video', label: 'Video', count: stats.value.video },
{ key: 'application', label: 'Documents', count: stats.value.application },
{ key: 'unattached', label: 'Unattached', count: stats.value.unattached },
]);
const deleteItem = async (item) => {
if (!item || busyId.value) return;
if (!window.confirm(`Permanently delete "${item.title}"? This cannot be undone.`)) return;
busyId.value = item.id;
error.value = '';
notice.value = '';
try {
const data = await request(`/media/${item.id}`, { method: 'DELETE' });
notice.value = data.message || 'Media item deleted.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const goPage = (page) => {
const next = Math.min(Math.max(1, page), pagination.value.totalPages || 1);
if (next === pagination.value.page) return;
pagination.value.page = next;
load();
};
const rowRange = computed(() => {
if (!pagination.value.total) return '0 to 0';
const start = (pagination.value.page - 1) * pagination.value.perPage + 1;
const end = Math.min(pagination.value.total, pagination.value.page * pagination.value.perPage);
return `${start} to ${end}`;
});
const typeIcon = (mimeType) => {
const group = (mimeType || '').split('/')[0];
if (group === 'audio') return 'dashicons-format-audio';
if (group === 'video') return 'dashicons-format-video';
if (mimeType === 'application/pdf') return 'dashicons-pdf';
return 'dashicons-media-document';
};
</script>
<template>
<main class="media-page">
<header class="media-page__header">
<div>
<p class="media-page__eyebrow">Content</p>
<h1>Media Library</h1>
</div>
<div class="media-page__actions">
<a class="button-primary" :href="serverData.newMediaUrl">Add Media File</a>
<button type="button" class="icon-button" title="Refresh media" :disabled="loading" @click="load">
<span class="dashicons dashicons-update" />
</button>
</div>
</header>
<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">
<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="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>
<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>{{ rowRange }} of {{ pagination.total }}</div>
<div class="media-page__pager">
<button type="button" :disabled="pagination.page <= 1" @click="goPage(1)">
<span class="dashicons dashicons-controls-skipback" />
</button>
<button type="button" :disabled="pagination.page <= 1" @click="goPage(pagination.page - 1)">
<span class="dashicons dashicons-arrow-left-alt2" />
</button>
<span>Page {{ pagination.page }} of {{ pagination.totalPages }}</span>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.page + 1)"
>
<span class="dashicons dashicons-arrow-right-alt2" />
</button>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.totalPages)"
>
<span class="dashicons dashicons-controls-skipforward" />
</button>
</div>
</footer>
</main>
</template>
<style scoped>
.media-page {
min-height: calc(100vh - 112px);
padding: 0;
color: var(--aurora-text);
background: transparent;
}
.media-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 24px;
}
.media-page__eyebrow {
margin: 0 0 7px;
color: var(--aurora-text-muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.media-page h1 {
margin: 0;
color: var(--aurora-text);
font-size: 1.45rem;
line-height: 1.15;
}
.media-page__actions,
.media-page__toolbar,
.media-page__filters,
.media-page__pager {
display: flex;
align-items: center;
}
.media-page__actions {
gap: 10px;
}
.button-primary {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 16px;
border-radius: 8px;
background: var(--aurora-accent);
color: var(--aurora-accent-text);
font-size: 0.84rem;
font-weight: 700;
text-decoration: none;
}
.media-page__toolbar {
justify-content: space-between;
gap: 18px;
margin-bottom: 16px;
}
.media-page__filters {
flex-wrap: wrap;
gap: 8px;
}
.filter-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
border: 1px solid var(--aurora-border);
border-radius: 999px;
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
padding: 0 12px;
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
}
.filter-pill strong {
min-width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--aurora-frame-bg);
color: inherit;
font-size: 0.72rem;
}
.filter-pill.is-active {
border-color: var(--aurora-text-muted);
background: var(--aurora-frame-bg);
color: var(--aurora-text);
}
.media-page__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);
}
.media-page__search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
box-shadow: none;
background: transparent;
color: var(--aurora-text);
font-size: 0.84rem;
}
.media-page__search input::placeholder {
color: var(--aurora-text-muted);
}
.icon-button {
width: 42px;
min-height: 42px;
border: 1px solid var(--aurora-border);
border-radius: 8px;
color: var(--aurora-text);
background: var(--aurora-bg-subtle);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
text-decoration: none;
}
.icon-button:hover:not(:disabled) {
border-color: var(--aurora-text-muted);
color: var(--aurora-text);
}
.icon-button--inline {
width: 34px;
min-height: 34px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.45);
color: #fff;
}
.icon-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.media-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;
}
.media-grid__empty {
padding: 60px 18px;
border: 1px solid var(--aurora-frame-border);
border-radius: 8px;
background: var(--aurora-frame-bg);
color: var(--aurora-text-muted);
font-size: 0.85rem;
text-align: center;
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.media-card {
border: 1px solid var(--aurora-frame-border);
border-radius: 10px;
background: var(--aurora-frame-bg);
overflow: hidden;
}
.media-card__preview {
position: relative;
aspect-ratio: 1;
background: var(--aurora-bg-subtle);
display: flex;
align-items: center;
justify-content: center;
}
.media-card__preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.media-card__icon {
font-size: 46px;
width: 46px;
height: 46px;
color: var(--aurora-text-muted);
}
.media-card__overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.15s ease;
}
.media-card:hover .media-card__overlay {
opacity: 1;
}
.media-card__meta {
padding: 10px 12px 12px;
display: flex;
flex-direction: column;
gap: 3px;
}
.media-card__meta strong {
overflow: hidden;
color: var(--aurora-text);
font-size: 0.8rem;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-card__meta span {
overflow: hidden;
color: var(--aurora-text-muted);
font-size: 0.72rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-card__meta a {
color: var(--aurora-accent);
text-decoration: none;
}
.media-card__unattached {
font-style: italic;
}
.media-page__footer {
min-height: 54px;
margin-top: 18px;
border-top: 1px solid var(--aurora-border);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 26px;
padding: 14px 4px 0;
color: var(--aurora-text-muted);
font-size: 0.8rem;
}
.media-page__pager {
gap: 8px;
}
.media-page__pager button {
width: 28px;
height: 28px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--aurora-text-muted);
cursor: pointer;
}
.media-page__pager button:not(:disabled):hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.media-page__pager button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 1080px) {
.media-page__header,
.media-page__toolbar,
.media-page__footer {
align-items: stretch;
flex-direction: column;
}
.media-page__search {
width: 100%;
}
.media-page__footer {
gap: 10px;
}
}
</style>
+622
View File
@@ -0,0 +1,622 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const pages = ref([]);
const stats = ref({ all: 0, publish: 0, draft: 0, pending: 0, trash: 0 });
const pagination = ref({ page: 1, perPage: 20, total: 0, totalPages: 1 });
const loading = ref(true);
const busyId = ref(0);
const error = ref('');
const notice = ref('');
const search = ref('');
const status = ref('all');
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: {
'Content-Type': 'application/json',
'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 () => {
loading.value = true;
error.value = '';
try {
const params = new URLSearchParams({
status: status.value,
search: search.value.trim(),
page: String(pagination.value.page),
perPage: String(pagination.value.perPage),
});
const data = await request(`/pages?${params.toString()}`);
pages.value = data.items || [];
stats.value = data.stats || stats.value;
pagination.value = data.pagination || pagination.value;
} catch (err) {
error.value = err.message || 'Could not load pages';
} finally {
loading.value = false;
}
};
onMounted(load);
let searchTimer = 0;
watch(search, () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
pagination.value.page = 1;
load();
}, 250);
});
const setStatus = (next) => {
status.value = next;
pagination.value.page = 1;
load();
};
const filters = computed(() => [
{ key: 'all', label: 'All', count: stats.value.all },
{ key: 'publish', label: 'Published', count: stats.value.publish },
{ key: 'draft', label: 'Draft', count: stats.value.draft },
{ key: 'pending', label: 'Pending', count: stats.value.pending },
{ key: 'trash', label: 'Trash', count: stats.value.trash },
]);
const trashPage = async (item) => {
if (!item || busyId.value) return;
busyId.value = item.id;
error.value = '';
notice.value = '';
try {
const data = await request('/pages/trash', {
method: 'POST',
body: JSON.stringify({ id: item.id }),
});
notice.value = data.message || 'Page moved to trash.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const restorePage = async (item) => {
if (!item || busyId.value) return;
busyId.value = item.id;
error.value = '';
notice.value = '';
try {
const data = await request('/pages/restore', {
method: 'POST',
body: JSON.stringify({ id: item.id }),
});
notice.value = data.message || 'Page restored.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const deletePage = async (item) => {
if (!item || busyId.value) return;
if (!window.confirm(`Permanently delete "${item.title}"? This cannot be undone.`)) return;
busyId.value = item.id;
error.value = '';
notice.value = '';
try {
const data = await request(`/pages/${item.id}`, { method: 'DELETE' });
notice.value = data.message || 'Page deleted.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const goPage = (page) => {
const next = Math.min(Math.max(1, page), pagination.value.totalPages || 1);
if (next === pagination.value.page) return;
pagination.value.page = next;
load();
};
const rowRange = computed(() => {
if (!pagination.value.total) return '0 to 0';
const start = (pagination.value.page - 1) * pagination.value.perPage + 1;
const end = Math.min(pagination.value.total, pagination.value.page * pagination.value.perPage);
return `${start} to ${end}`;
});
</script>
<template>
<main class="pages-page">
<header class="pages-page__header">
<div>
<p class="pages-page__eyebrow">Content</p>
<h1>Pages</h1>
</div>
<div class="pages-page__actions">
<a class="button-primary" :href="serverData.newPageUrl">Add Page</a>
<button type="button" class="icon-button" title="Refresh pages" :disabled="loading" @click="load">
<span class="dashicons dashicons-update" />
</button>
</div>
</header>
<section class="pages-page__toolbar" aria-label="Page filters and search">
<div class="pages-page__filters">
<button
v-for="filter in filters"
:key="filter.key"
type="button"
class="filter-pill"
:class="{ 'is-active': status === filter.key }"
@click="setStatus(filter.key)"
>
<span>{{ filter.label }}</span>
<strong>{{ filter.count }}</strong>
</button>
</div>
<label class="pages-page__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Search pages..." />
</label>
</section>
<div v-if="notice" class="pages-notice pages-notice--success">{{ notice }}</div>
<div v-if="error" class="pages-notice pages-notice--error">{{ error }}</div>
<section class="pages-table" aria-label="Pages">
<div class="pages-table__head">
<div>Title</div>
<div>Author</div>
<div>Parent</div>
<div>Comments</div>
<div>Date</div>
<div>Actions</div>
</div>
<div v-if="loading" class="pages-table__empty">Loading pages...</div>
<div v-else-if="!pages.length" class="pages-table__empty">No pages match your filters.</div>
<article v-for="item in pages" v-else :key="item.id" class="page-row">
<div class="page-row__title" data-label="Title">
<a :href="item.editUrl">{{ item.title }}</a>
<span class="status-badge" :class="`status-badge--${item.status}`">{{ item.statusLabel }}</span>
</div>
<div class="page-row__cell" data-label="Author">{{ item.author }}</div>
<div class="page-row__cell" data-label="Parent">{{ item.parentTitle || '—' }}</div>
<div class="page-row__cell" data-label="Comments">{{ item.commentCount }}</div>
<div class="page-row__cell" data-label="Date">
<strong>{{ item.dateLabel }}</strong>
<span>{{ item.date }}</span>
</div>
<div class="page-row__actions">
<a class="icon-button icon-button--inline" title="Edit page" :href="item.editUrl">
<span class="dashicons dashicons-edit" />
</a>
<a
v-if="item.viewUrl"
class="icon-button icon-button--inline"
title="View page"
:href="item.viewUrl"
target="_blank"
rel="noopener"
>
<span class="dashicons dashicons-visibility" />
</a>
<button
v-if="item.status !== 'trash'"
type="button"
class="icon-button icon-button--inline"
title="Move to trash"
:disabled="busyId === item.id"
@click="trashPage(item)"
>
<span class="dashicons dashicons-trash" />
</button>
<template v-else>
<button
type="button"
class="icon-button icon-button--inline"
title="Restore"
:disabled="busyId === item.id"
@click="restorePage(item)"
>
<span class="dashicons dashicons-undo" />
</button>
<button
v-if="item.canDelete"
type="button"
class="icon-button icon-button--inline"
title="Delete permanently"
:disabled="busyId === item.id"
@click="deletePage(item)"
>
<span class="dashicons dashicons-no-alt" />
</button>
</template>
</div>
</article>
<footer v-if="!loading && pages.length" class="pages-table__footer">
<div>Page Size: {{ pagination.perPage }}</div>
<div>{{ rowRange }} of {{ pagination.total }}</div>
<div class="pages-table__pager">
<button type="button" :disabled="pagination.page <= 1" @click="goPage(1)">
<span class="dashicons dashicons-controls-skipback" />
</button>
<button type="button" :disabled="pagination.page <= 1" @click="goPage(pagination.page - 1)">
<span class="dashicons dashicons-arrow-left-alt2" />
</button>
<span>Page {{ pagination.page }} of {{ pagination.totalPages }}</span>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.page + 1)"
>
<span class="dashicons dashicons-arrow-right-alt2" />
</button>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.totalPages)"
>
<span class="dashicons dashicons-controls-skipforward" />
</button>
</div>
</footer>
</section>
</main>
</template>
<style scoped>
.pages-page {
min-height: calc(100vh - 112px);
padding: 0;
color: var(--aurora-text);
background: transparent;
}
.pages-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 24px;
}
.pages-page__eyebrow {
margin: 0 0 7px;
color: var(--aurora-text-muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.pages-page h1 {
margin: 0;
color: var(--aurora-text);
font-size: 1.45rem;
line-height: 1.15;
}
.pages-page__actions,
.pages-page__toolbar,
.pages-page__filters,
.page-row__actions {
display: flex;
align-items: center;
}
.pages-page__actions {
gap: 10px;
}
.button-primary {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 16px;
border-radius: 8px;
background: var(--aurora-accent);
color: var(--aurora-accent-text);
font-size: 0.84rem;
font-weight: 700;
text-decoration: none;
}
.pages-page__toolbar {
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.pages-page__filters {
flex-wrap: wrap;
gap: 8px;
}
.filter-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
border: 1px solid var(--aurora-border);
border-radius: 999px;
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
padding: 0 12px;
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
}
.filter-pill strong {
min-width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--aurora-frame-bg);
color: inherit;
font-size: 0.72rem;
}
.filter-pill.is-active {
border-color: var(--aurora-text-muted);
background: var(--aurora-frame-bg);
color: var(--aurora-text);
}
.pages-page__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);
}
.pages-page__search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
box-shadow: none;
background: transparent;
color: var(--aurora-text);
font-size: 0.84rem;
}
.pages-page__search input::placeholder {
color: var(--aurora-text-muted);
}
.icon-button {
width: 42px;
min-height: 42px;
border: 1px solid var(--aurora-border);
border-radius: 8px;
color: var(--aurora-text);
background: var(--aurora-bg-subtle);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
text-decoration: none;
}
.icon-button:hover:not(:disabled) {
border-color: var(--aurora-text-muted);
color: var(--aurora-text);
}
.icon-button--inline {
width: 30px;
min-height: 30px;
border: 0;
background: transparent;
color: var(--aurora-text-muted);
}
.icon-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.pages-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;
}
.pages-table {
overflow: hidden;
border: 1px solid var(--aurora-frame-border);
border-radius: 8px;
background: var(--aurora-frame-bg);
}
.pages-table__head,
.page-row {
display: grid;
grid-template-columns: minmax(220px, 1.6fr) minmax(120px, 0.8fr) minmax(160px, 1fr) 110px 130px 132px;
align-items: center;
}
.pages-table__head {
min-height: 48px;
border-bottom: 1px solid var(--aurora-border);
color: var(--aurora-text);
font-size: 0.79rem;
font-weight: 850;
}
.pages-table__head > div {
min-height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
}
.page-row {
min-height: 66px;
border-bottom: 1px solid var(--aurora-border);
}
.page-row:hover {
background: var(--aurora-bg-subtle);
}
.page-row__title {
min-width: 0;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.page-row__title a {
overflow: hidden;
color: var(--aurora-text);
font-weight: 800;
font-size: 0.86rem;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-row__title a:hover {
text-decoration: underline;
}
.page-row__cell {
min-width: 0;
padding: 14px 16px;
color: var(--aurora-text-muted);
font-size: 0.8rem;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-row__cell span {
display: block;
color: var(--aurora-text-muted);
font-size: 0.74rem;
}
.page-row__cell strong {
display: block;
color: var(--aurora-text-muted);
font-size: 0.8rem;
}
.status-badge {
width: fit-content;
border-radius: 999px;
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
padding: 2px 9px;
font-size: 0.68rem;
font-weight: 850;
box-shadow: inset 0 0 0 1px var(--aurora-border);
}
.page-row__actions {
gap: 8px;
padding: 14px 16px;
}
.pages-table__empty {
padding: 38px 18px;
color: var(--aurora-text-muted);
font-size: 0.85rem;
text-align: center;
}
.pages-table__footer {
min-height: 54px;
border-top: 1px solid var(--aurora-border);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 26px;
padding: 0 16px;
color: var(--aurora-text-muted);
font-size: 0.8rem;
}
.pages-table__pager {
display: flex;
align-items: center;
gap: 8px;
}
.pages-table__pager button {
width: 28px;
height: 28px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--aurora-text-muted);
cursor: pointer;
}
.pages-table__pager button:not(:disabled):hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.pages-table__pager button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 1280px) {
.pages-table__head,
.page-row {
grid-template-columns: minmax(200px, 1.6fr) minmax(100px, 0.8fr) minmax(140px, 1fr) 100px 120px 122px;
}
}
@media (max-width: 1080px) {
.pages-table__head {
display: none;
}
.page-row {
grid-template-columns: 1fr;
align-items: stretch;
padding: 8px 0;
}
.page-row__title,
.page-row__cell,
.page-row__actions {
padding: 8px 16px;
}
.page-row__cell {
display: flex;
justify-content: space-between;
gap: 16px;
white-space: normal;
}
.page-row__cell::before {
content: attr(data-label);
color: var(--aurora-text-muted);
font-weight: 800;
}
.pages-table__footer,
.pages-page__header,
.pages-page__toolbar {
align-items: stretch;
flex-direction: column;
}
.pages-table__footer {
padding: 14px 16px;
gap: 10px;
}
.pages-page__search {
width: 100%;
}
}
</style>
+37
View File
@@ -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', id: 'plugins',
title: 'Plugins', title: 'Plugins',
@@ -224,6 +239,13 @@ export const settingsCategories = [
subtitle: 'Media library and upload management settings', subtitle: 'Media library and upload management settings',
icon: 'dashicons-admin-media', icon: 'dashicons-admin-media',
fields: [ fields: [
{
key: 'modern_media',
type: 'toggle',
label: 'Modern media library',
description: 'Use the Aurora media library interface.',
default: true,
},
{ {
key: 'enable_svg', key: 'enable_svg',
type: 'toggle', 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', id: 'comments',
title: 'Comments', title: 'Comments',
+569
View File
@@ -0,0 +1,569 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const users = ref([]);
const stats = ref({ all: 0, byRole: {} });
const pagination = ref({ page: 1, perPage: 20, total: 0, totalPages: 1 });
const loading = ref(true);
const busyId = ref(0);
const error = ref('');
const notice = ref('');
const search = ref('');
const role = ref('all');
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: {
'Content-Type': 'application/json',
'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 () => {
loading.value = true;
error.value = '';
try {
const params = new URLSearchParams({
role: role.value,
search: search.value.trim(),
page: String(pagination.value.page),
perPage: String(pagination.value.perPage),
});
const data = await request(`/users?${params.toString()}`);
users.value = data.items || [];
stats.value = data.stats || stats.value;
pagination.value = data.pagination || pagination.value;
} catch (err) {
error.value = err.message || 'Could not load users';
} finally {
loading.value = false;
}
};
onMounted(load);
let searchTimer = 0;
watch(search, () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
pagination.value.page = 1;
load();
}, 250);
});
const setRole = (next) => {
role.value = next;
pagination.value.page = 1;
load();
};
const filters = computed(() => {
const roleFilters = Object.entries(stats.value.byRole || {}).map(([key, count]) => ({
key,
label: key.charAt(0).toUpperCase() + key.slice(1),
count,
}));
return [{ key: 'all', label: 'All', count: stats.value.all }, ...roleFilters];
});
const deleteUser = async (user) => {
if (!user || busyId.value) return;
if (!window.confirm(`Delete "${user.displayName}"? Their content will be reassigned to you. This cannot be undone.`)) return;
busyId.value = user.id;
error.value = '';
notice.value = '';
try {
const data = await request(`/users/${user.id}`, { method: 'DELETE' });
notice.value = data.message || 'User deleted.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const goPage = (page) => {
const next = Math.min(Math.max(1, page), pagination.value.totalPages || 1);
if (next === pagination.value.page) return;
pagination.value.page = next;
load();
};
const rowRange = computed(() => {
if (!pagination.value.total) return '0 to 0';
const start = (pagination.value.page - 1) * pagination.value.perPage + 1;
const end = Math.min(pagination.value.total, pagination.value.page * pagination.value.perPage);
return `${start} to ${end}`;
});
const initials = (name) =>
(name || '?')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('');
</script>
<template>
<main class="users-page">
<header class="users-page__header">
<div>
<p class="users-page__eyebrow">Accounts</p>
<h1>Users</h1>
</div>
<div class="users-page__actions">
<a class="button-primary" :href="serverData.newUserUrl">Add User</a>
<button type="button" class="icon-button" title="Refresh users" :disabled="loading" @click="load">
<span class="dashicons dashicons-update" />
</button>
</div>
</header>
<section class="users-page__toolbar" aria-label="User filters and search">
<div class="users-page__filters">
<button
v-for="filter in filters"
:key="filter.key"
type="button"
class="filter-pill"
:class="{ 'is-active': role === filter.key }"
@click="setRole(filter.key)"
>
<span>{{ filter.label }}</span>
<strong>{{ filter.count }}</strong>
</button>
</div>
<label class="users-page__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Search users..." />
</label>
</section>
<div v-if="notice" class="users-notice users-notice--success">{{ notice }}</div>
<div v-if="error" class="users-notice users-notice--error">{{ error }}</div>
<section class="users-table" aria-label="Users">
<div class="users-table__head">
<div>User</div>
<div>Email</div>
<div>Role</div>
<div>Posts</div>
<div>Registered</div>
<div>Actions</div>
</div>
<div v-if="loading" class="users-table__empty">Loading users...</div>
<div v-else-if="!users.length" class="users-table__empty">No users match your filters.</div>
<article v-for="user in users" v-else :key="user.id" class="user-row">
<div class="user-row__user">
<img v-if="user.avatarUrl" :src="user.avatarUrl" alt="" class="user-row__avatar" />
<div v-else class="user-row__initials">{{ initials(user.displayName) }}</div>
<div class="user-row__person">
<strong>{{ user.displayName }}</strong>
<span>{{ user.username }}</span>
</div>
</div>
<div class="user-row__cell" data-label="Email">{{ user.email }}</div>
<div class="user-row__cell" data-label="Role">{{ user.roles.join(', ') || '—' }}</div>
<div class="user-row__cell" data-label="Posts">{{ user.postCount }}</div>
<div class="user-row__cell" data-label="Registered">{{ user.registeredDate }}</div>
<div class="user-row__actions">
<a class="icon-button icon-button--inline" title="Edit user" :href="user.editUrl">
<span class="dashicons dashicons-edit" />
</a>
<button
v-if="user.canDelete"
type="button"
class="icon-button icon-button--inline"
title="Delete user"
:disabled="busyId === user.id"
@click="deleteUser(user)"
>
<span class="dashicons dashicons-trash" />
</button>
</div>
</article>
<footer v-if="!loading && users.length" class="users-table__footer">
<div>Page Size: {{ pagination.perPage }}</div>
<div>{{ rowRange }} of {{ pagination.total }}</div>
<div class="users-table__pager">
<button type="button" :disabled="pagination.page <= 1" @click="goPage(1)">
<span class="dashicons dashicons-controls-skipback" />
</button>
<button type="button" :disabled="pagination.page <= 1" @click="goPage(pagination.page - 1)">
<span class="dashicons dashicons-arrow-left-alt2" />
</button>
<span>Page {{ pagination.page }} of {{ pagination.totalPages }}</span>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.page + 1)"
>
<span class="dashicons dashicons-arrow-right-alt2" />
</button>
<button
type="button"
:disabled="pagination.page >= pagination.totalPages"
@click="goPage(pagination.totalPages)"
>
<span class="dashicons dashicons-controls-skipforward" />
</button>
</div>
</footer>
</section>
</main>
</template>
<style scoped>
.users-page {
min-height: calc(100vh - 112px);
padding: 0;
color: var(--aurora-text);
background: transparent;
}
.users-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 24px;
}
.users-page__eyebrow {
margin: 0 0 7px;
color: var(--aurora-text-muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.users-page h1 {
margin: 0;
color: var(--aurora-text);
font-size: 1.45rem;
line-height: 1.15;
}
.users-page__actions,
.users-page__toolbar,
.users-page__filters,
.user-row__actions {
display: flex;
align-items: center;
}
.users-page__actions {
gap: 10px;
}
.button-primary {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 16px;
border-radius: 8px;
background: var(--aurora-accent);
color: var(--aurora-accent-text);
font-size: 0.84rem;
font-weight: 700;
text-decoration: none;
}
.users-page__toolbar {
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.users-page__filters {
flex-wrap: wrap;
gap: 8px;
}
.filter-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
border: 1px solid var(--aurora-border);
border-radius: 999px;
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
padding: 0 12px;
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
}
.filter-pill strong {
min-width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--aurora-frame-bg);
color: inherit;
font-size: 0.72rem;
}
.filter-pill.is-active {
border-color: var(--aurora-text-muted);
background: var(--aurora-frame-bg);
color: var(--aurora-text);
}
.users-page__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);
}
.users-page__search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
box-shadow: none;
background: transparent;
color: var(--aurora-text);
font-size: 0.84rem;
}
.users-page__search input::placeholder {
color: var(--aurora-text-muted);
}
.icon-button {
width: 42px;
min-height: 42px;
border: 1px solid var(--aurora-border);
border-radius: 8px;
color: var(--aurora-text);
background: var(--aurora-bg-subtle);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
text-decoration: none;
}
.icon-button:hover:not(:disabled) {
border-color: var(--aurora-text-muted);
color: var(--aurora-text);
}
.icon-button--inline {
width: 30px;
min-height: 30px;
border: 0;
background: transparent;
color: var(--aurora-text-muted);
}
.icon-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.users-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;
}
.users-table {
overflow: hidden;
border: 1px solid var(--aurora-frame-border);
border-radius: 8px;
background: var(--aurora-frame-bg);
}
.users-table__head,
.user-row {
display: grid;
grid-template-columns: minmax(200px, 1.4fr) minmax(180px, 1.2fr) minmax(110px, 0.8fr) 90px 130px 100px;
align-items: center;
}
.users-table__head {
min-height: 48px;
border-bottom: 1px solid var(--aurora-border);
color: var(--aurora-text);
font-size: 0.79rem;
font-weight: 850;
}
.users-table__head > div {
min-height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
}
.user-row {
min-height: 66px;
border-bottom: 1px solid var(--aurora-border);
}
.user-row:hover {
background: var(--aurora-bg-subtle);
}
.user-row__user {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
gap: 12px;
align-items: center;
min-width: 0;
padding: 14px 16px;
}
.user-row__avatar,
.user-row__initials {
width: 36px;
height: 36px;
border-radius: 50%;
}
.user-row__avatar {
display: block;
}
.user-row__initials {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
font-weight: 900;
}
.user-row__person {
min-width: 0;
}
.user-row__person strong {
display: block;
overflow: hidden;
color: var(--aurora-text);
font-size: 0.86rem;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-row__person span {
display: block;
overflow: hidden;
color: var(--aurora-text-muted);
font-size: 0.74rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-row__cell {
min-width: 0;
padding: 14px 16px;
color: var(--aurora-text-muted);
font-size: 0.8rem;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-row__actions {
gap: 8px;
padding: 14px 16px;
}
.users-table__empty {
padding: 38px 18px;
color: var(--aurora-text-muted);
font-size: 0.85rem;
text-align: center;
}
.users-table__footer {
min-height: 54px;
border-top: 1px solid var(--aurora-border);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 26px;
padding: 0 16px;
color: var(--aurora-text-muted);
font-size: 0.8rem;
}
.users-table__pager {
display: flex;
align-items: center;
gap: 8px;
}
.users-table__pager button {
width: 28px;
height: 28px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--aurora-text-muted);
cursor: pointer;
}
.users-table__pager button:not(:disabled):hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.users-table__pager button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 1280px) {
.users-table__head,
.user-row {
grid-template-columns: minmax(180px, 1.4fr) minmax(160px, 1.2fr) minmax(100px, 0.8fr) 80px 120px 90px;
}
}
@media (max-width: 1080px) {
.users-table__head {
display: none;
}
.user-row {
grid-template-columns: 1fr;
align-items: stretch;
padding: 8px 0;
}
.user-row__cell,
.user-row__actions {
padding: 8px 16px;
}
.user-row__cell {
display: flex;
justify-content: space-between;
gap: 16px;
white-space: normal;
}
.user-row__cell::before {
content: attr(data-label);
color: var(--aurora-text-muted);
font-weight: 800;
}
.users-table__footer,
.users-page__header,
.users-page__toolbar {
align-items: stretch;
flex-direction: column;
}
.users-table__footer {
padding: 14px 16px;
gap: 10px;
}
.users-page__search {
width: 100%;
}
}
</style>
+3
View File
@@ -31,6 +31,9 @@ export default defineConfig({
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)),
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)), 'locked-feature': fileURLToPath(new URL('./src/entries/locked-feature.js', import.meta.url)),
}, },
output: { output: {
+5 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Aurora Admin * Plugin Name: Aurora Admin
* Plugin URI: https://aurora.co * Plugin URI: https://aurora.co
* Description: A modern, fast WordPress admin interface. * Description: A modern, fast WordPress admin interface.
* Version: 2.5.0 * Version: 2.6.0
* Author: Aurora * Author: Aurora
* Text Domain: aurora-admin * Text Domain: aurora-admin
* Domain Path: /languages/ * Domain Path: /languages/
@@ -14,7 +14,7 @@
defined("ABSPATH") || exit(); 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_PATH", plugin_dir_path(__FILE__));
define("AURORA_ADMIN_URL", plugins_url("aurora-admin/")); 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/PluginsData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/CommentsData.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/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/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";