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:
co-authored by
Claude Sonnet 5
parent
56b6674527
commit
204c4365e2
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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";
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user