v1.0.0 — Initial public release of Aurora Admin

This commit is contained in:
NixReaper
2026-07-26 16:39:17 -05:00
commit f50159d45a
151 changed files with 24139 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
{
"permissions": {
"allow": [
"mcp__claude-in-chrome__tabs_context_mcp",
"mcp__claude-in-chrome__navigate",
"mcp__claude-in-chrome__javascript_tool",
"mcp__claude-in-chrome__computer",
"Bash(xargs -I{} sh -c 'echo ==={}===; cat {}')",
"Bash(echo \"exists:$?\")",
"Bash(git status *)",
"Bash(npm -v)",
"Bash(npm install *)",
"Bash(npm run *)",
"Read(//Users/kris/Local Sites/**)",
"Read(//Users/kris/Local Sites/aurora/**)",
"Bash(find ~/\"Library/Application Support/Local/run/LsEVQ8win\" -maxdepth 4 2>/dev/null | grep -i \"conf\\\\|fpm\\\\|ini\" | head -40)",
"Bash(grep -A1 \"OBJC_DISABLE\" ~/\"Library/Application Support/Local/run/LsEVQ8win/conf/php/php-fpm.d/www.conf\" 2>/dev/null; echo \"---\"; ls -la ~/\"Library/Application Support/Local/run/\" | grep -i local | head -5)",
"Bash(launchctl setenv *)",
"Bash(launchctl getenv *)",
"Bash(curl -sk -o /dev/null -w \"wp-admin \\(unauth\\): %{http_code}\\\\n\" https://admin.local/wp-admin/)",
"Bash(curl -sk -o /dev/null -w \"wp-login: %{http_code}\\\\n\" https://admin.local/wp-login.php)",
"Bash(sudo -n lsof -nP -iTCP:80 -sTCP:LISTEN)",
"Bash(sudo -n lsof -nP -iTCP:443 -sTCP:LISTEN)",
"Bash(lsof -nP -iTCP:80 -sTCP:LISTEN)",
"Bash(lsof -nP -iTCP:443 -sTCP:LISTEN)",
"Read(//private/etc/**)",
"mcp__claude-in-chrome__select_browser",
"mcp__claude-in-chrome__read_network_requests",
"Bash(cp /Users/kris/Projects/aurora-wordpress-admin/admin/src/Options/Settings.php ~/\"Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/admin/src/Options/Settings.php\" *)",
"mcp__claude-in-chrome__read_page",
"Bash(unzip -l dist/aurora-admin-v2.zip)",
"Bash(rm -rf '/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/app/dist')",
"Bash(cp -R app/dist '/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/app/dist')",
"Bash(rm -rf /tmp/aurora-zip-stage)",
"Bash(mkdir -p /tmp/aurora-zip-stage/aurora-admin)",
"Bash(rsync -a --exclude 'node_modules' --exclude '.git' --exclude 'app/node_modules' --exclude 'dist' . /tmp/aurora-zip-stage/aurora-admin/)",
"Read(//tmp/**)",
"Bash(zip -r -X /Users/kris/Projects/aurora-wordpress-admin/dist/aurora-admin-v2.zip aurora-admin -x \"*.DS_Store\")",
"Bash(rsync -a --exclude 'node_modules' --exclude '.git' --exclude '/dist' . /tmp/aurora-zip-stage/aurora-admin/)",
"Bash(mkdir -p /Users/kris/Projects/aurora-wordpress-admin/dist)",
"Bash(unzip -l /Users/kris/Projects/aurora-wordpress-admin/dist/aurora-admin-v2.zip)",
"Bash(grep -n \"#[0-9a-fA-F]\\\\{3,6\\\\}\\\\|--aurora-\" /Users/kris/Projects/aurora-wordpress-admin/admin/assets/native-dark.css)",
"Bash(cp -R /Users/kris/Projects/aurora-wordpress-admin/app/dist '/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/app/dist')",
"Bash(grep -n \"#[0-9a-fA-F]\\\\{3,6\\\\}\\\\|--aurora-\" /Users/kris/Projects/aurora-wordpress-admin/admin/assets/native-enhance.js)",
"Bash(grep -c \"#[0-9a-fA-F]\\\\{3,6\\\\}\" /Users/kris/Projects/aurora-wordpress-admin/admin/assets/native-dark.css)",
"Bash(grep -n \"#[0-9a-fA-F]\\\\{3,6\\\\}\" /Users/kris/Projects/aurora-wordpress-admin/admin/assets/native-dark.css)",
"Bash(LOCAL=\"/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin\" *)",
"Bash(rm -f dist/aurora-admin-v2.zip)",
"WebSearch",
"Bash(git add *)",
"Bash(git commit -m ' *)",
"Bash(git push *)",
"Bash(git fetch *)",
"Bash(git rebase *)",
"Bash(grep -o \"aurora-taskbar[^{,]*{[^}]*}\" \"/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/app/dist/assets/shell.fLy6Lkg3.css\")",
"Bash(rm -rf \"/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/app/dist\")",
"Bash(cp -R /Users/kris/Projects/aurora-wordpress-admin/app/dist \"/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/app/dist\")",
"Bash(cp -R dist \"/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/app/dist\")",
"Bash(mkdir -p /private/tmp/claude-501/-Users-kris-Projects-aurora-wordpress-admin/6af2c379-f5dd-4bde-a545-c5ae93e1646a/scratchpad/aurora-stage)",
"Bash(rm -rf /private/tmp/claude-501/-Users-kris-Projects-aurora-wordpress-admin/6af2c379-f5dd-4bde-a545-c5ae93e1646a/scratchpad/aurora-stage/aurora-admin)",
"Bash(mkdir -p /private/tmp/claude-501/-Users-kris-Projects-aurora-wordpress-admin/6af2c379-f5dd-4bde-a545-c5ae93e1646a/scratchpad/aurora-stage/aurora-admin)",
"Bash(rsync -a --exclude 'node_modules' --exclude '.git' --exclude '/dist' --exclude '.claude' ./ /private/tmp/claude-501/-Users-kris-Projects-aurora-wordpress-admin/6af2c379-f5dd-4bde-a545-c5ae93e1646a/scratchpad/aurora-stage/aurora-admin/)",
"Bash(cp -R /Users/kris/Projects/aurora-wordpress-admin/admin \"/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/\")",
"Bash(cp /Users/kris/Projects/aurora-wordpress-admin/aurora-admin.php \"/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/aurora-admin.php\")",
"Bash(cp /Users/kris/Projects/aurora-wordpress-admin/admin/src/Roles/RolesData.php \"/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/admin/src/Roles/RolesData.php\" && echo synced)",
"Bash(grep -rln \"auroraplugins\" /Users/kris/Projects/ 2>/dev/null | head -20)",
"Bash(grep -i -A5 \"auroraplugins\\\\|aurora\" ~/.ssh/config 2>/dev/null)",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 [email protected] \"echo connected && whoami\")",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 -p 2289 [email protected] \"echo connected\")",
"Bash(ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_auroradragon_root -C \"kris-root-auroradragon\" -N \"\")",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 -i ~/.ssh/id_ed25519_auroradragon_root [email protected] \"echo connected && whoami && hostname\")",
"Bash(ssh -i ~/.ssh/id_ed25519_auroradragon_root [email protected] \"find / -maxdepth 6 -iname '*auroraplugin*' -o -iname '*license*' 2>/dev/null | grep -v -E '/proc/|/sys/' | head -60\")",
"Bash(ssh -i ~/.ssh/id_ed25519_auroradragon_root [email protected] \"find /home/plugins/public_html -maxdepth 3 -iname '*api*' 2>/dev/null\")",
"Bash(ssh -i ~/.ssh/id_ed25519_auroradragon_root [email protected] \"ls -la /home/plugins/public_html/wp-content/plugins/ 2>/dev/null\")",
"Bash(ssh -i ~/.ssh/id_ed25519_auroradragon_root [email protected] \"find /home/plugins/public_html/wp-content/plugins/aurora-licensing -type f\")",
"Bash(ssh -i ~/.ssh/id_ed25519_auroradragon_root [email protected] \"cat /home/plugins/public_html/wp-content/plugins/aurora-licensing/aurora-licensing.php\")",
"Bash(ssh -o ConnectTimeout=10 -i ~/.ssh/id_ed25519_auroradragon_root [email protected] \"cat /home/plugins/public_html/wp-content/plugins/aurora-licensing/aurora-licensing.php\")",
"Bash(ssh -o ConnectTimeout=15 -i ~/.ssh/id_ed25519_auroradragon_root [email protected] \"echo ok\")",
"Bash(ssh -o ConnectTimeout=10 -o BatchMode=yes -p 2289 [email protected] \"echo alive\")",
"Bash(nc -zv -w 8 auroradragon.studio 22)",
"Bash(nc -zv -w 8 auroradragon.studio 2289)",
"Bash(nc -zv -w 6 auroradragon.studio 22)",
"Bash(ssh -o ConnectTimeout=10 -i ~/.ssh/id_ed25519_auroradragon_root [email protected] \"ls -la /home/aurora/public_html/wp-content/plugins/aurora-licensing/ 2>&1; echo ---; find / -maxdepth 8 -ipath '*aurora-licensing*' -type f 2>/dev/null | grep -v -E '/proc/|/sys/'\")",
"Bash(cp /Users/kris/Projects/aurora-wordpress-admin/admin/src/Licensing/Integrity.php \"/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/admin/src/Licensing/Integrity.php\")",
"Bash(cp /Users/kris/Projects/aurora-wordpress-admin/admin/src/Licensing/License.php \"/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/admin/src/Licensing/License.php\")",
"Bash(rm -f \"/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/admin/src/Licensing/integrity-manifest.json\")",
"Bash(rsync -a --exclude node_modules --exclude .git --exclude /dist --exclude .claude ./ /private/tmp/claude-501/-Users-kris-Projects-aurora-wordpress-admin/6af2c379-f5dd-4bde-a545-c5ae93e1646a/scratchpad/integrity-test/aurora-admin/)",
"Bash(php bin/build-integrity-manifest.php /private/tmp/claude-501/-Users-kris-Projects-aurora-wordpress-admin/6af2c379-f5dd-4bde-a545-c5ae93e1646a/scratchpad/integrity-test/aurora-admin)",
"Bash(find \"$HOME/Library/Application Support/Local\" -maxdepth 6 -iname \"php\" -type f 2>/dev/null | head -5)",
"Bash('/Users/kris/Library/Application Support/Local/lightning-services/php-8.2.30+1/bin/darwin-arm64/bin/php' bin/build-integrity-manifest.php /private/tmp/claude-501/-Users-kris-Projects-aurora-wordpress-admin/6af2c379-f5dd-4bde-a545-c5ae93e1646a/scratchpad/integrity-test/aurora-admin)",
"Bash(cp /Users/kris/Projects/aurora-wordpress-admin/aurora-admin.php \"/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin/aurora-admin.php\" && echo synced)",
"Bash(sed -i '' 's/Version: 2.1.0/Version: 2.1.1/' aurora-admin.php)",
"Bash(sed -i '' 's/AURORA_ADMIN_VERSION\", \"2.1.0\"/AURORA_ADMIN_VERSION\", \"2.1.1\"/' aurora-admin.php)",
"Bash(sed -i '' 's/\"version\": \"2.1.0\"/\"version\": \"2.1.1\"/' app/package.json)",
"Bash(sed -i '' 's/Version: 2.1.1/Version: 2.2.0/' aurora-admin.php)",
"Bash(sed -i '' 's/AURORA_ADMIN_VERSION\", \"2.1.1\"/AURORA_ADMIN_VERSION\", \"2.2.0\"/' aurora-admin.php)",
"Bash(sed -i '' 's/\"version\": \"2.1.1\"/\"version\": \"2.2.0\"/' app/package.json)",
"mcp__claude-in-chrome__find",
"mcp__claude-in-chrome__file_upload",
"Bash(sed -i '' 's/Version: 2.2.0/Version: 2.3.0/' aurora-admin.php)",
"Bash(sed -i '' 's/AURORA_ADMIN_VERSION\", \"2.2.0\"/AURORA_ADMIN_VERSION\", \"2.3.0\"/' aurora-admin.php)",
"Bash(sed -i '' 's/\"version\": \"2.2.0\"/\"version\": \"2.3.0\"/' app/package.json)",
"Bash(sed -i '' 's/Version: 2.3.0/Version: 2.3.1/' aurora-admin.php)",
"Bash(sed -i '' 's/AURORA_ADMIN_VERSION\", \"2.3.0\"/AURORA_ADMIN_VERSION\", \"2.3.1\"/' aurora-admin.php)",
"Bash(sed -i '' 's/\"version\": \"2.3.0\"/\"version\": \"2.3.1\"/' app/package.json)",
"Bash(sed -i '' 's/Version: 2.3.1/Version: 2.4.0/' aurora-admin.php)",
"Bash(sed -i '' 's/AURORA_ADMIN_VERSION\", \"2.3.1\"/AURORA_ADMIN_VERSION\", \"2.4.0\"/' aurora-admin.php)",
"Bash(sed -i '' 's/\"version\": \"2.3.1\"/\"version\": \"2.4.0\"/' app/package.json)",
"Bash(sed -i '' 's/Version: 2.4.0/Version: 2.4.1/' aurora-admin.php)",
"Bash(sed -i '' 's/AURORA_ADMIN_VERSION\", \"2.4.0\"/AURORA_ADMIN_VERSION\", \"2.4.1\"/' aurora-admin.php)",
"Bash(sed -i '' 's/\"version\": \"2.4.0\"/\"version\": \"2.4.1\"/' app/package.json)",
"Bash(sed -i '' 's/Version: 2.4.1/Version: 2.4.2/' aurora-admin.php)",
"Bash(sed -i '' 's/AURORA_ADMIN_VERSION\", \"2.4.1\"/AURORA_ADMIN_VERSION\", \"2.4.2\"/' aurora-admin.php)",
"Bash(sed -i '' 's/\"version\": \"2.4.1\"/\"version\": \"2.4.2\"/' app/package.json)",
"Bash(sed -i '' 's/Version: 2.4.2/Version: 2.5.0/' aurora-admin.php)",
"Bash(sed -i '' 's/AURORA_ADMIN_VERSION\", \"2.4.2\"/AURORA_ADMIN_VERSION\", \"2.5.0\"/' aurora-admin.php)",
"Bash(sed -i '' 's/\"version\": \"2.4.2\"/\"version\": \"2.5.0\"/' app/package.json)",
"Bash(sed -i '' 's/Version: 2.5.0/Version: 2.6.0/' aurora-admin.php)",
"Bash(sed -i '' 's/AURORA_ADMIN_VERSION\", \"2.5.0\"/AURORA_ADMIN_VERSION\", \"2.6.0\"/' aurora-admin.php)",
"Bash(sed -i '' 's/\"version\": \"2.5.0\"/\"version\": \"2.6.0\"/' app/package.json)",
"Bash(sed -i '' 's/Version: 2.6.0/Version: 2.7.0/' aurora-admin.php)",
"Bash(sed -i '' 's/AURORA_ADMIN_VERSION\", \"2.6.0\"/AURORA_ADMIN_VERSION\", \"2.7.0\"/' aurora-admin.php)",
"Bash(sed -i '' 's/\"version\": \"2.6.0\"/\"version\": \"2.7.0\"/' app/package.json)",
"Bash(grep -n '\\\\$permission = function' -A 3 /Users/kris/Projects/aurora-wordpress-admin/admin/src/FileManager/FileManagerData.php)",
"Bash(sed -i '' 's/Version: 2.7.0/Version: 2.7.1/' aurora-admin.php)",
"Bash(sed -i '' 's/AURORA_ADMIN_VERSION\", \"2.7.0\"/AURORA_ADMIN_VERSION\", \"2.7.1\"/' aurora-admin.php)",
"Bash(sed -i '' 's/\"version\": \"2.7.0\"/\"version\": \"2.7.1\"/' app/package.json)",
"Bash(sed -i '' 's/Version: 2.7.1/Version: 2.7.2/' aurora-admin.php)",
"Bash(sed -i '' 's/AURORA_ADMIN_VERSION\", \"2.7.1\"/AURORA_ADMIN_VERSION\", \"2.7.2\"/' aurora-admin.php)",
"Bash(sed -i '' 's/\"version\": \"2.7.1\"/\"version\": \"2.7.2\"/' app/package.json)",
"Bash(find /Users/kris/Projects /Users/kris/Downloads /Users/kris/Desktop -maxdepth 4 -iname \"*uixpress*\" -o -iname \"*uipress*\" 2>/dev/null | grep -v node_modules)"
]
}
}
+8
View File
@@ -0,0 +1,8 @@
app/node_modules/
app/dist/
dist/
wporg-submission/
admin/src/Licensing/integrity-manifest.json
*.log
.DS_Store
Thumbs.db
+53
View File
@@ -0,0 +1,53 @@
# Changelog
All notable changes to Aurora Admin, newest first — one entry per version bump.
## [1.0] — 2026-07-26
First public release.
### Admin experience
- Full modern admin shell — fixed sidebar, toolbar, and global search — in
place of the standard WordPress admin chrome.
- Real, functional replacement screens (not visual reskins) for: Dashboard,
Posts, Pages, Media, Users, Comments, Add User, Media Upload, Profile,
Plugins, Nav Menus, Widgets, Themes, the five core Settings screens
(General, Writing, Reading, Discussion, Permalinks), Tools (Available
Tools), Export, and Import.
- Sub-apps: Aurora Settings, Setup Wizard, Menu Creator (with drag-to-reorder
and native menu-item preservation), Role Editor, Admin Notices, Activity
Log, and Report a Bug.
- **Modules**: a one-click addon marketplace for Aurora's standalone
companion plugins (File Manager, Database Explorer, Site Backup).
- **Login Redirect**: optional, off-by-default redirects — send logged-out
wp-admin visitors to a custom URL instead of the login screen, and/or send
specific roles to a custom URL after logging in instead of the dashboard.
Never touches wp-login.php itself.
- **Disable Gutenberg**: an opt-in toggle that switches every post type to
WordPress's classic editor instead of the block editor.
- **Activity Log**: a filterable table (Date, Author, IP Address, Type,
Action, Description) with separate Admin and User tabs; the Author column
shows the user's avatar, role, and email.
- Media Folders and White Label rebranding for client sites.
- Screens intentionally left native rather than rebuilt: the block editor
and the Theme/Plugin File Editor — both dark-themed for legibility, since
full file-editing/code-editor functionality isn't accepted into the
WordPress.org Plugin Directory.
### Theming
- A real dark mode covering the entire admin, including every native screen
Aurora frames rather than rebuilds (Site Health, Export/Erase Personal
Data, Updates, the Theme/Plugin File Editor).
- Live theme presets — 10 color accents plus 2 flat neutrals — applied
across the whole interface, plus an optional (off-by-default) Google Font
for the interface typeface.
- Rounded, framed content cards with a soft shadow; scroll areas sit inside
the rounded frame instead of squaring off its corners, and scrollbars are
tinted with the active theme accent.
### Under the hood
- REST API namespace `aurora-admin/v1` backing every takeover screen.
- Security & performance hardening module, custom admin CSS/JS injection,
and a login-screen customizer.
Everything above is free — no license key, no locked features, no upsell.
+563
View File
@@ -0,0 +1,563 @@
/*
* Native WordPress admin dark theme.
*
* Loaded ONLY on native (non-Aurora) admin pages, and every rule is gated
* on html.aurora-dark so it is completely inert in light mode. It restyles
* the common WordPress admin elements to match Aurora's dark palette using
* the --aurora-* variables defined in the shell stylesheet.
*
* Scope is kept under #wpcontent / #wpbody-content so it never reaches the
* Aurora sidebar/toolbar (which live in their own root) and cannot leak to
* the front end. This is the one intentionally-large styling surface, kept
* isolated in a single file on purpose.
*/
html.aurora-dark.aurora-ready #wpbody-content,
html.aurora-dark.aurora-ready #wpbody-content .wrap {
color: var(--aurora-text);
}
/* Headings & text -------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content h1,
html.aurora-dark.aurora-ready #wpbody-content h2,
html.aurora-dark.aurora-ready #wpbody-content h3,
html.aurora-dark.aurora-ready #wpbody-content h4,
html.aurora-dark.aurora-ready #wpbody-content h5,
html.aurora-dark.aurora-ready #wpbody-content h6,
html.aurora-dark.aurora-ready #wpbody-content p,
html.aurora-dark.aurora-ready #wpbody-content li,
html.aurora-dark.aurora-ready #wpbody-content dt,
html.aurora-dark.aurora-ready #wpbody-content dd,
html.aurora-dark.aurora-ready #wpbody-content label,
html.aurora-dark.aurora-ready #wpbody-content th,
html.aurora-dark.aurora-ready #wpbody-content td,
html.aurora-dark.aurora-ready #wpbody-content strong {
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .description,
html.aurora-dark.aurora-ready #wpbody-content .howto,
html.aurora-dark.aurora-ready #wpbody-content .subtitle,
html.aurora-dark.aurora-ready #wpbody-content .wp-heading-inline + .subtitle {
color: var(--aurora-text-muted);
}
html.aurora-dark.aurora-ready #wpbody-content a {
color: var(--aurora-accent);
}
/* Postboxes, cards, metaboxes ------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .postbox,
html.aurora-dark.aurora-ready #wpbody-content .card,
html.aurora-dark.aurora-ready #wpbody-content .welcome-panel,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table,
/* .widefat: WP's general "wide flat table" utility class, used beyond just
WP_List_Table screens — e.g. update-core.php's plugin/theme update
tables (class="widefat updates-table", no .wp-list-table and no
.striped either), which were completely uncovered without this. */
html.aurora-dark.aurora-ready #wpbody-content .widefat,
html.aurora-dark.aurora-ready #wpbody-content .stuffbox {
background: var(--aurora-card-bg);
border-color: var(--aurora-card-border);
color: var(--aurora-text);
box-shadow: none;
}
html.aurora-dark.aurora-ready #wpbody-content .postbox-header,
html.aurora-dark.aurora-ready #wpbody-content .postbox .hndle,
html.aurora-dark.aurora-ready #wpbody-content .postbox h2,
html.aurora-dark.aurora-ready #wpbody-content .postbox h3 {
border-color: var(--aurora-card-border);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .inside,
html.aurora-dark.aurora-ready #wpbody-content .welcome-panel-content {
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .handlediv,
html.aurora-dark.aurora-ready #wpbody-content .postbox .handle-order-higher,
html.aurora-dark.aurora-ready #wpbody-content .postbox .handle-order-lower {
color: var(--aurora-text-muted);
}
/* Tables ---------------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table th,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table td,
html.aurora-dark.aurora-ready #wpbody-content .widefat th,
html.aurora-dark.aurora-ready #wpbody-content .widefat td {
border-color: var(--aurora-border);
/* Core sets an explicit white background directly on <td> (not just the
row), which otherwise wins over .wp-list-table/.widefat's own dark
background above — transparent lets the row's actual background
(plain dark, or the striped-odd shade below) show through instead of a
white cell sitting on an otherwise-dark row. Seen first on Export/
Erase Personal Data's request-queue table, but this is a generic
WP_List_Table/.widefat gap, not specific to any one screen. */
background: transparent;
}
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table thead th,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table tfoot th,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table thead th a,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table tfoot th a,
html.aurora-dark.aurora-ready #wpbody-content .widefat thead th,
html.aurora-dark.aurora-ready #wpbody-content .widefat tfoot th,
html.aurora-dark.aurora-ready #wpbody-content .widefat thead th a,
html.aurora-dark.aurora-ready #wpbody-content .widefat tfoot th a {
color: var(--aurora-text);
}
/* core's list-tables.css sets `.plugins tr { background: #fff; }` for the
native Plugins screen's list table — .plugins is a plain class name, not
scoped to that screen, so it also matches update-core.php's plugin-update
table (its <tbody> reuses the same class). Aurora's own Plugins screen is
a full custom rebuild that never hits this native markup, which is why
this collision hadn't surfaced before update-core.php. */
html.aurora-dark.aurora-ready #wpbody-content .plugins tr {
background: transparent;
}
/* Theme File Editor / Plugin File Editor (theme-editor.php, plugin-editor.php)
share the same #templateside file-tree component. Core sets an explicit
light background directly on `#templateside > ul` (not just inherited from
the page), which nothing else here targets — combined with this file's own
light text/link colors (meant for a dark surface), that produced light text
on a light background, i.e. effectively unreadable rather than just
unstyled. Both screens are deliberately left as framed native content (not
rebuilt), same as Site Health/Updates, but still need to be legible. */
html.aurora-dark.aurora-ready #wpbody-content #templateside > ul {
background: transparent;
}
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table.striped > tbody > :nth-child(odd),
html.aurora-dark.aurora-ready #wpbody-content .striped > tbody > :nth-child(odd) {
background: var(--aurora-bg-subtle);
}
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table tbody tr:hover {
background: var(--aurora-accent-soft);
}
html.aurora-dark.aurora-ready #wpbody-content .row-actions {
color: var(--aurora-text-muted);
}
/* Form fields ----------------------------------------------------------- */
/* !important: some native screens style specific fields via a 2-ID
selector (e.g. core's `#titlediv #title { background: #fff }` on the
classic/attachment edit screen's title field) — that beats this rule's
single #wpbody-content id no matter how many classes we stack on it,
same specificity trap as the earlier block-editor-page background bug.
There's no legitimate case where a native admin form field should stay
white, so forcing it here is safe across every screen, not just the
one that surfaced it. */
html.aurora-dark.aurora-ready #wpbody-content input[type='text'],
html.aurora-dark.aurora-ready #wpbody-content input[type='search'],
html.aurora-dark.aurora-ready #wpbody-content input[type='email'],
html.aurora-dark.aurora-ready #wpbody-content input[type='url'],
html.aurora-dark.aurora-ready #wpbody-content input[type='password'],
html.aurora-dark.aurora-ready #wpbody-content input[type='number'],
html.aurora-dark.aurora-ready #wpbody-content input[type='tel'],
html.aurora-dark.aurora-ready #wpbody-content input[type='date'],
html.aurora-dark.aurora-ready #wpbody-content textarea,
html.aurora-dark.aurora-ready #wpbody-content select {
background: var(--aurora-bg-subtle) !important;
border-color: var(--aurora-border) !important;
color: var(--aurora-text) !important;
}
html.aurora-dark.aurora-ready #wpbody-content input::placeholder,
html.aurora-dark.aurora-ready #wpbody-content textarea::placeholder {
color: var(--aurora-text-muted);
}
html.aurora-dark.aurora-ready #wpbody-content input[type='text']:focus,
html.aurora-dark.aurora-ready #wpbody-content input[type='search']:focus,
html.aurora-dark.aurora-ready #wpbody-content textarea:focus,
html.aurora-dark.aurora-ready #wpbody-content select:focus {
border-color: var(--aurora-accent);
box-shadow: 0 0 0 1px var(--aurora-accent);
outline: none;
}
/* Buttons --------------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .button,
html.aurora-dark.aurora-ready #wpbody-content .button-secondary {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .button:hover,
html.aurora-dark.aurora-ready #wpbody-content .button-secondary:hover {
background: var(--aurora-border);
border-color: var(--aurora-text-muted);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .button-primary {
background: var(--aurora-accent);
border-color: var(--aurora-accent);
color: #fff;
text-shadow: none;
box-shadow: none;
}
html.aurora-dark.aurora-ready #wpbody-content .button-primary:hover {
filter: brightness(1.1);
}
/* Nav tabs -------------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .nav-tab-wrapper,
html.aurora-dark.aurora-ready #wpbody-content .wp-filter {
background: var(--aurora-card-bg);
border-color: var(--aurora-border);
}
html.aurora-dark.aurora-ready #wpbody-content .wp-filter .filter-links a {
color: var(--aurora-accent);
}
html.aurora-dark.aurora-ready #wpbody-content .wp-filter .filter-links .current {
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .wp-filter .search-form input[type='search'] {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .nav-tab {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
color: var(--aurora-text-muted);
}
html.aurora-dark.aurora-ready #wpbody-content .nav-tab-active {
background: var(--aurora-card-bg);
border-bottom-color: var(--aurora-card-bg);
color: var(--aurora-text);
}
/* Notices --------------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .notice,
html.aurora-dark.aurora-ready #wpbody-content div.updated,
html.aurora-dark.aurora-ready #wpbody-content div.error {
background: var(--aurora-card-bg);
color: var(--aurora-text);
}
/* Code ------------------------------------------------------------------ */
html.aurora-dark.aurora-ready #wpbody-content code,
html.aurora-dark.aurora-ready #wpbody-content kbd,
html.aurora-dark.aurora-ready #wpbody-content pre {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
/* Tablenav / pagination / filters --------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .tablenav,
html.aurora-dark.aurora-ready #wpbody-content .subsubsub,
html.aurora-dark.aurora-ready #wpbody-content .tablenav .displaying-num {
color: var(--aurora-text-muted);
}
html.aurora-dark.aurora-ready #wpbody-content .tablenav-pages a,
html.aurora-dark.aurora-ready #wpbody-content .pagination-links a {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
color: var(--aurora-text);
}
/* Screen meta (Help / Screen Options pull-down tabs) -------------------- */
html.aurora-dark.aurora-ready #screen-meta,
html.aurora-dark.aurora-ready #screen-meta-links .show-settings,
html.aurora-dark.aurora-ready #contextual-help-wrap,
html.aurora-dark.aurora-ready #screen-options-wrap {
background: var(--aurora-card-bg);
border-color: var(--aurora-border);
color: var(--aurora-text);
}
/* Form tables (settings pages) ----------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .form-table th,
html.aurora-dark.aurora-ready #wpbody-content .form-table td {
border-color: var(--aurora-border);
color: var(--aurora-text);
}
/* Horizontal rules & misc dividers ------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content hr,
html.aurora-dark.aurora-ready #wpbody-content .wp-header-end {
border-color: var(--aurora-border);
}
/* ---------------------------------------------------------------------------
* WordPress Updates page (update-core.php) — boxed "card" layout.
* native-enhance.js wraps each section (Current version, Plugins, Themes)
* into a .aurora-native-card div; these rules give those cards the same
* surface look as the dashboard cards, plus a clean title and accent button.
* Scoped to the updates page body class so it can't affect other screens.
* ------------------------------------------------------------------------ */
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .wrap > h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 6px;
}
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .wrap > p {
color: var(--aurora-text-muted);
font-size: 0.875rem;
margin: 0 0 20px;
}
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-native-card {
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-card-border);
border-radius: 20px;
padding: 20px;
margin-bottom: 16px;
}
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-native-card h2 {
font-size: 1.0625rem;
font-weight: 600;
margin: 0 0 18px;
color: var(--aurora-text);
border: 0;
padding-top: 0;
}
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-native-card h2.response {
font-size: 1.15rem;
}
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-native-card .button {
background: transparent;
border: 1px solid var(--aurora-accent);
color: var(--aurora-accent);
border-radius: 8px;
padding: 7px 18px;
font-weight: 500;
height: auto;
box-shadow: none;
text-shadow: none;
}
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-native-card .button:hover {
background: var(--aurora-accent-soft);
}
/* ---------------------------------------------------------------------------
* Modals & popups (media library, plugin/theme install details) — these are
* Backbone/thickbox views appended to <body>, outside #wpbody-content, so
* every rule above misses them entirely. Scoped to html.aurora-dark only
* (no #wpbody-content ancestor requirement) since that's the only anchor
* available at body level.
* ------------------------------------------------------------------------ */
html.aurora-dark.aurora-ready .media-modal,
html.aurora-dark.aurora-ready .media-frame,
html.aurora-dark.aurora-ready .media-frame-title,
html.aurora-dark.aurora-ready .media-frame-router,
html.aurora-dark.aurora-ready .media-frame-content,
html.aurora-dark.aurora-ready .media-frame-toolbar,
html.aurora-dark.aurora-ready .media-sidebar,
html.aurora-dark.aurora-ready #TB_window,
html.aurora-dark.aurora-ready #TB_ajaxContent,
html.aurora-dark.aurora-ready .plugin-details-modal,
html.aurora-dark.aurora-ready .plugin-install-php .plugin-card {
background: var(--aurora-card-bg);
color: var(--aurora-text);
border-color: var(--aurora-card-border);
}
html.aurora-dark.aurora-ready .media-modal h1,
html.aurora-dark.aurora-ready .media-modal h2,
html.aurora-dark.aurora-ready .media-modal h3,
html.aurora-dark.aurora-ready .media-modal label,
html.aurora-dark.aurora-ready .media-frame-title h1,
html.aurora-dark.aurora-ready #TB_ajaxContent,
html.aurora-dark.aurora-ready #TB_ajaxContent p,
html.aurora-dark.aurora-ready #TB_ajaxContent h1,
html.aurora-dark.aurora-ready .plugin-card-top,
html.aurora-dark.aurora-ready .plugin-card .name,
html.aurora-dark.aurora-ready .plugin-card .desc {
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready .media-frame-menu,
html.aurora-dark.aurora-ready .media-menu,
html.aurora-dark.aurora-ready .attachments-browser .media-toolbar,
html.aurora-dark.aurora-ready .media-sidebar,
html.aurora-dark.aurora-ready .plugin-card-bottom {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
}
html.aurora-dark.aurora-ready .attachments-browser .attachments,
html.aurora-dark.aurora-ready .media-frame-content {
background: var(--aurora-app-bg-solid);
}
html.aurora-dark.aurora-ready .media-modal input,
html.aurora-dark.aurora-ready .media-modal textarea,
html.aurora-dark.aurora-ready .media-modal select,
html.aurora-dark.aurora-ready #TB_ajaxContent input,
html.aurora-dark.aurora-ready #TB_ajaxContent textarea,
html.aurora-dark.aurora-ready #TB_ajaxContent select {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready .media-modal-backdrop,
html.aurora-dark.aurora-ready #TB_overlay {
background: rgba(0, 0, 0, 0.7);
}
/* Plugin/theme card grids on plugins.php / plugin-install.php / themes.php */
html.aurora-dark.aurora-ready #wpbody-content .theme-browser .theme,
html.aurora-dark.aurora-ready #wpbody-content .plugin-card {
background: var(--aurora-card-bg);
border-color: var(--aurora-card-border);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .theme-browser .theme .theme-name,
html.aurora-dark.aurora-ready #wpbody-content .theme-browser .theme .theme-actions {
background: var(--aurora-card-bg);
color: var(--aurora-text);
border-color: var(--aurora-card-border);
}
/* ---------------------------------------------------------------------------
* Block editor (Gutenberg) chrome — the top toolbar/title bar and the
* right-hand Post/Block settings sidebar. The actual editing canvas
* (.interface-interface-skeleton__content, the white "page" itself) is
* deliberately left native/white — it's a WYSIWYG preview of the live,
* light-themed page, not admin chrome, so it shouldn't follow the theme.
* Everything surrounding it does, matching every other page's framing
* (shell-frame.css gives .interface-interface-skeleton itself the card
* background/border/radius; this just covers what's inside it).
* ------------------------------------------------------------------------ */
html.aurora-dark.aurora-ready .editor-header {
background: var(--aurora-frame-bg);
border-bottom: 1px solid var(--aurora-frame-border);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready .editor-header .components-button,
html.aurora-dark.aurora-ready .editor-header .editor-post-title__input,
html.aurora-dark.aurora-ready .editor-header .editor-document-bar,
html.aurora-dark.aurora-ready .editor-header .editor-document-bar__title {
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready .editor-header .components-button:hover {
background: var(--aurora-bg-subtle);
}
html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar {
background: var(--aurora-frame-bg);
border-left: 1px solid var(--aurora-frame-border);
color: var(--aurora-text);
}
/* .interface-complementary-area (the actual Post/Block settings panel) is
a nested child with its own explicit white background — the outer
__sidebar rule above doesn't reach it, so without this the whole panel
still shows through white regardless of theme. */
html.aurora-dark.aurora-ready .interface-complementary-area {
background: var(--aurora-frame-bg);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar .components-panel,
html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar .components-panel__body,
html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar .editor-post-status,
html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar .interface-complementary-area-header {
background: var(--aurora-frame-bg);
border-color: var(--aurora-frame-border);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar .components-panel__body-title,
html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar label {
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar input,
html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar select,
html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar textarea,
html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar .components-select-control__input,
html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar .components-text-control__input {
background: var(--aurora-bg-subtle);
border-color: var(--aurora-border);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready .interface-interface-skeleton__sidebar .components-button {
color: var(--aurora-text);
}
/* Font Library (font-library.php) --------------------------------------
* WP 6.5+ built this on @wordpress/components' Card/Modal primitives
* ("boot-layout" wrapper) instead of classic postbox markup — its own
* always-white background isn't reached by the postbox/card rules above,
* which left light text sitting on a white panel (invisible) inside an
* otherwise-dark-framed page. Emotion-generated hash classes on some of
* this screen's inner elements (e.g. "css-qi2azi") are unstable across WP
* builds and deliberately not targeted here — only the screen's own
* stable, hand-authored class names are. */
html.aurora-dark.aurora-ready #wpbody-content .boot-layout-container,
html.aurora-dark.aurora-ready #wpbody-content .boot-layout__stage,
html.aurora-dark.aurora-ready #wpbody-content .admin-ui-page,
html.aurora-dark.aurora-ready #wpbody-content .admin-ui-navigable-region,
html.aurora-dark.aurora-ready #wpbody-content .admin-ui-page__header,
html.aurora-dark.aurora-ready #wpbody-content .font-library-page__tab-panel,
html.aurora-dark.aurora-ready #wpbody-content .font-library__tabpanel-layout,
html.aurora-dark.aurora-ready #wpbody-content .font-library__footer {
background: var(--aurora-card-bg);
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .admin-ui-page__header-title,
html.aurora-dark.aurora-ready #wpbody-content .font-library__fonts-title {
color: var(--aurora-text);
}
html.aurora-dark.aurora-ready #wpbody-content .font-library-page__tablist {
border-color: var(--aurora-card-border);
}
html.aurora-dark.aurora-ready #wpbody-content .font-library__fonts-list-item,
html.aurora-dark.aurora-ready #wpbody-content .font-library__font-card {
background: var(--aurora-card-bg);
border-color: var(--aurora-card-border);
color: var(--aurora-text);
}
/* Site Health (site-health.php) is left running WordPress core's own test
* logic rather than reimplemented — its Status tab async checks hit core's
* own /wp-site-health/v1/tests/* REST routes, and third-party plugins can
* add their own checks via the site_status_tests filter, so reproducing it
* risks both drifting from core and silently dropping other plugins'
* checks. Only the framing/theming is Aurora's, same "frame native content"
* treatment as the block editor above.
*
* Three of its own elements paint a hardcoded white/off-white background
* that the generic #wpbody-content light-text cascade doesn't reach,
* leaving light text unreadable against it — the same "boot-layout" pattern
* fixed above, just a different screen. */
html.aurora-dark.aurora-ready #wpbody-content .health-check-header,
html.aurora-dark.aurora-ready #wpbody-content .health-check-accordion-panel {
background: var(--aurora-card-bg);
}
html.aurora-dark.aurora-ready #wpbody-content .health-check-accordion-trigger {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
/* The Security/Performance category pill inside each trigger row keeps its
* own dark text color (readable against the trigger's original near-white
* background) — needs to flip to light along with the trigger's background
* above, or it becomes unreadable in the other direction. */
html.aurora-dark.aurora-ready #wpbody-content .health-check-accordion-trigger .badge {
color: var(--aurora-text);
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Progressive enhancement for native WordPress admin pages that benefit from
* Aurora's boxed "card" layout but ship as flat sibling markup.
*
* Currently targets the Updates page (update-core.php): it groups each
* section (Current version, Plugins, Themes) into a .aurora-native-card div
* so the dark stylesheet can give them the same surface as the dashboard
* cards. Purely visual and reversible — no native behaviour is altered.
*/
(function () {
'use strict';
function enhanceUpdates() {
var wrap = document.querySelector('#wpbody-content .wrap');
if (!wrap || wrap.querySelector('.aurora-native-card')) {
return;
}
var children = Array.prototype.slice.call(wrap.children);
// A new card starts at each major section heading. The "response" h2
// ("You have the latest version") belongs inside the version section,
// so it is not treated as a boundary.
var isBoundary = function (el) {
return el.tagName === 'H2' && !el.classList.contains('response');
};
var groups = [];
var current = null;
children.forEach(function (el) {
if (el.tagName === 'H1') {
return; // page title stays outside the cards
}
if (!current && !isBoundary(el)) {
return; // intro paragraph(s) before the first section stay in place
}
if (isBoundary(el)) {
current = [];
groups.push(current);
}
if (current) {
current.push(el);
}
});
groups.forEach(function (group) {
if (!group.length) {
return;
}
var card = document.createElement('div');
card.className = 'aurora-native-card';
group[0].parentNode.insertBefore(card, group[0]);
group.forEach(function (el) {
card.appendChild(el);
});
});
}
function run() {
if (document.body.classList.contains('update-core-php')) {
enhanceUpdates();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
})();
+226
View File
@@ -0,0 +1,226 @@
<?php
namespace AuroraAdmin\ActivityLog;
defined("ABSPATH") || exit();
/**
* Records a real event log across core WP actions. Storage + the hook
* listeners live here; REST list/filter lives in ActivityLogData.php.
*/
class ActivityLog
{
const TABLE = "aurora_admin_activity_log";
const DB_VERSION = "2";
const DB_OPTION = "aurora_admin_activity_log_db_version";
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
add_action("save_post", [self::class, "on_save_post"], 10, 3);
add_action("before_delete_post", [self::class, "on_delete_post"]);
add_action("add_attachment", [self::class, "on_add_attachment"]);
add_action("delete_attachment", [self::class, "on_delete_attachment"]);
add_action("user_register", [self::class, "on_user_register"]);
add_action("profile_update", [self::class, "on_profile_update"]);
add_action("wp_login", [self::class, "on_login"], 10, 2);
add_action("wp_logout", [self::class, "on_logout"]);
add_action("activated_plugin", [self::class, "on_plugin_activated"]);
add_action("deactivated_plugin", [self::class, "on_plugin_deactivated"]);
add_action("switch_theme", [self::class, "on_theme_switch"]);
add_action("update_option_aurora_admin_settings", [self::class, "on_settings_updated"]);
}
public static function table_name()
{
global $wpdb;
return $wpdb->prefix . self::TABLE;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
action_type VARCHAR(60) NOT NULL DEFAULT '',
object_type VARCHAR(60) NOT NULL DEFAULT '',
object_id BIGINT UNSIGNED NULL,
object_label VARCHAR(191) NOT NULL DEFAULT '',
user_id BIGINT UNSIGNED NULL,
user_name VARCHAR(191) NOT NULL DEFAULT '',
ip_address VARCHAR(45) NULL,
details LONGTEXT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_action (action_type),
KEY idx_object (object_type),
KEY idx_created (created_at)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
/**
* @param array|null $actor Overrides the "current user" attribution with
* an explicit ["id" => int, "name" => string] — needed for wp_logout,
* where wp_get_current_user() has already been reset to the logged-out
* (id 0) user by the time this hook fires, which would otherwise record
* every logout as done by nobody.
*/
private static function record($action_type, $object_type, $object_id, $object_label, $details = [], $actor = null)
{
global $wpdb;
if ($actor === null) {
$current = wp_get_current_user();
$actor = [
"id" => $current && $current->exists() ? $current->ID : null,
"name" => $current && $current->exists() ? $current->display_name : "",
];
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- $wpdb->insert() is WordPress's own escaping-safe API for writing to a custom table (not raw SQL); there's no core post/postmeta abstraction for Aurora's own activity-log table, so any access to it is necessarily "direct" by this sniff's definition.
$wpdb->insert(self::table_name(), [
"action_type" => $action_type,
"object_type" => $object_type,
"object_id" => $object_id,
"object_label" => substr((string) $object_label, 0, 191),
"user_id" => $actor["id"],
"user_name" => $actor["name"],
"ip_address" => self::client_ip(),
"details" => $details ? wp_json_encode($details) : null,
"created_at" => current_time("mysql"),
]);
}
/**
* REMOTE_ADDR only — the actual TCP peer, not spoofable by a request
* header. Behind a reverse proxy/load balancer this will read as the
* proxy's own address rather than the original visitor; deliberately not
* trusting X-Forwarded-For or similar instead, since blindly trusting a
* client-supplied header for anything (even just display, as here) is the
* standard IP-spoofing footgun, and this plugin has no reliable way to
* know whether a given host's proxy is trusted.
*/
private static function client_ip()
{
$ip = isset($_SERVER["REMOTE_ADDR"]) ? sanitize_text_field(wp_unslash($_SERVER["REMOTE_ADDR"])) : "";
return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : null;
}
public static function on_save_post($post_id, $post, $update)
{
// save_post fires on autosaves, revisions, and attachments too — none
// represent a real user-visible content change (attachments get their
// own add_attachment/delete_attachment hooks below), so all three are
// skipped here.
if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id) || $post->post_type === "attachment") {
return;
}
if (!in_array($post->post_status, ["publish", "draft", "pending", "future", "private"], true)) {
return;
}
// object_type is the post's real type (post/page/a custom post type),
// not a hardcoded "post" — the Activity Log's Type column needs this to
// read correctly for anything that isn't the built-in Posts type.
self::record($update ? "updated" : "created", $post->post_type, $post_id, $post->post_title ?: "(no title)");
}
public static function on_delete_post($post_id)
{
$post = get_post($post_id);
if (!$post || $post->post_type === "revision" || $post->post_type === "attachment") {
return;
}
self::record("deleted", $post->post_type, $post_id, $post->post_title ?: "(no title)");
}
public static function on_add_attachment($post_id)
{
$post = get_post($post_id);
self::record("created", "attachment", $post_id, $post ? $post->post_title : "(no title)");
}
public static function on_delete_attachment($post_id)
{
$post = get_post($post_id);
self::record("deleted", "attachment", $post_id, $post ? $post->post_title : "(no title)");
}
public static function on_user_register($user_id)
{
$user = get_userdata($user_id);
// Self-registration means the visitor isn't authenticated yet at this
// point, so wp_get_current_user() would show "not logged in" rather
// than the account that was just created. Attributing explicitly to the
// new account instead — including when an admin creates the account —
// is a deliberate choice for this specific event: unlike profile edits
// (where the acting admin, shown via the default wp_get_current_user()
// attribution, is the more useful "who did this"), a registration event
// is overwhelmingly self-service, so "who this account belongs to" is
// the more useful read here, and treating both origins the same way
// keeps this one event type's Author column consistent regardless of
// which flow created the account.
self::record("registered", "user", $user_id, $user ? $user->user_login : "", [], [
"id" => $user_id,
"name" => $user ? $user->display_name : "",
]);
}
public static function on_profile_update($user_id)
{
$user = get_userdata($user_id);
self::record("profile_updated", "user", $user_id, $user ? $user->user_login : "");
}
public static function on_login($user_login, $user)
{
// Not wp_get_current_user(): wp_login fires from inside wp_signon(),
// before wp-login.php's own form handler calls wp_set_current_user() —
// the same ordering issue on_logout() has, just in the other direction.
// $user is passed in for exactly this reason; use it directly instead.
self::record("login", "user", $user->ID, $user_login, [], [
"id" => $user->ID,
"name" => $user->display_name,
]);
}
public static function on_logout($user_id)
{
$user = $user_id ? get_userdata($user_id) : false;
$login = $user ? $user->user_login : "";
self::record("logout", "user", $user_id ?: null, $login, [], [
"id" => $user_id ?: null,
"name" => $user ? $user->display_name : "",
]);
}
public static function on_plugin_activated($plugin)
{
self::record("activated", "plugin", null, $plugin);
}
public static function on_plugin_deactivated($plugin)
{
self::record("deactivated", "plugin", null, $plugin);
}
public static function on_theme_switch($new_name)
{
self::record("switched", "theme", null, $new_name);
}
public static function on_settings_updated()
{
self::record("updated", "settings", null, "Aurora Admin settings");
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace AuroraAdmin\ActivityLog;
defined("ABSPATH") || exit();
/**
* REST list for the activity log. GET /activity-log?action_type=&object_type=&page=&per_page=
*/
class ActivityLogData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/activity-log", [
"methods" => "GET",
"callback" => [self::class, "handle"],
"permission_callback" => function () {
return current_user_can("manage_options");
},
"args" => [
// "admin" = site/content activity (everything except user-account
// events), "user" = account activity (login/logout/registration/
// profile updates) — the Activity Log page's two tabs. Omit for
// unfiltered (both).
"scope" => ["type" => "string", "required" => false, "enum" => ["admin", "user"]],
"action_type" => ["type" => "string", "required" => false],
"object_type" => ["type" => "string", "required" => false],
"search" => ["type" => "string", "required" => false],
"page" => ["type" => "integer", "required" => false, "default" => 1],
"per_page" => ["type" => "integer", "required" => false, "default" => 20],
],
]);
}
public static function handle($request)
{
global $wpdb;
$table = ActivityLog::table_name();
$where = ["1=1"];
$params = [];
$scope = $request->get_param("scope");
if ($scope === "user") {
$where[] = "object_type = 'user'";
} elseif ($scope === "admin") {
$where[] = "object_type != 'user'";
}
if ($request->get_param("action_type")) {
$where[] = "action_type = %s";
$params[] = sanitize_text_field($request->get_param("action_type"));
}
if ($request->get_param("object_type")) {
$where[] = "object_type = %s";
$params[] = sanitize_text_field($request->get_param("object_type"));
}
if ($request->get_param("search")) {
$where[] = "(object_label LIKE %s OR user_name LIKE %s)";
$like = "%" . $wpdb->esc_like(sanitize_text_field($request->get_param("search"))) . "%";
$params[] = $like;
$params[] = $like;
}
$where_sql = implode(" AND ", $where);
$per_page = max(1, min(100, (int) $request->get_param("per_page") ?: 20));
$page = max(1, (int) $request->get_param("page") ?: 1);
$offset = ($page - 1) * $per_page;
// $table is an internal, hardcoded {$wpdb->prefix}-based table name
// (ActivityLog::table_name()); $where_sql is built only from the fixed
// "1=1"/"col = %s" fragments above (never raw user input).
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reading Aurora's own custom activity-log table, no core API for that; $count_sql is prepare()'d whenever $params is non-empty (the only case with real values to bind) — when $params is empty, $count_sql is just the literal "SELECT COUNT(*) FROM {$table} WHERE 1=1" with nothing user-controlled in it at all.
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql));
// $table/$where_sql: same as $count_sql above.
$sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reading Aurora's own custom activity-log table; always passed through $wpdb->prepare() with $params plus the trailing $per_page/$offset bound via %d/%d.
$rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, $offset])));
// Live user info (avatar/role/email) rather than freezing it into each
// log row at record time — reflects the account's CURRENT role/email
// (correct if either changes later) and avoids duplicating PII into
// every single event row when user_id is already a foreign key back to
// it. Batch-fetched once for every distinct user_id on this page,
// rather than one get_userdata() call per row.
$user_ids = array_values(array_unique(array_filter(array_map(function ($row) {
return $row->user_id ? (int) $row->user_id : 0;
}, $rows))));
$users_by_id = [];
if ($user_ids) {
// $roles[0] is a role SLUG (e.g. "shop_manager") — its translated
// display name has to come from wp_roles()'s own registered name for
// that slug, not a guess like ucfirst($slug); that only happens to
// work for the single-word built-in roles.
$all_roles = wp_roles()->roles;
foreach (get_users(["include" => $user_ids, "fields" => "all_with_meta"]) as $u) {
$slug = $u->roles ? $u->roles[0] : "";
$role_name = $slug && isset($all_roles[$slug]) ? translate_user_role($all_roles[$slug]["name"]) : "";
$users_by_id[$u->ID] = [
"avatar" => get_avatar_url($u->ID, ["size" => 64]),
"role" => $role_name,
"email" => $u->user_email,
"editUrl" => esc_url_raw(admin_url("user-edit.php?user_id=" . $u->ID)),
];
}
}
$items = array_map(function ($row) use ($users_by_id) {
$uid = $row->user_id ? (int) $row->user_id : null;
$u = $uid && isset($users_by_id[$uid]) ? $users_by_id[$uid] : null;
return [
"id" => (int) $row->id,
"actionType" => $row->action_type,
"objectType" => $row->object_type,
"objectId" => $row->object_id ? (int) $row->object_id : null,
"objectLabel" => $row->object_label,
"userId" => $uid,
"userName" => $row->user_name,
"userAvatar" => $u ? $u["avatar"] : null,
"userRole" => $u ? $u["role"] : "",
"userEmail" => $u ? $u["email"] : "",
"userEditUrl" => $u ? $u["editUrl"] : "",
"ipAddress" => $row->ip_address,
"details" => $row->details ? json_decode($row->details, true) : null,
"createdAt" => $row->created_at,
];
}, $rows);
return new \WP_REST_Response([
"items" => $items,
"total" => $total,
"page" => $page,
"perPage" => $per_page,
"totalPages" => (int) ceil($total / $per_page),
], 200);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace AuroraAdmin\Analytics;
defined("ABSPATH") || exit();
/**
* Lightweight first-party visitor analytics.
*
* Records one row per front-end pageview into a custom table, server-side
* (no client script, so it isn't blocked by ad blockers). All aggregation
* happens later in the REST endpoint. Written fresh — does not reuse the
* original plugin's analytics code.
*/
class Analytics
{
const TABLE = "aurora_admin_pageviews";
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_analytics_db_version";
const COOKIE = "aurora_admin_sid";
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
// Record on the front end only, after the main query is resolved.
add_action("template_redirect", [self::class, "record"], 999);
}
public static function table_name()
{
global $wpdb;
return $wpdb->prefix . self::TABLE;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
session_id VARCHAR(32) NOT NULL DEFAULT '',
url VARCHAR(255) NOT NULL DEFAULT '',
referrer VARCHAR(255) NOT NULL DEFAULT '',
device VARCHAR(20) NOT NULL DEFAULT 'desktop',
browser VARCHAR(40) NOT NULL DEFAULT '',
country VARCHAR(2) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_created (created_at),
KEY idx_session (session_id)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
/**
* Records the current front-end request as a pageview, skipping admin,
* AJAX/REST/cron, bots, and logged-in administrators.
*/
public static function record()
{
// Tracking is on unless explicitly disabled in Settings > General.
$enabled = \AuroraAdmin\Options\Settings::get("enable_analytics", true);
if ($enabled === false || $enabled === 0 || $enabled === "0") {
return;
}
if (is_admin() || wp_doing_ajax() || wp_doing_cron() || is_robots() || is_feed()) {
return;
}
if (defined("REST_REQUEST") && REST_REQUEST) {
return;
}
// Admins browsing their own site are skipped unless Settings >
// Analytics > "Track logged-in admins" is enabled.
$track_admins = \AuroraAdmin\Options\Settings::get("track_admins", false);
if (!$track_admins && current_user_can("manage_options")) {
return;
}
$ua = isset($_SERVER["HTTP_USER_AGENT"]) ? sanitize_text_field(wp_unslash($_SERVER["HTTP_USER_AGENT"])) : "";
if (self::is_bot($ua)) {
return;
}
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom pageview table.
$wpdb->insert(self::table_name(), [
"session_id" => self::session_id(),
"url" => substr(self::current_path(), 0, 255),
"referrer" => substr(self::referrer(), 0, 255),
"device" => self::device($ua),
"browser" => self::browser($ua),
"country" => self::country(),
"created_at" => current_time("mysql"),
]);
}
private static function session_id()
{
if (!empty($_COOKIE[self::COOKIE])) {
$cookie = sanitize_text_field(wp_unslash($_COOKIE[self::COOKIE]));
return preg_replace("/[^a-f0-9]/", "", substr($cookie, 0, 32));
}
$sid = md5(uniqid("", true));
// 30-minute rolling session window
if (!headers_sent()) {
setcookie(self::COOKIE, $sid, time() + 1800, COOKIEPATH ?: "/", COOKIE_DOMAIN);
}
$_COOKIE[self::COOKIE] = $sid;
return $sid;
}
private static function current_path()
{
$uri = isset($_SERVER["REQUEST_URI"]) ? sanitize_text_field(wp_unslash($_SERVER["REQUEST_URI"])) : "/";
return wp_parse_url($uri, PHP_URL_PATH) ?: "/";
}
private static function referrer()
{
$ref = isset($_SERVER["HTTP_REFERER"]) ? sanitize_text_field(wp_unslash($_SERVER["HTTP_REFERER"])) : "";
if (!$ref) {
return "";
}
$host = wp_parse_url($ref, PHP_URL_HOST);
// Ignore self-referrals
if ($host && $host === wp_parse_url(home_url(), PHP_URL_HOST)) {
return "";
}
return $host ?: "";
}
private static function device($ua)
{
$ua = strtolower($ua);
if (preg_match("/(ipad|tablet|playbook|silk)|(android(?!.*mobile))/", $ua)) {
return "tablet";
}
if (preg_match("/(mobile|iphone|ipod|android|blackberry|opera mini|iemobile)/", $ua)) {
return "mobile";
}
return "desktop";
}
private static function browser($ua)
{
$map = [
"Edg" => "Edge",
"OPR" => "Opera",
"Chrome" => "Chrome",
"Firefox" => "Firefox",
"Safari" => "Safari",
];
foreach ($map as $needle => $name) {
if (stripos($ua, $needle) !== false) {
return $name;
}
}
return "Other";
}
private static function country()
{
// Use a CDN-provided country header if available; otherwise leave blank.
// Sanitized into $value before any check runs on it — the empty()/
// strlen() checks below need the unslashed+sanitized copy too, not
// just the eventual return value, since reading the raw superglobal
// for any purpose (even just a length check) is what the sanitization
// rule actually flags.
foreach (["HTTP_CF_IPCOUNTRY", "GEOIP_COUNTRY_CODE", "HTTP_X_COUNTRY_CODE"] as $h) {
$value = isset($_SERVER[$h]) ? sanitize_text_field(wp_unslash($_SERVER[$h])) : "";
if ($value !== "" && strlen($value) === 2) {
return strtoupper($value);
}
}
return "";
}
private static function is_bot($ua)
{
if (!$ua) {
return true;
}
return (bool) preg_match(
"/bot|crawl|slurp|spider|mediapartners|facebookexternalhit|embedly|quora|pingdom|monitor|headless|lighthouse|gtmetrix/i",
$ua
);
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace AuroraAdmin\App;
// Guard placed immediately after the namespace declaration (rather than
// after the use-import block below, this file's only departure from the
// rest of the codebase's convention) — this file's unusually large
// number of imports pushed the guard past the first several dozen
// lines, past where at least one automated file-access-protection
// scanner looks.
defined("ABSPATH") || exit();
use AuroraAdmin\Options\Settings;
use AuroraAdmin\Shell\Shell;
use AuroraAdmin\Pages\DashboardTakeover;
use AuroraAdmin\Pages\SettingsPage;
use AuroraAdmin\Pages\SetupWizard;
use AuroraAdmin\Rest\DashboardData;
use AuroraAdmin\Analytics\Analytics;
use AuroraAdmin\Rest\AnalyticsData;
use AuroraAdmin\Rest\PluginsData;
use AuroraAdmin\Rest\CommentsData;
use AuroraAdmin\Rest\PostsData;
use AuroraAdmin\Rest\PagesData;
use AuroraAdmin\Rest\MediaData;
use AuroraAdmin\Rest\UsersData;
use AuroraAdmin\Posts\ScheduledDayFilter;
use AuroraAdmin\WhiteLabel\WhiteLabel;
use AuroraAdmin\Pages\SubApps;
use AuroraAdmin\Pages\HijackedNativePages;
use AuroraAdmin\Pages\NativeSettingsPages;
use AuroraAdmin\Rest\PermalinksData;
use AuroraAdmin\Rest\DiscussionData;
use AuroraAdmin\Pages\AppearancePages;
use AuroraAdmin\Pages\ToolsPage;
use AuroraAdmin\Pages\ExportPage;
use AuroraAdmin\Pages\ImportPage;
use AuroraAdmin\Rest\NavMenusData;
use AuroraAdmin\Rest\ThemesData;
use AuroraAdmin\Rest\WidgetsData;
use AuroraAdmin\Rest\ProfileData;
use AuroraAdmin\MenuCreator\MenuCreator;
use AuroraAdmin\MenuCreator\MenuCreatorData;
use AuroraAdmin\Notices\Notices;
use AuroraAdmin\Notices\NoticesData;
use AuroraAdmin\Roles\RolesData;
use AuroraAdmin\ActivityLog\ActivityLog;
use AuroraAdmin\ActivityLog\ActivityLogData;
use AuroraAdmin\Login\LoginStyle;
use AuroraAdmin\Login\LoginRedirect;
use AuroraAdmin\Editor\DisableGutenberg;
use AuroraAdmin\Media\SvgUploads;
use AuroraAdmin\Media\MediaFolders;
use AuroraAdmin\ImageFormats\ImageFormats;
use AuroraAdmin\Security\Security;
use AuroraAdmin\Code\CodeInjection;
use AuroraAdmin\Rest\MediaFolderData;
use AuroraAdmin\Rest\BugReportData;
use AuroraAdmin\Rest\CompanionPluginData;
/**
* Plugin bootstrap. Deliberately minimal: each piece of functionality
* registers its own hooks in its own constructor, instantiated here.
* No global state, no manifest-parsing gymnastics beyond Assets::enqueue,
* no body-hider/temporary-style tricks.
*/
class AuroraAdmin
{
public function __construct()
{
new Settings();
new Shell();
new DashboardTakeover();
new SettingsPage();
new SetupWizard();
new DashboardData();
new Analytics();
new AnalyticsData();
new PluginsData();
new CommentsData();
new PostsData();
new PagesData();
new MediaData();
new UsersData();
new ScheduledDayFilter();
new WhiteLabel();
new SubApps();
new HijackedNativePages();
new NativeSettingsPages();
new PermalinksData();
new DiscussionData();
new AppearancePages();
new ToolsPage();
new ExportPage();
new ImportPage();
new NavMenusData();
new ThemesData();
new WidgetsData();
new ProfileData();
new MenuCreator();
new MenuCreatorData();
new Notices();
new NoticesData();
new RolesData();
new ActivityLog();
new ActivityLogData();
new LoginStyle();
new LoginRedirect();
new DisableGutenberg();
new SvgUploads();
new MediaFolders();
new ImageFormats();
new Security();
new CodeInjection();
new MediaFolderData();
new BugReportData();
new CompanionPluginData();
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace AuroraAdmin\Code;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Custom code injection (Settings > Code).
*
* Four admin-authored fields, only settable by manage_options users on the
* settings page:
* - Admin CSS → wrapped in a <style> in the admin <head>
* - Admin JS → wrapped in a <script> in the admin footer
* - Header scripts → printed raw in the front-end <head>
* - Footer scripts → printed raw before the front-end </body>
*
* Header/footer scripts are printed verbatim (they're meant to hold full
* <script>/<meta> tags — analytics, verification, etc.), the same trusted-
* admin model as any "insert headers and footers" tool. Admin CSS/JS is the
* user's own code too, but is wrapped in the right tag for them and guarded
* against accidentally breaking out of that tag.
*/
class CodeInjection
{
public function __construct()
{
add_action("admin_head", [self::class, "admin_css"], 999);
add_action("admin_print_footer_scripts", [self::class, "admin_js"], 999);
add_action("wp_head", [self::class, "header_scripts"], 999);
add_action("wp_footer", [self::class, "footer_scripts"], 999);
}
public static function admin_css()
{
$css = trim((string) Settings::get("custom_admin_css", ""));
if ($css === "") {
return;
}
// Keep the CSS from breaking out of its own <style> element.
$css = str_ireplace("</style>", "", $css);
echo "\n<style id=\"aurora-custom-admin-css\">\n" . $css . "\n</style>\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- admin-authored stylesheet, output raw inside a <style> tag by design; </style> stripped above to prevent breakout.
}
public static function admin_js()
{
$js = trim((string) Settings::get("custom_admin_js", ""));
if ($js === "") {
return;
}
$js = str_ireplace("</script>", "<\\/script>", $js);
echo "\n<script id=\"aurora-custom-admin-js\">\n" . $js . "\n</script>\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- admin-authored script, output raw inside a <script> tag by design.
}
public static function header_scripts()
{
$code = trim((string) Settings::get("header_scripts", ""));
if ($code === "") {
return;
}
echo "\n" . $code . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- admin-authored header markup (scripts/meta), printed verbatim by design.
}
public static function footer_scripts()
{
$code = trim((string) Settings::get("footer_scripts", ""));
if ($code === "") {
return;
}
echo "\n" . $code . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- admin-authored footer markup (scripts), printed verbatim by design.
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace AuroraAdmin\Editor;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Settings > Screens > Editor: "Disable Gutenberg" — opt-in, off by default.
* Falls back to WordPress core's own classic (meta-box/TinyMCE) post editor
* via the same core-supported filter the official Classic Editor plugin
* uses, rather than framing or rebuilding the block editor itself — the
* block editor stays one of the two deliberate "still native" screens in
* the admin takeover (alongside the Theme/Plugin File Editor), so this is
* an escape hatch around it, not a takeover of it.
*/
class DisableGutenberg
{
public function __construct()
{
add_filter("use_block_editor_for_post_type", [self::class, "maybe_disable"], 100);
}
public static function maybe_disable($use_block_editor)
{
if ((bool) Settings::get("disable_gutenberg", false)) {
return false;
}
return $use_block_editor;
}
}
+346
View File
@@ -0,0 +1,346 @@
<?php
namespace AuroraAdmin\ImageFormats;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Settings > Modern Image Formats: generates a WebP or AVIF sibling file
* for every size WordPress already produces for a new JPEG/PNG upload
* (full size plus every registered subsize), then optionally rewrites
* <img> markup into a <picture> element offering the modern format with
* the original as a fallback source.
*
* Deliberately additive, never destructive: the original JPEG/PNG files
* WordPress generates are always left in place untouched. This plugin
* only ever adds sibling files alongside them and points browsers that
* understand the modern format at those siblings via <picture> — nothing
* about the attachment's own post_mime_type, the original files, or any
* other plugin/theme reading wp_get_attachment_image_src() the normal way
* is changed. Existing library images are unaffected until re-uploaded or
* regenerated (e.g. via the Regenerate Thumbnails plugin or `wp media
* regenerate`), same as any other subsize-generation change.
*
* Generated sibling files are recorded in the attachment's own metadata
* under the 'aurora_image_formats' key (a sibling structure to core's own
* 'sizes' key, one entry per size name plus 'full') so cleanup on delete
* and markup rewriting both have a single source of truth for what was
* actually generated.
*/
class ImageFormats
{
const ELIGIBLE_MIME_TYPES = ["image/jpeg", "image/png"];
public function __construct()
{
add_filter("wp_generate_attachment_metadata", [self::class, "generate_sibling_formats"], 20, 2);
add_action("delete_attachment", [self::class, "cleanup_sibling_formats"]);
add_filter("wp_editor_set_quality", [self::class, "set_quality"], 10, 2);
add_filter("wp_content_img_tag", [self::class, "filter_content_img_tag"], 10, 3);
add_filter("wp_get_attachment_image_html", [self::class, "filter_attachment_image_html"], 10, 5);
}
private static function enabled()
{
return (bool) Settings::get("image_formats_enabled", true);
}
/**
* A single target mime type, not a list — matches the Settings UI's
* single "Output format" choice. 'auto' prefers AVIF (smaller files at
* a given quality) and falls back to WebP; an explicit choice that the
* server can't actually produce (checked via wp_image_editor_supports(),
* the same core function any image-editor-capability check should use)
* returns null, which callers treat as "generate nothing" rather than
* erroring — a host without modern format support just doesn't get the
* feature, silently, same as the rest of this plugin's graceful-
* degradation posture elsewhere (e.g. AVIF write support genuinely
* varies a lot by ImageMagick build).
*/
private static function target_mime_type()
{
$format = (string) Settings::get("image_formats_format", "auto");
if ($format === "webp") {
return wp_image_editor_supports(["mime_type" => "image/webp"]) ? "image/webp" : null;
}
if ($format === "avif") {
return wp_image_editor_supports(["mime_type" => "image/avif"]) ? "image/avif" : null;
}
if (wp_image_editor_supports(["mime_type" => "image/avif"])) {
return "image/avif";
}
if (wp_image_editor_supports(["mime_type" => "image/webp"])) {
return "image/webp";
}
return null;
}
/**
* Runs after WordPress's own subsize generation (priority 20, and in
* any case wp_generate_attachment_metadata() only fires this filter
* once 'sizes' is already fully populated) — so every size this method
* sees already has a real file on disk to re-encode.
*/
public static function generate_sibling_formats($metadata, $attachment_id)
{
if (!self::enabled() || !is_array($metadata)) {
return $metadata;
}
$mime = get_post_mime_type($attachment_id);
if (!in_array($mime, self::ELIGIBLE_MIME_TYPES, true)) {
return $metadata;
}
$target_mime = self::target_mime_type();
if ($target_mime === null) {
return $metadata;
}
$file = get_attached_file($attachment_id);
if (!$file || !file_exists($file)) {
return $metadata;
}
$formats = [];
$full = self::generate_sibling($file, $target_mime);
if ($full !== null) {
$formats["full"] = $full;
}
if (!empty($metadata["sizes"]) && is_array($metadata["sizes"])) {
$base_dir = dirname($file);
foreach ($metadata["sizes"] as $size_name => $size_data) {
if (empty($size_data["file"])) {
continue;
}
$size_path = $base_dir . "/" . $size_data["file"];
if (!file_exists($size_path)) {
continue;
}
$sibling = self::generate_sibling($size_path, $target_mime);
if ($sibling !== null) {
$formats[$size_name] = $sibling;
}
}
}
if (!empty($formats)) {
$metadata["aurora_image_formats"] = $formats;
}
return $metadata;
}
/**
* Re-encodes a single already-generated size file into the target mime
* type, in the same directory (WP_Image_Editor::save() with a null
* filename derives a sibling filename from the source automatically).
* Discards the result if it isn't actually smaller than the source —
* there's no point keeping a "modern format" copy around that's bigger
* than the file it's meant to replace on the wire.
*/
private static function generate_sibling($source_path, $target_mime)
{
$editor = wp_get_image_editor($source_path);
if (is_wp_error($editor)) {
return null;
}
$saved = $editor->save(null, $target_mime);
if (is_wp_error($saved) || empty($saved["file"])) {
return null;
}
$original_size = @filesize($source_path);
if ($original_size && !empty($saved["filesize"]) && $saved["filesize"] >= $original_size) {
if (!empty($saved["path"]) && file_exists($saved["path"])) {
wp_delete_file($saved["path"]);
}
return null;
}
return [
"file" => $saved["file"],
"filesize" => $saved["filesize"] ?? null,
"mime-type" => $saved["mime-type"] ?? $target_mime,
];
}
/**
* Quality for the modern formats specifically — core's own default
* (82 for most editors) is a reasonable target for WebP/AVIF too, but
* setting it explicitly here means a future change to core's JPEG
* default doesn't silently change modern-format quality along with it.
* Untouched for every other mime type.
*/
public static function set_quality($quality, $mime_type)
{
if ($mime_type === "image/webp" || $mime_type === "image/avif") {
return 82;
}
return $quality;
}
/**
* Deletes every generated sibling file when the attachment itself is
* deleted — WordPress core only knows to clean up the files it
* generated itself (recorded in 'sizes'), not this plugin's additions,
* so without this every deleted image would leave orphaned .webp/.avif
* files behind indefinitely.
*/
public static function cleanup_sibling_formats($attachment_id)
{
$metadata = wp_get_attachment_metadata($attachment_id);
if (empty($metadata["aurora_image_formats"]) || !is_array($metadata["aurora_image_formats"])) {
return;
}
$file = get_attached_file($attachment_id);
if (!$file) {
return;
}
$base_dir = dirname($file);
foreach ($metadata["aurora_image_formats"] as $entry) {
if (empty($entry["file"])) {
continue;
}
$path = $base_dir . "/" . $entry["file"];
if (file_exists($path)) {
wp_delete_file($path);
}
}
}
// --- <picture> markup rewriting -----------------------------------------
/**
* Covers images WordPress finds inside post content, widgets, and
* similar rendered text (wp_filter_content_tags() applies this core
* filter to each <img> tag it detects).
*/
public static function filter_content_img_tag($filtered_image, $context, $attachment_id)
{
$picture = self::build_picture($filtered_image, (int) $attachment_id);
return $picture ?? $filtered_image;
}
/**
* Covers images rendered directly via wp_get_attachment_image() (post
* thumbnails, block-editor image blocks that call it, etc.) — a
* separate core filter from wp_content_img_tag above since core itself
* doesn't route wp_get_attachment_image() output back through that one.
*/
public static function filter_attachment_image_html($html, $attachment_id, $size, $icon, $attr)
{
$picture = self::build_picture($html, (int) $attachment_id);
return $picture ?? $html;
}
/**
* Returns a <picture> wrapping the original $html as the fallback
* child, with one <source> offering the generated modern format, or
* null if there's nothing to rewrite (feature/picture-element disabled,
* no <img> tag present, or no sibling formats were ever generated for
* this attachment).
*/
private static function build_picture($html, $attachment_id)
{
if (!self::enabled() || !Settings::get("image_formats_picture_element", true)) {
return null;
}
if ($attachment_id <= 0 || strpos($html, "<img") === false) {
return null;
}
$metadata = wp_get_attachment_metadata($attachment_id);
if (empty($metadata["aurora_image_formats"]) || !is_array($metadata["aurora_image_formats"])) {
return null;
}
$srcset = self::build_srcset($metadata);
if ($srcset === "") {
return null;
}
$formats = $metadata["aurora_image_formats"];
$representative = $formats["full"] ?? reset($formats);
$mime = $representative["mime-type"] ?? "";
if ($mime === "") {
return null;
}
// The original <img>'s own `sizes` attribute already reflects the
// theme/layout's responsive-width logic (core computed it once when
// building the <img> tag) — reusing it on the <source> is correct and
// avoids re-deriving the same value a second way.
$sizes_attr = "";
if (preg_match('/\ssizes=["\']([^"\']+)["\']/', $html, $m)) {
$sizes_attr = ' sizes="' . esc_attr($m[1]) . '"';
}
$source = sprintf(
'<source type="%1$s" srcset="%2$s"%3$s>',
esc_attr($mime),
esc_attr($srcset),
$sizes_attr
);
return "<picture>" . $source . $html . "</picture>";
}
private static function build_srcset($metadata)
{
$formats = $metadata["aurora_image_formats"];
$entries = [];
if (!empty($formats["full"]) && !empty($metadata["width"])) {
$entry = self::srcset_entry($metadata, $formats["full"], (int) $metadata["width"]);
if ($entry !== "") {
$entries[] = $entry;
}
}
if (!empty($metadata["sizes"]) && is_array($metadata["sizes"])) {
foreach ($metadata["sizes"] as $size_name => $size_data) {
if (empty($formats[$size_name]) || empty($size_data["width"])) {
continue;
}
$entry = self::srcset_entry($metadata, $formats[$size_name], (int) $size_data["width"]);
if ($entry !== "") {
$entries[] = $entry;
}
}
}
return implode(", ", array_unique($entries));
}
/**
* Sibling files live in the same directory as the size file they were
* generated from, which for a standard (non-year/month-disabled)
* install is dirname($metadata['file']) relative to the uploads
* baseurl — the same relative-path convention core's own subsize URLs
* use, not something specific to this plugin's file layout.
*/
private static function srcset_entry($metadata, $format_entry, $width)
{
if (empty($format_entry["file"]) || $width <= 0 || empty($metadata["file"])) {
return "";
}
$upload_dir = wp_get_upload_dir();
$relative_dir = trailingslashit(dirname($metadata["file"]));
if ($relative_dir === "./") {
$relative_dir = "";
}
$url = $upload_dir["baseurl"] . "/" . $relative_dir . $format_entry["file"];
return esc_url_raw($url) . " " . $width . "w";
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace AuroraAdmin\Login;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Settings > Login > "Login Redirect": optionally sends logged-out wp-admin
* visitors to a custom URL instead of the login screen, and/or sends
* specific roles to a custom URL after logging in instead of the dashboard.
* Both are opt-in and empty by default (normal WordPress behavior).
*
* Deliberately does NOT touch wp-login.php itself — it is never hidden,
* blocked, or moved, so this can only change where someone lands
* before/after that point, never whether they can reach the login form.
* Changing/hiding the login URL itself is a separate, higher-risk feature
* not implemented here.
*/
class LoginRedirect
{
public function __construct()
{
add_filter("login_redirect", [self::class, "after_login"], 10, 3);
add_filter("allowed_redirect_hosts", [self::class, "allow_configured_hosts"]);
// wp_loaded, not admin_init: WordPress's own auth_redirect() (called
// from wp-admin/admin.php, before admin_init fires) already redirects
// an unauthenticated visitor to wp-login.php and exits — admin_init
// would never run for the case this needs to catch. wp_loaded fires
// from inside wp-settings.php, which wp-admin/admin.php requires and
// waits on BEFORE calling auth_redirect(), so it reliably runs first.
add_action("wp_loaded", [self::class, "guard_admin"]);
}
private static function enabled()
{
return (bool) Settings::get("login_redirect_enabled", false);
}
/** Destination after a successful login, for the configured roles. */
public static function after_login($redirect_to, $requested_redirect_to, $user)
{
if (!self::enabled() || !($user instanceof \WP_User) || !$user->exists()) {
return $redirect_to;
}
$roles = (array) Settings::get("redirect_roles", []);
if (!$roles || !array_intersect((array) $user->roles, $roles)) {
return $redirect_to;
}
$target = trim((string) Settings::get("redirect_after_login_url", ""));
return $target !== "" ? esc_url_raw($target) : $redirect_to;
}
/**
* Sends logged-out visitors to a custom URL instead of the default
* wp-login.php screen when they try to open wp-admin. Scoped tightly to
* real browser page loads under wp-admin — admin-ajax.php and
* admin-post.php (both used by logged-out requests via the *_nopriv_*
* hooks other plugins rely on for public form handling), async uploads,
* REST, and multisite's network admin are all left untouched, so nothing
* outside the login screen itself is affected.
*/
public static function guard_admin()
{
if (!self::enabled() || is_user_logged_in()) {
return;
}
if (!is_admin() || is_network_admin() || wp_doing_ajax()) {
return;
}
if (defined("REST_REQUEST") && REST_REQUEST) {
return;
}
$excluded_scripts = ["admin-post.php", "admin-ajax.php", "async-upload.php"];
if (in_array($GLOBALS["pagenow"] ?? "", $excluded_scripts, true)) {
return;
}
$target = trim((string) Settings::get("redirect_unauthenticated_url", ""));
// Empty, or would send a logged-out visitor straight back into
// wp-admin — the latter is almost certainly a misconfiguration and
// would otherwise create a redirect loop.
if ($target === "" || stripos($target, "wp-admin") !== false) {
return;
}
wp_safe_redirect(esc_url_raw($target));
exit();
}
/**
* Lets wp_safe_redirect() honor an external target the admin configured
* for either redirect above, instead of silently falling back to the site
* home URL — both settings come from manage_options-gated Settings, never
* from request input, so there's no open-redirect exposure in trusting
* their host.
*/
public static function allow_configured_hosts($hosts)
{
foreach (["redirect_unauthenticated_url", "redirect_after_login_url"] as $key) {
$host = wp_parse_url(trim((string) Settings::get($key, "")), PHP_URL_HOST);
if ($host) {
$hosts[] = $host;
}
}
return $hosts;
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace AuroraAdmin\Login;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Applies Settings > Login's "Style login page" and "Login logo" fields to
* wp-login.php. Both settings existed in the UI with no implementation
* behind them until now — this is that implementation.
*
* Deliberately a single fixed dark theme rather than reusing the admin
* shell's per-preset palette system (themePresets.js): the login screen
* loads independently of wp-admin, so none of the --aurora-* CSS variables
* set on html.aurora-ready are available there, and duplicating the
* hue-ramp logic in PHP just to match whichever preset happens to be
* selected wasn't worth the upkeep for a first pass.
*/
class LoginStyle
{
public function __construct()
{
add_action("login_enqueue_scripts", [self::class, "print_styles"]);
add_filter("login_headerurl", [self::class, "header_url"]);
}
private static function enabled()
{
return (bool) Settings::get("style_login", false);
}
public static function header_url($url)
{
return self::enabled() ? home_url("/") : $url;
}
public static function print_styles()
{
if (!self::enabled()) {
return;
}
$logo = trim((string) Settings::get("login_logo", ""));
$bg_image = trim((string) Settings::get("login_bg_image", ""));
// Colors fall back to the original fixed dark theme when unset; invalid
// values are dropped by sanitize_hex_color (returns null) then fall back.
$bg_color = sanitize_hex_color((string) Settings::get("login_bg_color", "")) ?: "";
$form_bg = sanitize_hex_color((string) Settings::get("login_form_bg", "")) ?: "#1c2333";
$accent = sanitize_hex_color((string) Settings::get("login_button_color", "")) ?: "#4f7cff";
$custom_css = str_ireplace("</style>", "", (string) Settings::get("login_custom_css", ""));
if ($bg_image) {
$background = "url(" . esc_url($bg_image) . ") center / cover no-repeat";
} elseif ($bg_color) {
$background = $bg_color;
} else {
$background = "linear-gradient(135deg, #1b2333 0%, #0f1420 100%)";
}
?>
<style>
body.login {
background: <?php echo $background; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- composed from a sanitize_hex_color() color or esc_url()'d image above. ?>;
}
body.login #login h1 a {
<?php if ($logo): ?>
background-image: url(<?php echo esc_url($logo); ?>);
background-size: contain;
background-position: center;
width: 100%;
height: 80px;
<?php else: ?>
filter: brightness(0) invert(1);
<?php endif; ?>
}
body.login #login {
width: 360px;
}
body.login form#loginform,
body.login form#registerform,
body.login form#lostpasswordform {
background: <?php echo esc_attr($form_bg); ?>;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
padding: 26px 24px 24px;
}
body.login label {
color: #cbd3e1;
}
body.login input[type="text"],
body.login input[type="password"],
body.login input[type="email"] {
background: #10141f;
border-color: rgba(255, 255, 255, 0.12);
color: #fff;
box-shadow: none;
}
body.login input[type="text"]:focus,
body.login input[type="password"]:focus,
body.login input[type="email"]:focus {
border-color: <?php echo esc_attr($accent); ?>;
box-shadow: 0 0 0 1px <?php echo esc_attr($accent); ?>;
}
body.login .forgetmenot label {
color: #9fb0d0;
}
body.login #backtoblog a,
body.login #nav a {
color: #9fb0d0;
}
body.login #backtoblog a:hover,
body.login #nav a:hover {
color: #fff;
}
body.login .wp-core-ui .button-primary {
background: <?php echo esc_attr($accent); ?>;
border-color: <?php echo esc_attr($accent); ?>;
box-shadow: none;
text-shadow: none;
}
body.login .wp-core-ui .button-primary:hover,
body.login .wp-core-ui .button-primary:focus {
filter: brightness(0.92);
}
body.login #login_error,
body.login .message,
body.login .success {
background: #232b40;
border-left-color: <?php echo esc_attr($accent); ?>;
color: #e2e8f4;
}
<?php echo $custom_css; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- admin-authored login CSS, output raw by design; </style> stripped above. ?>
</style>
<?php
}
}
+392
View File
@@ -0,0 +1,392 @@
<?php
namespace AuroraAdmin\Media;
defined("ABSPATH") || exit();
/**
* Storage + model for Media Library folders — duplicates the organizing
* capability of the FileBird Pro plugin, at the user's request, with its
* own independent schema rather than reusing FileBird's tables.
*
* Deliberately simpler than FileBird's own model in two ways:
* - No folder "type" (folder vs. collection/saved-search) — collections
* add no organizational value over folders, skipped unless asked for.
* - One folder per attachment, enforced here (assign_attachments() always
* removes any existing membership row first) — avoids the ambiguity of
* multi-folder membership in the UI, even though the join table's shape
* would technically allow it.
*
* REST surface lives in MediaFolderData.php; this class only owns storage.
*/
class MediaFolders
{
const TABLE_FOLDERS = "aurora_media_folders";
const TABLE_ITEMS = "aurora_media_folder_items";
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_media_folders_db_version";
// Pseudo-folder ids the UI always shows above the real tree. Never
// written to the folders table — resolved specially wherever a folder id
// is accepted (get_tree(), MediaData::list_media()).
const ALL_MEDIA = 0;
const UNCATEGORIZED = -1;
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
}
public static function folders_table()
{
global $wpdb;
return $wpdb->prefix . self::TABLE_FOLDERS;
}
public static function items_table()
{
global $wpdb;
return $wpdb->prefix . self::TABLE_ITEMS;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$charset = $wpdb->get_charset_collate();
$folders_sql = "CREATE TABLE " . self::folders_table() . " (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(191) NOT NULL,
parent BIGINT UNSIGNED NOT NULL DEFAULT 0,
ord INT NOT NULL DEFAULT 0,
color VARCHAR(7) NULL,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_parent (parent)
) {$charset};";
$items_sql = "CREATE TABLE " . self::items_table() . " (
folder_id BIGINT UNSIGNED NOT NULL,
attachment_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (folder_id, attachment_id),
KEY idx_attachment (attachment_id)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($folders_sql);
dbDelta($items_sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
/**
* Full folder tree (nested children[]) with per-folder attachment counts,
* plus the two pseudo-folders pinned above it by the caller (REST layer),
* not here — this only returns the real, user-created folders.
*/
public static function get_tree()
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query.
$rows = $wpdb->get_results("SELECT id, name, parent, ord, color FROM " . self::folders_table() . " ORDER BY parent ASC, ord ASC, name ASC", ARRAY_A);
$counts = self::counts_by_folder();
$by_parent = [];
foreach ($rows as $row) {
$row["id"] = (int) $row["id"];
$row["parent"] = (int) $row["parent"];
$row["ord"] = (int) $row["ord"];
$row["count"] = $counts[$row["id"]] ?? 0;
$row["children"] = [];
$by_parent[$row["parent"]][] = $row;
}
$build = function ($parent_id) use (&$build, &$by_parent) {
$children = $by_parent[$parent_id] ?? [];
foreach ($children as &$child) {
$child["children"] = $build($child["id"]);
}
return $children;
};
return $build(0);
}
private static function counts_by_folder()
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names from internal, hardcoded {$wpdb->prefix}-based constants, not user input; no user-supplied values in this query.
$rows = $wpdb->get_results("SELECT fi.folder_id, COUNT(*) AS c FROM " . self::items_table() . " fi INNER JOIN {$wpdb->posts} p ON p.ID = fi.attachment_id WHERE p.post_type = 'attachment' AND p.post_status = 'inherit' GROUP BY fi.folder_id");
$counts = [];
foreach ($rows as $row) {
$counts[(int) $row->folder_id] = (int) $row->c;
}
return $counts;
}
public static function total_attachment_count()
{
return (int) wp_count_posts("attachment")->inherit;
}
public static function uncategorized_count()
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query.
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->posts} p WHERE p.post_type = 'attachment' AND p.post_status = 'inherit' AND p.ID NOT IN (SELECT attachment_id FROM " . self::items_table() . ")");
}
public static function create_folder($name, $parent)
{
global $wpdb;
$name = self::unique_name(trim((string) $name), (int) $parent);
if ($name === "") {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$ord = (int) $wpdb->get_var($wpdb->prepare("SELECT COALESCE(MAX(ord), -1) + 1 FROM " . self::folders_table() . " WHERE parent = %d", (int) $parent));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->insert(
self::folders_table(),
[
"name" => $name,
"parent" => (int) $parent,
"ord" => $ord,
"created_by" => get_current_user_id(),
"created_at" => current_time("mysql"),
],
["%s", "%d", "%d", "%d", "%s"]
);
return (int) $wpdb->insert_id;
}
private static function unique_name($name, $parent)
{
if ($name === "") {
return "";
}
global $wpdb;
$base = $name;
$suffix = 2;
while (self::name_taken($parent, $name)) {
$name = $base . " ({$suffix})";
$suffix++;
}
return $name;
}
private static function name_taken($parent, $name)
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d/%s values are passed through $wpdb->prepare() below.
return (bool) $wpdb->get_var($wpdb->prepare("SELECT id FROM " . self::folders_table() . " WHERE parent = %d AND name = %s", (int) $parent, $name));
}
public static function rename_folder($id, $name)
{
global $wpdb;
$id = (int) $id;
$name = trim((string) $name);
if (!$id || $name === "") {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$parent = (int) $wpdb->get_var($wpdb->prepare("SELECT parent FROM " . self::folders_table() . " WHERE id = %d", $id));
$name = self::unique_name($name, $parent);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->update(self::folders_table(), ["name" => $name], ["id" => $id], ["%s"], ["%d"]);
return true;
}
/**
* Reparents a folder and renumbers the destination parent's children by
* the caller-supplied order. Simpler than FileBird's live insert-shift
* math (which recomputes `ord` for every sibling on every drag step) —
* the UI sends the full sibling id list in its new order after a drop,
* and this just writes that order out in one pass.
*/
public static function move_folder($id, $new_parent, $sibling_order = [])
{
global $wpdb;
$id = (int) $id;
$new_parent = (int) $new_parent;
if ($id === $new_parent) {
return false;
}
if (self::is_descendant($new_parent, $id)) {
// Refuse to move a folder into its own descendant — would orphan
// the subtree into an unreachable cycle.
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$name = $wpdb->get_var($wpdb->prepare("SELECT name FROM " . self::folders_table() . " WHERE id = %d", $id));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$current_parent = (int) $wpdb->get_var($wpdb->prepare("SELECT parent FROM " . self::folders_table() . " WHERE id = %d", $id));
if ($current_parent !== $new_parent) {
$renamed = self::unique_name((string) $name, $new_parent);
if ($renamed !== $name) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->update(self::folders_table(), ["name" => $renamed], ["id" => $id], ["%s"], ["%d"]);
}
}
$order = array_map("intval", $sibling_order);
if (!in_array($id, $order, true)) {
$order[] = $id;
}
foreach ($order as $index => $sibling_id) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->update(
self::folders_table(),
["parent" => $new_parent, "ord" => $index],
["id" => $sibling_id],
["%d", "%d"],
["%d"]
);
}
return true;
}
private static function is_descendant($maybe_descendant_id, $ancestor_id)
{
global $wpdb;
$current = (int) $maybe_descendant_id;
// Bounded walk up the parent chain — folder depth is never large
// enough for this to matter, and it guarantees termination even if
// data were ever corrupted into a cycle.
for ($i = 0; $i < 100 && $current > 0; $i++) {
if ($current === (int) $ancestor_id) {
return true;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$current = (int) $wpdb->get_var($wpdb->prepare("SELECT parent FROM " . self::folders_table() . " WHERE id = %d", $current));
}
return false;
}
/**
* Deletes a folder. Child folders are reparented to root rather than
* cascade-deleted — less destructive default, matches the plan's choice
* over FileBird's own recursive delete.
*/
public static function delete_folder($id)
{
global $wpdb;
$id = (int) $id;
if (!$id) {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->update(self::folders_table(), ["parent" => 0], ["parent" => $id], ["%d"], ["%d"]);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->delete() is WordPress's own escaping-safe API for writing to Aurora's custom folder-items table.
$wpdb->delete(self::items_table(), ["folder_id" => $id], ["%d"]);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->delete() is WordPress's own escaping-safe API for writing to Aurora's custom folders table.
$wpdb->delete(self::folders_table(), ["id" => $id], ["%d"]);
return true;
}
/**
* Moves the given attachments into $folder_id, replacing any existing
* folder membership (enforces one-folder-per-attachment). $folder_id of
* UNCATEGORIZED just removes membership without assigning a new folder.
*/
public static function assign_attachments($folder_id, $attachment_ids)
{
global $wpdb;
$folder_id = (int) $folder_id;
$attachment_ids = array_map("intval", (array) $attachment_ids);
$attachment_ids = array_filter($attachment_ids);
if (empty($attachment_ids)) {
return false;
}
$placeholders = implode(",", array_fill(0, count($attachment_ids), "%d"));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is an internal, hardcoded {$wpdb->prefix}-based constant, not user input; {$placeholders} is a dynamically built run of %d tokens (one per element of $attachment_ids) consumed by the prepare() call immediately below, so the placeholders are in fact present and filled.
$wpdb->query($wpdb->prepare("DELETE FROM " . self::items_table() . " WHERE attachment_id IN ({$placeholders})", ...$attachment_ids));
if ($folder_id === self::UNCATEGORIZED || $folder_id === self::ALL_MEDIA) {
return true;
}
foreach ($attachment_ids as $attachment_id) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom folder-items table.
$wpdb->insert(
self::items_table(),
["folder_id" => $folder_id, "attachment_id" => $attachment_id],
["%d", "%d"]
);
}
return true;
}
public static function attachment_ids_in_folder($folder_id)
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
return array_map("intval", $wpdb->get_col($wpdb->prepare("SELECT attachment_id FROM " . self::items_table() . " WHERE folder_id = %d", (int) $folder_id)));
}
public static function folder_id_for_attachment($attachment_id)
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$folder_id = $wpdb->get_var($wpdb->prepare("SELECT folder_id FROM " . self::items_table() . " WHERE attachment_id = %d", (int) $attachment_id));
return $folder_id !== null ? (int) $folder_id : self::UNCATEGORIZED;
}
public static function all_assigned_attachment_ids()
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query.
return array_map("intval", $wpdb->get_col("SELECT DISTINCT attachment_id FROM " . self::items_table()));
}
/**
* Batch lookup, used by MediaData::list_media() so a page of results
* costs one query instead of one per attachment.
*/
public static function folder_ids_for_attachments($attachment_ids)
{
$attachment_ids = array_map("intval", array_filter((array) $attachment_ids));
if (empty($attachment_ids)) {
return [];
}
global $wpdb;
$placeholders = implode(",", array_fill(0, count($attachment_ids), "%d"));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is an internal, hardcoded {$wpdb->prefix}-based constant, not user input; {$placeholders} is a dynamically built run of %d tokens (one per element of $attachment_ids, each cast via array_map("intval", ...) above) consumed by the prepare() call immediately below via PHP's ...$attachment_ids spread — the placeholder count and value count are mechanically the same array, so they always match; this exact pattern (dynamic IN-clause placeholder count built from array_fill(), spread into prepare()) is WordPress core's own documented way to safely parameterize a variable-length IN clause, since prepare() has no native "bind an array" placeholder.
$rows = $wpdb->get_results($wpdb->prepare("SELECT attachment_id, folder_id FROM " . self::items_table() . " WHERE attachment_id IN ({$placeholders})", ...$attachment_ids));
$map = [];
foreach ($rows as $row) {
$map[(int) $row->attachment_id] = (int) $row->folder_id;
}
return $map;
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace AuroraAdmin\Media;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Implements Settings > Media's "Allow SVG uploads" toggle — previously
* existed in the UI with no backend behind it, so every SVG upload was
* rejected by WordPress's default checks regardless of the setting.
*
* Two separate WP checks both have to pass, not just one:
* 1. upload_mimes — the allowlist of extension => mime type pairs.
* 2. wp_check_filetype_and_ext()'s real-mime sniff (finfo) — SVG is XML
* text, so finfo reports something like text/plain, which doesn't
* match image/svg+xml even after (1) allowlists it, and WordPress
* nulls out ext/type as a security measure. Fixed here by restoring
* them for .svg files specifically, which is the standard, documented
* workaround for this exact WP behavior.
*
* Also does basic sanitization on upload (strips <script> tags, inline
* event-handler attributes, javascript: URIs) since SVG can carry
* executable content and is a known stored-XSS vector once uploads are
* opened up. This is a regex-based pass, not a full XML-parser-based
* sanitizer — no Composer dependency in this project to lean on one.
* Good enough to block the common attack patterns; not a substitute for
* a hardened library (e.g. enshrined/svg-sanitize) if that matters more
* than keeping this dependency-free.
*/
class SvgUploads
{
public function __construct()
{
if (!Settings::get("enable_svg", false)) {
return;
}
add_filter("upload_mimes", [self::class, "allow_mime"]);
add_filter("wp_check_filetype_and_ext", [self::class, "fix_filetype_check"], 10, 4);
add_filter("wp_handle_upload_prefilter", [self::class, "sanitize"]);
add_filter("wp_generate_attachment_metadata", [self::class, "add_dimensions"], 10, 2);
}
public static function allow_mime($mimes)
{
$mimes["svg"] = "image/svg+xml";
return $mimes;
}
public static function fix_filetype_check($data, $file, $filename, $mimes)
{
if (!empty($data["ext"]) && !empty($data["type"])) {
return $data;
}
$wp_filetype = wp_check_filetype($filename, $mimes);
if ($wp_filetype["ext"] === "svg" && $wp_filetype["type"] === "image/svg+xml") {
$data["ext"] = "svg";
$data["type"] = "image/svg+xml";
}
return $data;
}
public static function sanitize($file)
{
if (!isset($file["tmp_name"], $file["name"])) {
return $file;
}
if (strtolower(pathinfo($file["name"], PATHINFO_EXTENSION)) !== "svg") {
return $file;
}
$content = file_get_contents($file["tmp_name"]);
if ($content === false || stripos($content, "<svg") === false) {
$file["error"] = __("File does not appear to be a valid SVG.", "aurora-admin");
return $file;
}
$clean = preg_replace(
[
"/<script\b[^>]*>.*?<\/script>/is",
'/\son\w+\s*=\s*"[^"]*"/i',
"/\son\w+\s*=\s*'[^']*'/i",
'/\son\w+\s*=\s*[^\s>]+/i',
'/(href|xlink:href)\s*=\s*"javascript:[^"]*"/i',
],
"",
$content
);
file_put_contents($file["tmp_name"], $clean);
return $file;
}
/**
* SVGs have no raster dimensions getimagesize()/Imagick/GD can read, so
* WordPress's own metadata generation leaves 'width'/'height' out of the
* attachment metadata entirely for them. That's not just cosmetic —
* wp_get_missing_image_subsizes() (wp-admin/includes/image.php, called
* right after generation) unconditionally reads $image_meta['width']/
* ['height'] in its no-imagesize-available branch, throwing "Undefined
* array key" warnings. On any environment with warnings displayed
* inline (common on staging/debug setups), that warning text gets
* prepended to the REST/AJAX response body, which the media uploader's
* JS can't parse as JSON — surfacing to the user as "upload failed"
* with no useful message. Parsing real dimensions out of the SVG here
* fixes both the correctness gap and that failure mode.
*/
public static function add_dimensions($metadata, $attachment_id)
{
$file = get_attached_file($attachment_id);
if (!$file || strtolower(pathinfo($file, PATHINFO_EXTENSION)) !== "svg") {
return $metadata;
}
$size = self::svg_dimensions($file);
$metadata["width"] = $size["width"];
$metadata["height"] = $size["height"];
return $metadata;
}
private static function svg_dimensions($file)
{
$width = 100;
$height = 100;
$content = file_get_contents($file);
if ($content !== false) {
if (preg_match('/<svg\b[^>]*\bviewBox=["\']\s*[\d.\-]+[\s,]+[\d.\-]+[\s,]+([\d.\-]+)[\s,]+([\d.\-]+)/i', $content, $m)) {
$width = (float) $m[1];
$height = (float) $m[2];
} elseif (
preg_match('/<svg\b[^>]*\bwidth=["\']([\d.]+)/i', $content, $mw) &&
preg_match('/<svg\b[^>]*\bheight=["\']([\d.]+)/i', $content, $mh)
) {
$width = (float) $mw[1];
$height = (float) $mh[1];
}
}
return ["width" => max(1, (int) round($width)), "height" => max(1, (int) round($height))];
}
}
+192
View File
@@ -0,0 +1,192 @@
<?php
namespace AuroraAdmin\MenuCreator;
defined("ABSPATH") || exit();
/**
* Custom admin-menu configurations: hide/rename/reorder WP's native menu
* items per role. Storage + the runtime hook that actually applies a
* published config live here; REST CRUD lives in MenuCreatorData.php.
*/
class MenuCreator
{
const TABLE = "aurora_admin_menus";
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_menus_db_version";
/** Cached merged item list for the current user (per request). */
private static $items_cache = null;
/** Raw top-level menu snapshot, captured before apply() hides anything. */
private static $native_snapshot = null;
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
// Snapshot the full native menu just before apply() runs, so the editor
// can list (and re-enable) items this config hides. Both run after every
// other plugin has registered its menu items.
add_action("admin_menu", [self::class, "capture_native"], PHP_INT_MAX - 1);
add_action("admin_menu", [self::class, "apply"], PHP_INT_MAX);
}
/** Captures the raw top-level menu for the editor (see native_snapshot()). */
public static function capture_native()
{
self::$native_snapshot = \AuroraAdmin\Shell\MenuSerializer::top_level_items();
}
/**
* The raw top-level menu items for the current admin page load. The Menu
* Creator page feeds this into its serverData (SubApps.php) because the
* REST route can't build the admin menu.
*/
public static function native_snapshot()
{
return is_array(self::$native_snapshot) ? self::$native_snapshot : [];
}
public static function table_name()
{
global $wpdb;
return $wpdb->prefix . self::TABLE;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(191) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'draft',
roles TEXT NULL,
items LONGTEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_status (status)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
/**
* Merged item list from every published config whose target roles match
* the current user. Cached for the request (read by both apply() and
* ordered_slugs() on every admin page load).
*/
private static function current_user_items()
{
if (self::$items_cache !== null) {
return self::$items_cache;
}
self::$items_cache = [];
global $wpdb;
if (!is_admin() || !function_exists("wp_get_current_user")) {
return self::$items_cache;
}
$user = wp_get_current_user();
if (!$user || !$user->exists()) {
return self::$items_cache;
}
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name, not user input; no user-supplied values in this query. Runs on every admin page load to build the sidebar menu, so a per-request cache wouldn't help; a persistent cache would need its own invalidation on every config save, more complexity than this read is worth.
$configs = $wpdb->get_results("SELECT roles, items FROM {$table} WHERE status = 'published'");
if (!$configs) {
return self::$items_cache;
}
$items = [];
foreach ($configs as $config) {
$roles = json_decode($config->roles ?: "[]", true);
if (!is_array($roles) || !$roles || !array_intersect((array) $user->roles, $roles)) {
continue;
}
$decoded = json_decode($config->items ?: "[]", true);
if (is_array($decoded)) {
$items = array_merge($items, $decoded);
}
}
self::$items_cache = $items;
return $items;
}
/**
* Hides items marked hidden and relabels items with a custom label, on the
* native top-level $menu (which Aurora's MenuSerializer then reads).
*/
public static function apply()
{
global $menu;
if (!is_array($menu)) {
return;
}
foreach (self::current_user_items() as $item) {
$native_id = isset($item["native_id"]) ? $item["native_id"] : "";
if ($native_id === "") {
continue;
}
foreach ($menu as $key => $entry) {
if (!isset($entry[2]) || $entry[2] !== $native_id) {
continue;
}
if (!empty($item["hidden"])) {
unset($menu[$key]);
continue 2;
}
if (!empty($item["label"])) {
$menu[$key][0] = sanitize_text_field($item["label"]);
}
}
}
}
/**
* Desired top-level slug order for the current user, from the saved config
* (items sorted by their 'order' field). Empty when no config applies.
*
* Consumed by MenuSerializer so Aurora's own sidebar reflects the order —
* WordPress's native `menu_order` filter only reorders the core admin menu,
* which Aurora hides, so it can't be used here.
*/
public static function ordered_slugs()
{
$items = self::current_user_items();
if (!$items) {
return [];
}
usort($items, function ($a, $b) {
$ao = isset($a["order"]) ? (int) $a["order"] : 0;
$bo = isset($b["order"]) ? (int) $b["order"] : 0;
return $ao <=> $bo;
});
$slugs = [];
foreach ($items as $item) {
$slug = isset($item["native_id"]) ? $item["native_id"] : "";
if ($slug !== "" && !in_array($slug, $slugs, true)) {
$slugs[] = $slug;
}
}
return $slugs;
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace AuroraAdmin\MenuCreator;
defined("ABSPATH") || exit();
/**
* REST CRUD for saved menu configurations. GET/POST /menus,
* GET/PUT/DELETE /menus/:id. All manage_options-gated.
*/
class MenuCreatorData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("manage_options");
};
register_rest_route("aurora-admin/v1", "/menus", [
[
"methods" => "GET",
"callback" => [self::class, "list_menus"],
"permission_callback" => $permission,
"args" => [
"status" => ["type" => "string", "required" => false],
],
],
[
"methods" => "POST",
"callback" => [self::class, "create"],
"permission_callback" => $permission,
"args" => [
"name" => ["type" => "string", "required" => true],
"status" => ["type" => "string", "required" => false],
"roles" => ["type" => "array", "required" => false],
"items" => ["type" => "array", "required" => false],
],
],
]);
register_rest_route("aurora-admin/v1", "/menus/native-items", [
"methods" => "GET",
"callback" => [self::class, "native_items"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/menus/(?P<id>\d+)", [
[
"methods" => "GET",
"callback" => [self::class, "get_one"],
"permission_callback" => $permission,
],
[
"methods" => "PUT",
"callback" => [self::class, "update"],
"permission_callback" => $permission,
"args" => [
"name" => ["type" => "string", "required" => false],
"status" => ["type" => "string", "required" => false],
"roles" => ["type" => "array", "required" => false],
"items" => ["type" => "array", "required" => false],
],
],
[
"methods" => "DELETE",
"callback" => [self::class, "delete"],
"permission_callback" => $permission,
],
]);
}
/**
* The real, current WordPress menu (same source MenuSerializer feeds to
* the sidebar) flattened so the menu editor has something concrete to
* toggle hide/rename on — top-level items plus their submenu items.
*/
public static function native_items()
{
$menu = \AuroraAdmin\Shell\MenuSerializer::serialize();
$flat = [];
// Top-level items only. These are what the Menu Creator can reorder,
// hide, and rename today; submenu editing isn't applied yet, so it isn't
// offered (showing controls that do nothing is worse than not showing
// them).
foreach ($menu as $item) {
if (!empty($item["separator"]) || empty($item["id"])) {
continue;
}
$flat[] = ["native_id" => $item["id"], "title" => $item["title"], "icon" => $item["icon"]];
}
return new \WP_REST_Response($flat, 200);
}
public static function list_menus($request)
{
global $wpdb;
$table = MenuCreator::table_name();
$status = $request->get_param("status");
if ($status) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name, not user input; $status is bound via the $wpdb->prepare() %s placeholder.
$rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE status = %s ORDER BY updated_at DESC", $status));
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name, not user input; no user-supplied values in this query.
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY updated_at DESC");
}
return new \WP_REST_Response(array_map([self::class, "format"], $rows), 200);
}
public static function get_one($request)
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM " . MenuCreator::table_name() . " WHERE id = %d", (int) $request->get_param("id")));
if (!$row) {
return new \WP_Error("not_found", "Menu not found", ["status" => 404]);
}
return new \WP_REST_Response(self::format($row), 200);
}
public static function create($request)
{
global $wpdb;
$now = current_time("mysql");
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom menu-configs table.
$wpdb->insert(MenuCreator::table_name(), [
"name" => sanitize_text_field($request->get_param("name")),
"status" => self::sanitize_status($request->get_param("status")),
"roles" => wp_json_encode(self::sanitize_roles($request->get_param("roles"))),
"items" => wp_json_encode(self::sanitize_items($request->get_param("items"))),
"created_at" => $now,
"updated_at" => $now,
]);
$get_request = new \WP_REST_Request("GET");
$get_request->set_url_params(["id" => $wpdb->insert_id]);
return self::get_one($get_request);
}
public static function update($request)
{
global $wpdb;
$id = (int) $request->get_param("id");
$fields = ["updated_at" => current_time("mysql")];
if ($request->get_param("name") !== null) {
$fields["name"] = sanitize_text_field($request->get_param("name"));
}
if ($request->get_param("status") !== null) {
$fields["status"] = self::sanitize_status($request->get_param("status"));
}
if ($request->get_param("roles") !== null) {
$fields["roles"] = wp_json_encode(self::sanitize_roles($request->get_param("roles")));
}
if ($request->get_param("items") !== null) {
$fields["items"] = wp_json_encode(self::sanitize_items($request->get_param("items")));
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom menu-configs table.
$wpdb->update(MenuCreator::table_name(), $fields, ["id" => $id]);
return self::get_one($request);
}
public static function delete($request)
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->delete() is WordPress's own escaping-safe API for writing to Aurora's custom menu-configs table.
$wpdb->delete(MenuCreator::table_name(), ["id" => (int) $request->get_param("id")]);
return new \WP_REST_Response(["deleted" => true], 200);
}
private static function format($row)
{
return [
"id" => (int) $row->id,
"name" => $row->name,
"status" => $row->status,
"roles" => json_decode($row->roles ?: "[]", true) ?: [],
"items" => json_decode($row->items ?: "[]", true) ?: [],
"createdAt" => $row->created_at,
"updatedAt" => $row->updated_at,
];
}
private static function sanitize_status($status)
{
return $status === "published" ? "published" : "draft";
}
private static function sanitize_roles($roles)
{
if (!is_array($roles)) {
return [];
}
$valid = array_keys(wp_roles()->roles);
return array_values(array_intersect(array_map("sanitize_key", $roles), $valid));
}
private static function sanitize_items($items)
{
if (!is_array($items)) {
return [];
}
$out = [];
foreach ($items as $item) {
if (!is_array($item) || empty($item["native_id"])) {
continue;
}
$out[] = [
"native_id" => sanitize_text_field($item["native_id"]),
"label" => isset($item["label"]) ? sanitize_text_field($item["label"]) : "",
"hidden" => !empty($item["hidden"]),
"order" => isset($item["order"]) ? (int) $item["order"] : 0,
];
}
return $out;
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
namespace AuroraAdmin\Notices;
use AuroraAdmin\Pages\DashboardTakeover;
defined("ABSPATH") || exit();
/**
* Custom admin notices: storage + the runtime hook that actually renders
* active notices matching the current user/page. REST CRUD lives in
* NoticesData.php.
*/
class Notices
{
const TABLE = "aurora_admin_notices";
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_notices_db_version";
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
add_action("admin_notices", [self::class, "render"]);
}
public static function table_name()
{
global $wpdb;
return $wpdb->prefix . self::TABLE;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(191) NOT NULL DEFAULT '',
message LONGTEXT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'info',
roles TEXT NULL,
pages TEXT NULL,
dismissible TINYINT(1) NOT NULL DEFAULT 1,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_active (active)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
/**
* Hooked to admin_notices, which fires everywhere EXCEPT it doesn't
* actually reach the user on the Dashboard: admin_notices normally
* prints inside #wpbody, and DashboardTakeover hides #wpbody entirely
* there to hide the *native* dashboard widgets, taking custom notices
* down with it as collateral damage. render_for_dashboard() (called
* directly by DashboardTakeover, inside its own visible mount point) is
* the fix for that one case — skip the normal path here so it isn't
* rendered (invisibly) twice.
*/
public static function render()
{
if (DashboardTakeover::is_dashboard()) {
return;
}
self::render_notices(false);
}
/**
* Called directly by DashboardTakeover::render_mount(), inside the
* visible dashboard frame, since the normal admin_notices hook never
* reaches the user there (see render()).
*/
public static function render_for_dashboard()
{
self::render_notices(true);
}
/**
* $keep_in_place matters because of a WP core behavior, not a bug of
* ours: wp-admin/js/common.js unconditionally relocates every
* `.notice`/`.updated`/`.error` element on the page to right after the
* page's <h1> ($('div.notice').not('.inline, .below-h2').insertAfter(...)),
* regardless of where it was originally placed in the DOM. On every
* other page that's harmless (it's already heading there anyway); on
* the Dashboard it silently moves our notice into the native #wpbody,
* which is hidden entirely by DashboardTakeover — invisible, not gone.
* `below-h2` is core's own documented opt-out class for exactly this
* "leave this notice exactly where I put it" case.
*/
private static function render_notices($keep_in_place)
{
global $wpdb;
$user = wp_get_current_user();
if (!$user || !$user->exists()) {
return;
}
$current_page = isset($GLOBALS["pagenow"]) ? $GLOBALS["pagenow"] : "";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query. Runs on every admin page load to decide which notices to show, so caching would need its own invalidation on every notice save — more complexity than this read is worth.
$rows = $wpdb->get_results("SELECT * FROM " . self::table_name() . " WHERE active = 1");
foreach ($rows as $row) {
$roles = json_decode($row->roles ?: "[]", true);
if (is_array($roles) && $roles && !array_intersect((array) $user->roles, $roles)) {
continue;
}
$pages = json_decode($row->pages ?: "[]", true);
if (is_array($pages) && $pages && !in_array($current_page, $pages, true)) {
continue;
}
$type = in_array($row->type, ["success", "warning", "error", "info"], true) ? $row->type : "info";
$classes = "notice notice-{$type} aurora-native-notice";
if ($keep_in_place) {
$classes .= " below-h2";
}
if ($row->dismissible) {
$classes .= " is-dismissible";
}
printf(
'<div class="%1$s"><p><strong>%2$s</strong>%3$s</p></div>',
esc_attr($classes),
esc_html($row->title),
$row->message ? ": " . esc_html($row->message) : ""
);
}
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace AuroraAdmin\Notices;
defined("ABSPATH") || exit();
/**
* REST CRUD for admin notices. GET/POST /notices, PUT/DELETE /notices/:id.
*/
class NoticesData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("manage_options");
};
register_rest_route("aurora-admin/v1", "/notices", [
[
"methods" => "GET",
"callback" => [self::class, "list_notices"],
"permission_callback" => $permission,
],
[
"methods" => "POST",
"callback" => [self::class, "create"],
"permission_callback" => $permission,
"args" => [
"title" => ["type" => "string", "required" => true],
],
],
]);
register_rest_route("aurora-admin/v1", "/notices/(?P<id>\d+)", [
[
"methods" => "PUT",
"callback" => [self::class, "update"],
"permission_callback" => $permission,
],
[
"methods" => "DELETE",
"callback" => [self::class, "delete"],
"permission_callback" => $permission,
],
]);
}
public static function list_notices()
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; no user-supplied values in this query.
$rows = $wpdb->get_results("SELECT * FROM " . Notices::table_name() . " ORDER BY updated_at DESC");
return new \WP_REST_Response(array_map([self::class, "format"], $rows), 200);
}
public static function create($request)
{
global $wpdb;
$now = current_time("mysql");
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->insert() is WordPress's own escaping-safe API for writing to Aurora's custom notices table.
$wpdb->insert(Notices::table_name(), [
"title" => sanitize_text_field($request->get_param("title")),
"message" => sanitize_textarea_field((string) $request->get_param("message")),
"type" => self::sanitize_type($request->get_param("type")),
"roles" => wp_json_encode(self::sanitize_list($request->get_param("roles"))),
"pages" => wp_json_encode(self::sanitize_list($request->get_param("pages"))),
"dismissible" => $request->get_param("dismissible") === false ? 0 : 1,
"active" => $request->get_param("active") === false ? 0 : 1,
"created_at" => $now,
"updated_at" => $now,
]);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM " . Notices::table_name() . " WHERE id = %d", $wpdb->insert_id));
return new \WP_REST_Response(self::format($row), 200);
}
public static function update($request)
{
global $wpdb;
$id = (int) $request->get_param("id");
$fields = ["updated_at" => current_time("mysql")];
foreach (["title" => "sanitize_text_field", "message" => "sanitize_textarea_field"] as $key => $fn) {
if ($request->get_param($key) !== null) {
$fields[$key] = call_user_func($fn, (string) $request->get_param($key));
}
}
if ($request->get_param("type") !== null) {
$fields["type"] = self::sanitize_type($request->get_param("type"));
}
if ($request->get_param("roles") !== null) {
$fields["roles"] = wp_json_encode(self::sanitize_list($request->get_param("roles")));
}
if ($request->get_param("pages") !== null) {
$fields["pages"] = wp_json_encode(self::sanitize_list($request->get_param("pages")));
}
if ($request->get_param("dismissible") !== null) {
$fields["dismissible"] = $request->get_param("dismissible") ? 1 : 0;
}
if ($request->get_param("active") !== null) {
$fields["active"] = $request->get_param("active") ? 1 : 0;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->update() is WordPress's own escaping-safe API for writing to Aurora's custom notices table.
$wpdb->update(Notices::table_name(), $fields, ["id" => $id]);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table name from an internal, hardcoded {$wpdb->prefix}-based constant, not user input; the %d value is passed through $wpdb->prepare() below.
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM " . Notices::table_name() . " WHERE id = %d", $id));
return new \WP_REST_Response(self::format($row), 200);
}
public static function delete($request)
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $wpdb->delete() is WordPress's own escaping-safe API for writing to Aurora's custom notices table.
$wpdb->delete(Notices::table_name(), ["id" => (int) $request->get_param("id")]);
return new \WP_REST_Response(["deleted" => true], 200);
}
private static function format($row)
{
return [
"id" => (int) $row->id,
"title" => $row->title,
"message" => $row->message,
"type" => $row->type,
"roles" => json_decode($row->roles ?: "[]", true) ?: [],
"pages" => json_decode($row->pages ?: "[]", true) ?: [],
"dismissible" => (bool) $row->dismissible,
"active" => (bool) $row->active,
"createdAt" => $row->created_at,
"updatedAt" => $row->updated_at,
];
}
private static function sanitize_type($type)
{
return in_array($type, ["success", "warning", "error", "info"], true) ? $type : "info";
}
private static function sanitize_list($list)
{
if (!is_array($list)) {
return [];
}
return array_values(array_map("sanitize_text_field", $list));
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace AuroraAdmin\Options;
defined("ABSPATH") || exit();
/**
* Single source of truth for the aurora_admin_settings option.
* Deliberately simple — no request-lifetime caching layer, no schema
* ceremony beyond what register_setting requires. Add fields as pages
* actually need them.
*/
class Settings
{
const OPTION_NAME = "aurora_admin_settings";
public function __construct()
{
add_action("init", [self::class, "register"]);
add_action("init", [self::class, "remove_retired_keys"]);
}
// One-time cleanup for settings keys that no longer exist in the UI:
// the removed Remote Site Switcher feature (remote_sites stored a real
// secret — application passwords — that nothing ever consumed) and the
// removed licensing system (license_key, dev_license_override). Actively
// deletes them from the database rather than just leaving them inertly
// stored.
public static function remove_retired_keys()
{
$settings = self::get();
$retired = ["remote_sites", "remote_site_switcher_capability", "license_key", "dev_license_override"];
$present = array_intersect($retired, array_keys($settings));
if (empty($present)) {
return;
}
foreach ($present as $key) {
unset($settings[$key]);
}
update_option(self::OPTION_NAME, $settings);
}
public static function register()
{
register_setting("aurora-admin", self::OPTION_NAME, [
"type" => "object",
"default" => [],
"capability" => "manage_options",
// Merge incoming payloads into the stored value instead of replacing
// it wholesale. This lets pages save just the keys they own (e.g. the
// dashboard sends only dashboard_card_order, the shell only dark_mode)
// without wiping settings they never received — which matters because
// most pages get a sanitized copy (see get_safe()) and must never
// write that stripped copy back.
"sanitize_callback" => [self::class, "merge_on_save"],
"show_in_rest" => [
"schema" => [
"type" => "object",
"properties" => [
// Two producers write this key with different types: the
// toolbar's tri-state switcher sends 'light'/'dark'/'system'
// (string), the settings page's Dark Mode toggle sends a plain
// boolean. Leaving it untyped (falls through to
// additionalProperties) lets both save instead of one
// rejecting the other with a 400.
"dashboard_card_order" => ["type" => "object"],
],
"additionalProperties" => true,
],
],
]);
}
public static function merge_on_save($value)
{
$existing = get_option(self::OPTION_NAME, []);
if (!is_array($existing)) {
$existing = [];
}
if (!is_array($value)) {
return $existing;
}
return array_merge($existing, $value);
}
public static function get($key = null, $default = null)
{
$settings = get_option(self::OPTION_NAME, []);
if (!is_array($settings)) {
$settings = [];
}
if ($key === null) {
return $settings;
}
return isset($settings[$key]) ? $settings[$key] : $default;
}
/**
* Settings safe to expose on every admin page (the shell serializes this
* for all logged-in users). Strips backup_s3_credentials/
* backup_gdrive_credentials (contain a secret access key / OAuth client
* secret + refresh token) — the exact "future field that shouldn't be
* broadcast" case this method was originally kept separate from get()
* to handle.
*/
public static function get_safe()
{
$settings = self::get();
unset($settings["backup_s3_credentials"], $settings["backup_gdrive_credentials"]);
return $settings;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the Appearance-area screens for the "full admin takeover"
* initiative: Nav Menus, Themes, Widgets (all real hijacked replacements).
*
* Theme Editor is deliberately NOT hijacked or replaced — arbitrary
* in-browser PHP/CSS file editing is exactly the category of functionality
* WordPress.org's Plugin Directory policy excludes (see the removed
* File Manager sub-app, now distributed as a separate standalone plugin
* instead of being bundled here). Visits to theme-editor.php fall through
* to WordPress core's own Theme Editor unmodified, which already respects
* DISALLOW_FILE_EDIT itself.
*/
class AppearancePages
{
public function __construct()
{
ScreenHijack::register(
"load-nav-menus.php",
"edit_theme_options",
"full_admin_takeover",
"nav-menus",
"aurora-admin-nav-menus-root"
);
ScreenHijack::register(
"load-themes.php",
"switch_themes",
"full_admin_takeover",
"themes",
"aurora-admin-themes-root",
null,
null,
function () {
return [
"customizeUrl" => esc_url_raw(admin_url("customize.php")),
// Native theme-install.php covers both "browse WordPress.org" and
// "Upload Theme" (a button at the top of that same screen) — not
// rebuilt here, same reuse-native-flow-for-add pattern as Plugins'
// addUrl (plugin-install.php) and Media's newMediaUrl
// (media-new.php).
"addThemeUrl" => current_user_can("install_themes") ? esc_url_raw(admin_url("theme-install.php")) : "",
];
}
);
ScreenHijack::register(
"load-widgets.php",
"edit_theme_options",
"full_admin_takeover",
"widgets",
"aurora-admin-widgets-root"
);
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
class Dashboard
{
private static $hook_suffix = null;
public function __construct()
{
add_action("admin_menu", [self::class, "register_menu"]);
// Runs late (after submenus are registered) to drop the duplicate
// first submenu item WordPress auto-creates for a top-level menu.
add_action("admin_menu", [self::class, "remove_duplicate_submenu"], 100);
add_action("admin_enqueue_scripts", [self::class, "maybe_enqueue"]);
}
public static function register_menu()
{
self::$hook_suffix = add_menu_page(
__("Aurora Admin", "aurora-admin"),
__("Aurora Admin", "aurora-admin"),
"read",
"aurora-admin",
[self::class, "render"],
"dashicons-admin-generic",
3
);
}
/**
* WordPress auto-adds a submenu item duplicating the top-level menu
* (same "Aurora Admin" label + slug). Remove it so the submenu only
* shows the real child pages (e.g. Settings).
*/
public static function remove_duplicate_submenu()
{
remove_submenu_page("aurora-admin", "aurora-admin");
}
public static function maybe_enqueue($hook_suffix)
{
if ($hook_suffix !== self::$hook_suffix) {
return;
}
Assets::enqueue("aurora-admin-dashboard", "entries/dashboard.js");
}
public static function render()
{
$current_user = wp_get_current_user();
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"userName" => $current_user->display_name,
"settings" => Settings::get(),
];
?>
<div
id="aurora-admin-dashboard-root"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
use AuroraAdmin\Notices\Notices;
defined("ABSPATH") || exit();
/**
* Replaces the native WordPress dashboard (index.php) with Aurora's own
* dashboard, the way the original plugin did.
*
* Rather than removing/moving WordPress's dashboard DOM, we render the
* Aurora mount point at the very top of the content area (the
* `in_admin_header` hook fires there, before the native .wrap) and simply
* hide the native dashboard's .wrap with CSS. Clean and reversible — if
* this plugin is disabled, the native dashboard returns untouched.
*/
class DashboardTakeover
{
public function __construct()
{
add_action("admin_enqueue_scripts", [self::class, "maybe_enqueue"]);
add_action("admin_head", [self::class, "hide_native"]);
add_action("in_admin_header", [self::class, "render_mount"]);
}
public static function is_dashboard()
{
// Settings > Dashboard > "Custom dashboard" — previously always on
// regardless of this setting; default true so existing installs keep
// their current behavior until explicitly turned off.
if (!Settings::get("use_custom_dashboard", true)) {
return false;
}
return isset($GLOBALS["pagenow"]) && $GLOBALS["pagenow"] === "index.php";
}
public static function maybe_enqueue()
{
if (!self::is_dashboard()) {
return;
}
Assets::enqueue("aurora-admin-dashboard", "entries/dashboard.js");
}
public static function hide_native()
{
if (!self::is_dashboard()) {
return;
}
?>
<style id="aurora-admin-dashboard-takeover">
/* The Aurora dashboard is rendered in #wpcontent (above #wpbody) via
in_admin_header, so the entire native #wpbody — heading, welcome
panel, widgets, and the Screen Options / Help tabs inside it — is
hidden. This leaves just the Aurora dashboard with no empty native
frame below it. Printed only on the dashboard screen. */
html.aurora-active #wpbody {
display: none !important;
}
</style>
<?php
}
public static function render_mount()
{
if (!self::is_dashboard()) {
return;
}
$current_user = wp_get_current_user();
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"userName" => $current_user->display_name,
"settings" => Settings::get_safe(),
];
?>
<div id="aurora-admin-dashboard-notices">
<?php Notices::render_for_dashboard(); ?>
</div>
<div
id="aurora-admin-dashboard-root"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the Export screen (export.php) for the "full admin takeover"
* initiative — a real Aurora-built form, not a reframed native page.
*
* Safe to fully rebuild (unlike Tools/Site Health): export.php's actual file
* generation (the `?download=true` request) calls export_wp() and die()s
* before ever reaching in_admin_header/admin_footer — the two hooks
* ScreenHijack's buffering relies on — so that request is never touched by
* the hijack at all and runs completely native, unmodified. Only the HTML
* form (the plain GET view with no `download` param) is replaced; Aurora's
* form submits via a real browser navigation to the same
* export.php?download=true&... URL, so the download itself is byte-for-byte
* what core would have produced.
*/
class ExportPage
{
public function __construct()
{
ScreenHijack::register(
"load-export.php",
"export", // Matches core's own capability for this screen.
"full_admin_takeover",
"export",
"aurora-admin-export-root",
null,
null,
function () {
return [
"exportUrl" => esc_url_raw(admin_url("export.php")),
];
}
);
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers every native screen Aurora replaces in place (same URL, no
* redirect) via ScreenHijack. Kept as its own bootstrap class rather than
* inline calls in AuroraAdmin's constructor since this list is expected to
* grow past a dozen entries as more native screens are covered.
*
* Each of these previously lived in SubApps::PAGES as a registered
* "aurora-admin-*" submenu with a matching redirect_native_pages() branch.
* Plugins is the one screen that still uses that mechanism — plugins.php
* prints output from within its own load hook in a way that can't be
* cleanly buffered, confirmed by the old reference plugin's own abandoned
* attempt at hijacking it — so it's deliberately not listed here.
*/
class HijackedNativePages
{
public function __construct()
{
ScreenHijack::register(
"load-edit-comments.php",
"moderate_comments",
"modern_comments",
"comments",
"aurora-admin-comments-root"
);
// "edit.php" is shared by Posts (the default post type) and every other
// post type registered on the site, including Pages — screen_match is
// what tells them apart; other custom post types fall through untouched.
ScreenHijack::register(
"load-edit.php",
"edit_posts",
"modern_posts",
"posts",
"aurora-admin-posts-root",
function ($screen) {
return $screen->post_type === "post";
},
null,
function () {
return ["newPostUrl" => esc_url_raw(admin_url("post-new.php"))];
}
);
ScreenHijack::register(
"load-edit.php",
"edit_pages",
"modern_pages",
"pages",
"aurora-admin-pages-root",
function ($screen) {
return $screen->post_type === "page";
},
null,
function () {
return ["newPageUrl" => esc_url_raw(admin_url("post-new.php?post_type=page"))];
}
);
ScreenHijack::register(
"load-upload.php",
"upload_files",
"modern_media",
"media",
"aurora-admin-media-root",
null,
null,
function () {
return [
"newMediaUrl" => esc_url_raw(admin_url("media-new.php")),
];
}
);
ScreenHijack::register(
"load-users.php",
"list_users",
"modern_users",
"users",
"aurora-admin-users-root",
null,
null,
function () {
return ["newUserUrl" => esc_url_raw(admin_url("user-new.php"))];
}
);
// media-new.php / user-new.php: Media Upload and Add New User. Free,
// gated by the same modern_media / modern_users toggles as their
// already-free list-view companions (Media Library / Users) — no
// reason to paywall mid-workflow between "browse" and "add".
ScreenHijack::register(
"load-media-new.php",
"upload_files",
"modern_media",
"media-upload",
"aurora-admin-media-upload-root",
null,
null,
function () {
return ["libraryUrl" => esc_url_raw(admin_url("upload.php"))];
}
);
ScreenHijack::register(
"load-user-new.php",
"create_users",
"modern_users",
"add-user",
"aurora-admin-add-user-root",
null,
null,
function () {
$roles = [];
foreach (wp_roles()->roles as $slug => $info) {
$roles[] = ["value" => $slug, "label" => translate_user_role($info["name"])];
}
return [
"usersUrl" => esc_url_raw(admin_url("users.php")),
"roles" => $roles,
"defaultRole" => get_option("default_role", "subscriber"),
];
}
);
// profile.php: reskins only WordPress's own fields (name, contact
// info, bio, password). If any plugin has hooked show_user_profile or
// personal_options — the two documented extension points for adding
// fields to this exact screen (2FA setup, WooCommerce customer fields,
// etc.) — this deliberately does NOT hijack, falling through to the
// real native screen instead, since a Vue reskin has no way to host
// another plugin's raw HTML/JS output safely. Checked at hijack time
// (load-profile.php), which fires after every plugin's own init/
// admin_init hooks have already registered their callbacks.
ScreenHijack::register(
"load-profile.php",
"read",
"modern_users",
"profile",
"aurora-admin-profile-root",
function () {
return !has_action("show_user_profile") && !has_action("personal_options");
}
);
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the Import screen (import.php) for the "full admin takeover"
* initiative — a real Aurora-built listing, not a reframed native page.
*
* Only the LISTING page is rebuilt. Running an already-active importer
* (native "Run Importer" link) still goes to admin.php?import={id} — a
* screen registered and entirely rendered by whatever third-party importer
* plugin owns that id (register_importer()'s own callback). That page is
* arbitrary, unpredictable third-party UI outside this hijack's scope
* (a different load-{hook}), and stays native, same as e.g. the block
* editor stays native beyond its frame.
*
* Install/activate reuses existing, already-proven mechanisms instead of
* new logic: WordPress core's own admin-ajax "install-plugin" action (same
* one native's "Install Now" button triggers) for installing, and Aurora's
* own aurora-admin/v1/plugins/activate route (see PluginsData.php) for
* activation.
*/
class ImportPage
{
public function __construct()
{
ScreenHijack::register(
"load-import.php",
"import", // Matches core's own capability for this screen.
"full_admin_takeover",
"import",
"aurora-admin-import-root",
null,
null,
function () {
return self::build_data();
}
);
}
private static function build_data()
{
if (!function_exists("get_importers")) {
require_once ABSPATH . "wp-admin/includes/import.php";
}
// Real, already-active importers (registered via register_importer() by
// an active importer plugin) — global $wp_importers. get_importers()
// returns that global as-is, which is null (not an empty array) until
// something actually calls register_importer() — true whenever no
// importer plugin is active, e.g. a fresh install. Native import.php
// never hits this directly: it only ever reads $importers via isset(),
// and PHP auto-vivifies null into an array the moment popular_importers
// gets merged in via `$importers[$id] = [...]`, so by the time IT
// foreach()es, an assignment has always already happened. This code
// foreach()es the raw result directly, so it needs its own guard.
$active = get_importers();
if (!is_array($active)) {
$active = [];
}
$can_install = current_user_can("install_plugins");
$popular = $can_install ? wp_get_popular_importers() : [];
$merged = [];
foreach ($active as $id => $data) {
$merged[] = [
"id" => $id,
"name" => $data[0],
"description" => $data[1],
"status" => "active",
"runUrl" => esc_url_raw(self_admin_url("admin.php?import=" . $id)),
];
}
// Same de-dup + dummy-registration logic as native import.php: a
// popular importer already covered by an active one is skipped.
foreach ($popular as $pop_key => $pop) {
$importer_id = isset($pop["importer-id"]) ? $pop["importer-id"] : $pop_key;
if (isset($active[$pop_key]) || isset($active[$importer_id])) {
continue;
}
$plugin_slug = isset($pop["plugin-slug"]) ? $pop["plugin-slug"] : "";
$entry = [
"id" => $importer_id,
"name" => $pop["name"],
"description" => $pop["description"],
"slug" => $plugin_slug,
// Deterministic regardless of current status — provided for every
// entry so the front end never has to construct an admin URL
// itself (this respects any custom/network admin path).
"runUrl" => esc_url_raw(self_admin_url("admin.php?import=" . $importer_id)),
];
if ($plugin_slug !== "" && file_exists(WP_PLUGIN_DIR . "/" . $plugin_slug)) {
if (!function_exists("get_plugins")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}
$plugins = get_plugins("/" . $plugin_slug);
if ($plugins) {
$keys = array_keys($plugins);
$entry["status"] = "installed_inactive";
$entry["pluginFile"] = $plugin_slug . "/" . $keys[0];
} else {
$entry["status"] = "not_installed";
}
} else {
$entry["status"] = "not_installed";
}
$merged[] = $entry;
}
usort($merged, function ($a, $b) {
return strcasecmp($a["name"], $b["name"]);
});
// Any third-party output hooked to import_filters (a genuine, if rarely
// used, extension point) — same capture-and-relay approach as Tools'
// tool_box. Empty on a site with nothing hooked to it.
ob_start();
do_action("import_filters");
$native_filters_html = trim((string) ob_get_clean());
return [
"importers" => $merged,
"canInstallPlugins" => $can_install,
"installNonce" => wp_create_nonce("updates"),
"ajaxUrl" => esc_url_raw(admin_url("admin-ajax.php")),
"pluginSearchUrl" => esc_url_raw(network_admin_url("plugin-install.php?tab=search&type=tag&s=importer")),
"nativeFiltersHtml" => $native_filters_html,
];
}
}
+327
View File
@@ -0,0 +1,327 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the 5 native Settings screens (General/Writing/Reading/
* Discussion/Permalinks) via ScreenHijack, part of the "full admin
* takeover" initiative (see PROJECT_STATUS.md). Unlike Posts/Pages/Media/
* Users/Comments, these are gated on the master `full_admin_takeover`
* setting (default off), since they're new ground rather than a migration
* of something already shipped.
*
* All 5 share one Vue entry (native-settings.js / NativeSettings.vue) and
* one mount id — only one native Settings screen is ever hijacked per
* request, so there's no collision. Each just supplies a different field
* schema + current values via its data_callback. Every field except
* Permalinks' structure is one of core's own REST-exposed settings
* (`/wp/v2/settings`), so the Vue side reads/writes that route directly;
* Permalinks has no core REST route for rewrite rules and uses
* PermalinksData's own aurora-admin/v1/permalinks route instead.
*/
class NativeSettingsPages
{
// Core's REST settings route exposes some fields under a different name
// than the wp_options row they actually live in (registered via
// register_setting()'s show_in_rest 'name' argument) — reading them back
// with get_option() needs the real option name, not the REST one, or the
// field just reads back empty.
const OPTION_NAME_MAP = [
"title" => "blogname",
"description" => "blogdescription",
"url" => "siteurl",
];
public function __construct()
{
self::register_screen(
"load-options-general.php",
"general",
"General",
"Site title, tagline, timezone, and date/time formats.",
[
["key" => "title", "type" => "text", "label" => "Site Title"],
["key" => "description", "type" => "text", "label" => "Tagline"],
["key" => "url", "type" => "text", "label" => "Site Address (URL)", "readonly" => true],
["key" => "admin_email", "type" => "text", "label" => "Administration Email Address"],
["key" => "timezone_string", "type" => "text", "label" => "Timezone", "placeholder" => "e.g. America/New_York"],
["key" => "date_format", "type" => "text", "label" => "Date Format"],
["key" => "time_format", "type" => "text", "label" => "Time Format"],
[
"key" => "start_of_week",
"type" => "select",
"label" => "Week Starts On",
"options" => [
["value" => "0", "label" => "Sunday"],
["value" => "1", "label" => "Monday"],
["value" => "2", "label" => "Tuesday"],
["value" => "3", "label" => "Wednesday"],
["value" => "4", "label" => "Thursday"],
["value" => "5", "label" => "Friday"],
["value" => "6", "label" => "Saturday"],
],
],
]
);
self::register_screen(
"load-options-writing.php",
"writing",
"Writing",
"Defaults used when publishing new content.",
[
// use_smilies is registered in core's REST schema (register_initial_settings())
// but was never added here — a real gap, not a deliberate omission.
["key" => "use_smilies", "type" => "toggle", "label" => "Convert emoticons like :-) and :-P to graphics on display"],
["key" => "default_category", "type" => "select", "label" => "Default Post Category", "options" => self::category_options()],
["key" => "default_post_format", "type" => "select", "label" => "Default Post Format", "options" => self::post_format_options()],
]
);
self::register_screen(
"load-options-reading.php",
"reading",
"Reading",
"What visitors see on the front page and how many posts are listed.",
[
[
"key" => "show_on_front",
"type" => "select",
"label" => "Homepage Displays",
"options" => [
["value" => "posts", "label" => "Your latest posts"],
["value" => "page", "label" => "A static page"],
],
],
[
"key" => "page_on_front",
"type" => "select",
"label" => "Homepage",
"options" => self::page_options(),
"showIf" => ["key" => "show_on_front", "equals" => "page"],
],
[
"key" => "page_for_posts",
"type" => "select",
"label" => "Posts Page",
"options" => self::page_options(),
"showIf" => ["key" => "show_on_front", "equals" => "page"],
],
["key" => "posts_per_page", "type" => "text", "label" => "Posts Per Page"],
]
);
self::register_screen(
"load-options-discussion.php",
"discussion",
"Discussion",
"Default article settings for pings and comments.",
[
// Saved via DiscussionData's own /discussion-settings route, not
// wp/v2/settings — core's REST schema only exposes the first two
// of these fields (see DiscussionData.php's docblock for why).
["key" => "default_pingback_flag", "type" => "toggle", "label" => "Attempt to notify any blogs linked to from the article"],
[
"key" => "default_ping_status",
"type" => "select",
"label" => "Allow link notifications from other blogs (pingbacks and trackbacks)",
"options" => [
["value" => "open", "label" => "Open"],
["value" => "closed", "label" => "Closed"],
],
],
[
"key" => "default_comment_status",
"type" => "select",
"label" => "Allow people to submit comments on new posts",
"options" => [
["value" => "open", "label" => "Open"],
["value" => "closed", "label" => "Closed"],
],
],
["key" => "require_name_email", "type" => "toggle", "label" => "Comment author must fill out name and email"],
["key" => "comment_registration", "type" => "toggle", "label" => "Users must be registered and logged in to comment"],
["key" => "close_comments_for_old_posts", "type" => "toggle", "label" => "Automatically close comments on old posts"],
[
"key" => "close_comments_days_old",
"type" => "text",
"label" => "Close comments when post is how many days old",
"showIf" => ["key" => "close_comments_for_old_posts", "equals" => true],
],
["key" => "show_comments_cookies_opt_in", "type" => "toggle", "label" => "Show comments cookies opt-in checkbox"],
["key" => "thread_comments", "type" => "toggle", "label" => "Enable threaded (nested) comments"],
[
"key" => "thread_comments_depth",
"type" => "select",
"label" => "Number of levels for threaded (nested) comments",
"options" => array_map(
function ($n) {
return ["value" => (string) $n, "label" => (string) $n];
},
range(2, 10)
),
"showIf" => ["key" => "thread_comments", "equals" => true],
],
["key" => "page_comments", "type" => "toggle", "label" => "Break comments into pages"],
[
"key" => "comments_per_page",
"type" => "text",
"label" => "Top level comments per page",
"showIf" => ["key" => "page_comments", "equals" => true],
],
[
"key" => "default_comments_page",
"type" => "select",
"label" => "Comments page to display by default",
"options" => [
["value" => "newest", "label" => "Last page"],
["value" => "oldest", "label" => "First page"],
],
"showIf" => ["key" => "page_comments", "equals" => true],
],
[
"key" => "comment_order",
"type" => "select",
"label" => "Comments to display at the top of each page",
"options" => [
["value" => "asc", "label" => "Older"],
["value" => "desc", "label" => "Newer"],
],
],
["key" => "comments_notify", "type" => "toggle", "label" => "Email me whenever: anyone posts a comment"],
["key" => "moderation_notify", "type" => "toggle", "label" => "Email me whenever: a comment is held for moderation"],
["key" => "wp_notes_notify", "type" => "toggle", "label" => "Email me whenever: WordPress news and updates", "default" => true],
["key" => "comment_moderation", "type" => "toggle", "label" => "Comment must be manually approved"],
["key" => "comment_previously_approved", "type" => "toggle", "label" => "Comment author must have a previously approved comment"],
["key" => "comment_max_links", "type" => "text", "label" => "Hold a comment in the queue if it contains this many links"],
[
"key" => "moderation_keys",
"type" => "textarea",
"label" => "Comment Moderation",
"description" => "When a comment contains any of these words in its content, author name, URL, email, IP address, or browser's user agent string, it will be held in the moderation queue. One word or IP address per line.",
],
[
"key" => "disallowed_keys",
"type" => "textarea",
"label" => "Disallowed Comment Keys",
"description" => "When a comment contains any of these words in its content, author name, URL, email, IP address, or browser's user agent string, it will be put in the Trash. One word or IP address per line.",
],
["key" => "show_avatars", "type" => "toggle", "label" => "Show Avatars"],
[
"key" => "avatar_rating",
"type" => "select",
"label" => "Maximum Rating",
"options" => [
["value" => "G", "label" => "G — Suitable for all audiences"],
["value" => "PG", "label" => "PG — Possibly offensive, usually for audiences 13 and above"],
["value" => "R", "label" => "R — Intended for adult audiences above 17"],
["value" => "X", "label" => "X — Even more mature than above"],
],
"showIf" => ["key" => "show_avatars", "equals" => true],
],
[
"key" => "avatar_default",
"type" => "select",
"label" => "Default Avatar",
"options" => [
["value" => "mystery", "label" => "Mystery Person"],
["value" => "blank", "label" => "Blank"],
["value" => "gravatar_default", "label" => "Gravatar Logo"],
["value" => "identicon", "label" => "Identicon (Generated)"],
["value" => "wavatar", "label" => "Wavatar (Generated)"],
["value" => "monsterid", "label" => "MonsterID (Generated)"],
["value" => "retro", "label" => "Retro (Generated)"],
["value" => "robohash", "label" => "RoboHash (Generated)"],
["value" => "initials", "label" => "Initials (Generated)"],
["value" => "color", "label" => "Color (Generated)"],
],
"default" => "mystery",
"showIf" => ["key" => "show_avatars", "equals" => true],
],
]
);
self::register_screen(
"load-options-permalink.php",
"permalinks",
"Permalinks",
"The URL structure used for posts and pages.",
[["key" => "permalink_structure", "type" => "text", "label" => "Custom Structure"]]
);
}
private static function category_options()
{
$categories = get_categories(["hide_empty" => false]);
return array_map(
function ($cat) {
return ["value" => (string) $cat->term_id, "label" => $cat->name];
},
$categories
);
}
private static function post_format_options()
{
// get_post_format_strings() doesn't include "Standard" (the '0'/default
// value native WP stores when no format is set) — added manually to
// match the native Writing screen's own dropdown exactly.
$options = [["value" => "0", "label" => "Standard"]];
foreach (get_post_format_strings() as $slug => $label) {
if ($slug === "standard") {
continue;
}
$options[] = ["value" => $slug, "label" => $label];
}
return $options;
}
private static function page_options()
{
$options = [["value" => "0", "label" => "— Select —"]];
$pages = get_pages();
foreach ($pages as $page) {
$options[] = ["value" => (string) $page->ID, "label" => $page->post_title ?: "(no title)"];
}
return $options;
}
private static function register_screen($hook_suffix, $screen, $title, $subtitle, $fields)
{
ScreenHijack::register(
$hook_suffix,
"manage_options",
"full_admin_takeover",
"native-settings",
"aurora-admin-native-settings-root",
null,
null,
function () use ($screen, $title, $subtitle, $fields) {
$values = [];
foreach ($fields as $field) {
$option_name = self::OPTION_NAME_MAP[$field["key"]] ?? $field["key"];
$value = get_option($option_name, $field["default"] ?? "");
// Toggle fields need a real boolean — get_option() returns
// whatever's actually stored ("1"/""/true/false depending on how
// it was last saved), and a falsy non-bool like "" would
// otherwise still satisfy Vue's `!!val` check inconsistently.
$values[$field["key"]] = $field["type"] === "toggle" ? (bool) $value : $value;
}
return [
"screen" => $screen,
"title" => $title,
"subtitle" => $subtitle,
"fields" => $fields,
"values" => $values,
];
},
"admin-takeover"
);
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Replaces a native WordPress admin screen with an Aurora Vue page without
* changing its URL, instead of SubApps' registered-page + redirect approach.
*
* WordPress fires "load-{hook_suffix}" once a screen is confirmed but before
* it prints anything, so the recipe is: check access, buffer everything the
* native screen would have printed, throw the buffer away once WordPress
* reaches admin_footer, and print Aurora's own mount div in its place. The
* URL the browser sees never changes, so there's no separate slug that can
* fall out of sync with a plugin's own redirect, a bookmark, or any other
* direct visit — the exact bug class SubApps::redirect_native_pages() has to
* work around case by case (see its Plugins comment).
*
* Not every native screen can be hijacked this way — some (plugins.php is
* the confirmed case so far) print output from within the load-{page}.php
* callback itself or otherwise short-circuit before in_admin_header fires.
* Those stay on SubApps' redirect mechanism instead.
*/
class ScreenHijack
{
// Set true the moment any registered hijack actually activates for the
// current request (all gating checks passed). Shell::is_own_page() reads
// this — hijacked screens keep their native URL (no ?page=aurora-admin),
// so the shell can't otherwise tell them apart from a real native page
// when deciding whether to load native-dark.css's own-page exclusion.
private static $hijack_active = false;
public static function is_hijack_active()
{
return self::$hijack_active;
}
/**
* @param string $hook_suffix "load-{page}.php", e.g. "load-edit.php"
* @param string $capability Required capability, checked with current_user_can()
* @param string|null $setting_key Settings::get() key gating this hijack
* (default true), or null to always hijack once capability passes
* @param string $entry Vite entry name (without "src/entries/" or ".js")
* @param string $root_id DOM id for the mount div
* @param callable|null $screen_match fn(WP_Screen $screen): bool — extra
* match beyond the hook itself firing, e.g. distinguishing edit.php's
* several post types
* @param callable|null $short_circuit fn(WP_Query $query): void, hooked to
* pre_get_posts only if provided — for list screens where letting
* WordPress's own query run would be wasted work
* @param callable|null $data_callback fn(): array — extra data merged into
* the mount div's data-aurora-admin JSON, beyond the restUrl/restNonce
* defaults every page gets
*/
public static function register(
$hook_suffix,
$capability,
$setting_key,
$entry,
$root_id,
$screen_match = null,
$short_circuit = null,
$data_callback = null
) {
add_action(
$hook_suffix,
function () use (
$capability,
$setting_key,
$entry,
$root_id,
$screen_match,
$short_circuit,
$data_callback
) {
if (!current_user_can($capability)) {
return;
}
if ($setting_key !== null && !Settings::get($setting_key, true)) {
return;
}
if ($screen_match !== null) {
$screen = get_current_screen();
if (!$screen || !$screen_match($screen)) {
return;
}
}
self::$hijack_active = true;
if ($short_circuit !== null) {
add_filter("pre_get_posts", $short_circuit);
}
add_action(
"in_admin_header",
function () {
ob_start();
},
999
);
add_action(
"admin_footer",
function () use ($entry, $root_id, $data_callback) {
// A screen that exited early (its own wp_die(), a mid-render
// redirect, a validation failure taking a different code path
// than the initial GET) may never have reached in_admin_header,
// or may have already closed its own buffer — calling
// ob_end_clean() unconditionally in that case throws a notice
// and can corrupt the response. Only discard a buffer that's
// actually open.
if (ob_get_level() > 0) {
ob_end_clean();
}
$data = array_merge(
[
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
],
$data_callback !== null ? $data_callback() : []
);
// The buffer just discarded didn't just hold the native
// screen's content — in_admin_header fires before WP prints
// <div id="wpbody"><div id="wpbody-content">, and those
// wrapper divs' closing tags print (in admin-footer.php)
// before this admin_footer callback runs, so both got
// captured and thrown away too. Re-print them around the
// mount div so the DOM WP itself would have produced still
// exists — windowChrome.js's traffic-light bar and the shell's
// frame CSS both key off #wpbody-content being there.
?>
<div id="wpbody" role="main">
<div id="wpbody-content">
<div
id="<?php echo esc_attr($root_id); ?>"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
</div>
</div>
<?php
},
0
);
add_action("admin_enqueue_scripts", function () use ($entry) {
Assets::enqueue("aurora-admin-" . $entry, "entries/" . $entry . ".js");
});
},
5
);
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
class SettingsPage
{
private static $hook_suffix = null;
public function __construct()
{
add_action("admin_menu", [self::class, "register_menu"]);
add_action("admin_enqueue_scripts", [self::class, "maybe_enqueue"]);
}
public static function register_menu()
{
// Top-level "Aurora Admin" menu opens the Settings page directly. The
// Aurora dashboard is reached through WordPress's own Dashboard menu
// (taken over on index.php), so this menu has no submenu of its own.
self::$hook_suffix = add_menu_page(
__("Aurora Admin", "aurora-admin"),
__("Aurora Admin", "aurora-admin"),
"manage_options",
"aurora-admin",
[self::class, "render"],
"dashicons-admin-generic",
3
);
}
public static function maybe_enqueue($hook_suffix)
{
if ($hook_suffix !== self::$hook_suffix) {
return;
}
// Without this, window.wp.media is undefined client-side and every
// image field (Logo, Dark mode logo, Login logo, Admin favicon)
// silently falls back to a plain URL prompt instead of the real media
// library picker (see SettingsField.vue's pickImage()).
wp_enqueue_media();
Assets::enqueue("aurora-admin-settings", "entries/settings.js");
}
public static function render()
{
// Option lists for multiselect fields (roles for "Disable theme",
// public post types for "Search post types").
$roles = [];
foreach (wp_roles()->roles as $slug => $info) {
$roles[] = ["value" => $slug, "label" => translate_user_role($info["name"])];
}
$post_types = [];
foreach (get_post_types(["public" => true], "objects") as $pt) {
if ($pt->name === "attachment") {
continue;
}
$post_types[] = ["value" => $pt->name, "label" => $pt->labels->name];
}
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"userName" => wp_get_current_user()->display_name,
// Full settings (including secrets) — this page is manage_options-only
// and needs to display/edit them.
"settings" => Settings::get(),
"roles" => $roles,
"postTypes" => $post_types,
];
?>
<div
id="aurora-admin-settings-root"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* First-run onboarding wizard.
*
* A full-screen, self-contained page (no Aurora sidebar/toolbar shell, no
* native WP chrome) walking a new user through appearance, branding, and
* which replacement screens to enable. Registered as a hidden submenu of the
* "aurora-admin" menu — MenuSerializer skips that slug, so it never shows in
* the Aurora sidebar, and the native menu it does technically live under is
* hidden by the shell on every other page anyway.
*
* First run: on activation an option flags a one-time redirect here; the
* wizard clears it and sets a "complete" option when finished or skipped, so
* it never auto-opens again (but stays reachable at its URL).
*/
class SetupWizard
{
const PAGE = "aurora-admin-setup";
const OPT_COMPLETE = "aurora_admin_setup_complete";
const OPT_REDIRECT = "aurora_admin_setup_redirect";
private static $hook_suffix = null;
public function __construct()
{
register_activation_hook(AURORA_ADMIN_PATH . "aurora-admin.php", [self::class, "on_activate"]);
add_action("admin_menu", [self::class, "register_menu"]);
add_action("admin_enqueue_scripts", [self::class, "maybe_enqueue"]);
add_action("admin_init", [self::class, "maybe_redirect"]);
add_action("rest_api_init", [self::class, "register_rest"]);
// Turn the immersive shell off on this page so the wizard owns the whole
// viewport; native chrome is hidden separately (printed below).
add_filter("aurora_admin_shell_enabled", [self::class, "disable_shell_here"]);
add_action("admin_head", [self::class, "hide_native_chrome"]);
}
/** Flag a one-time redirect to the wizard, unless setup was already done. */
public static function on_activate()
{
if (!get_option(self::OPT_COMPLETE)) {
update_option(self::OPT_REDIRECT, 1);
}
}
private static function is_wizard_page()
{
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page match; no state change.
return isset($_GET["page"]) && sanitize_text_field(wp_unslash($_GET["page"])) === self::PAGE;
}
public static function disable_shell_here($enabled)
{
return self::is_wizard_page() ? false : $enabled;
}
public static function register_menu()
{
self::$hook_suffix = add_submenu_page(
"aurora-admin",
__("Setup", "aurora-admin"),
__("Setup", "aurora-admin"),
"manage_options",
self::PAGE,
[self::class, "render"]
);
}
public static function maybe_redirect()
{
if (!get_option(self::OPT_REDIRECT)) {
return;
}
if (wp_doing_ajax() || !current_user_can("manage_options") || self::is_wizard_page()) {
return;
}
delete_option(self::OPT_REDIRECT);
wp_safe_redirect(admin_url("admin.php?page=" . self::PAGE));
exit();
}
public static function maybe_enqueue($hook_suffix)
{
if ($hook_suffix !== self::$hook_suffix) {
return;
}
// Image fields (logo, dark logo, favicon) use the real media library.
wp_enqueue_media();
Assets::enqueue("aurora-admin-setup", "entries/setup-wizard.js");
}
/** Hide native WP chrome on the wizard page (the shell, which normally does
* this, is turned off here). The Vue app also renders as a full-screen
* overlay, but this keeps the admin bar/menu from flashing first. */
public static function hide_native_chrome()
{
if (!self::is_wizard_page()) {
return;
}
?>
<style id="aurora-setup-hide-chrome">
#adminmenumain, #wpadminbar, #wpfooter { display: none !important; }
html.wp-toolbar { padding-top: 0 !important; }
#wpcontent { margin-left: 0 !important; }
#wpbody-content { padding: 0 !important; float: none; width: auto; }
</style>
<?php
}
public static function register_rest()
{
register_rest_route("aurora-admin/v1", "/setup/complete", [
"methods" => "POST",
"callback" => [self::class, "rest_complete"],
"permission_callback" => function () {
return current_user_can("manage_options");
},
]);
}
public static function rest_complete()
{
update_option(self::OPT_COMPLETE, 1);
delete_option(self::OPT_REDIRECT);
return ["success" => true];
}
public static function render()
{
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"userName" => wp_get_current_user()->display_name,
"settings" => Settings::get(),
"dashboardUrl" => esc_url_raw(admin_url("index.php")),
"settingsUrl" => esc_url_raw(admin_url("admin.php?page=aurora-admin")),
];
?>
<div
id="aurora-admin-setup-root"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Registers Aurora's secondary full-page apps (Menu Creator, Admin Notices,
* Role Editor, Activity Log, Database Explorer, Plugins, Comments). Each is
* a real WP admin page (proper capability check, working URL, its own
* hook_suffix for asset gating) registered as a submenu of "aurora-admin"
* (or "plugins.php" for the Plugins page).
*
* These stay registered in WordPress's own $submenu — they are NOT hidden
* via remove_submenu_page(). An earlier version did call it (the standard
* "register but hide" trick), but that turned out to corrupt WP core's own
* access-control bookkeeping: user_can_access_admin_page() partly relies on
* $submenu still containing the entry to resolve the page's required
* capability, so removing it caused every user (including admins) to be
* denied with "Sorry, you are not allowed to access this page" — confirmed
* by reproducing the bug, disabling the remove_submenu_page() call, and
* watching it resolve immediately. Since Aurora already hides WordPress's
* entire native admin menu chrome (replaced by its own Vue sidebar), a
* still-registered-but-native-menu-invisible entry has no visible effect on
* its own — MenuSerializer::serialize() is what keeps these out of the
* sidebar's own WP-native-menu section (skips slugs prefixed
* "aurora-admin-", plus the "aurora-admin" top-level slug itself), so they
* aren't duplicated there. They're presented instead in the sidebar's
* dedicated Aurora nav section (see Shell::aurora_nav()).
*/
class SubApps
{
public static function pages()
{
return [
"aurora-admin-modules" => ["title" => __("Modules", "aurora-admin"), "entry" => "modules"],
"aurora-admin-menu-creator" => ["title" => __("Menu Creator", "aurora-admin"), "entry" => "menu-creator"],
"aurora-admin-notices" => ["title" => __("Admin Notices", "aurora-admin"), "entry" => "admin-notices"],
"aurora-admin-roles" => ["title" => __("Role Editor", "aurora-admin"), "entry" => "role-editor"],
"aurora-admin-activity-log" => ["title" => __("Activity Log", "aurora-admin"), "entry" => "activity-log"],
"aurora-admin-plugins" => ["title" => __("Installed Plugins", "aurora-admin"), "entry" => "plugins"],
"aurora-admin-bug-report" => ["title" => __("Report a Bug", "aurora-admin"), "entry" => "bug-report"],
];
}
private static $hook_suffixes = [];
public function __construct()
{
add_action("admin_menu", [self::class, "register_menus"]);
add_action("admin_enqueue_scripts", [self::class, "maybe_enqueue"]);
add_action("admin_init", [self::class, "redirect_native_pages"]);
}
public static function register_menus()
{
foreach (self::pages() as $slug => $page) {
$parent = self::parent_slug($slug);
$hook = add_submenu_page(
$parent,
$page["title"],
$page["title"],
self::capability($slug),
$slug,
[self::class, "render"]
);
self::$hook_suffixes[$slug] = $hook;
}
}
private static function parent_slug($slug)
{
if ($slug === "aurora-admin-plugins") {
return "plugins.php";
}
return "aurora-admin";
}
private static function capability($slug)
{
if ($slug === "aurora-admin-plugins") {
return "activate_plugins";
}
return "manage_options";
}
public static function maybe_enqueue($hook_suffix)
{
$slug = array_search($hook_suffix, self::$hook_suffixes, true);
$pages = self::pages();
if ($slug === false || !isset($pages[$slug])) {
return;
}
$page = $pages[$slug];
Assets::enqueue("aurora-admin-" . $page["entry"], "entries/" . $page["entry"] . ".js");
}
public static function redirect_native_pages()
{
global $pagenow;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only isset() presence check (no value read), used only to skip pages that already have a ?page= param; no state change.
if (isset($_GET["page"]) || wp_doing_ajax()) {
return;
}
// Settings > Plugins > "Modern plugins page". Unlike the others below,
// this one was previously only handled by MenuSerializer rewriting the
// sidebar's own link — direct visits to plugins.php never redirected,
// which is exactly what WordPress's own post-activate/post-install
// redirect does (wp_redirect(self_admin_url('plugins.php?activate=true'))
// — no ?page= param, so it always landed on the true native page,
// fixed only once the user next clicked the (correctly rewritten)
// sidebar link themselves. Redirects to plugins.php?page=... (not
// admin.php?page=...) to match the URL admin_url("plugins.php?page=
// aurora-admin-plugins") that the sidebar link itself already uses.
if (
$pagenow === "plugins.php" &&
current_user_can("activate_plugins") &&
Settings::get("modern_plugins", true)
) {
wp_safe_redirect(admin_url("plugins.php?page=aurora-admin-plugins"));
exit;
}
}
public static function render()
{
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, routes rendering to the matching page's Vue root by slug; no state change.
$page = isset($_GET["page"]) ? sanitize_text_field(wp_unslash($_GET["page"])) : "";
$pages = self::pages();
if (!isset($pages[$page])) {
return;
}
$roles = [];
foreach (wp_roles()->roles as $slug => $info) {
$roles[] = ["value" => $slug, "label" => translate_user_role($info["name"])];
}
global $wpdb;
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"userName" => wp_get_current_user()->display_name,
"roles" => $roles,
"tablePrefix" => $wpdb->prefix,
"newPostUrl" => esc_url_raw(admin_url("post-new.php")),
"newPageUrl" => esc_url_raw(admin_url("post-new.php?post_type=page")),
"newMediaUrl" => esc_url_raw(admin_url("media-new.php")),
"newUserUrl" => esc_url_raw(admin_url("user-new.php")),
];
// Report a Bug auto-fills its diagnostic fields from these — saves the
// reporter from having to look any of it up themselves.
if ($page === "aurora-admin-bug-report") {
$data["pluginVersion"] = defined("AURORA_ADMIN_VERSION") ? AURORA_ADMIN_VERSION : "";
$data["wpVersion"] = get_bloginfo("version");
$data["phpVersion"] = phpversion();
$data["theme"] = wp_get_theme()->get("Name");
}
// The Menu Creator editor needs the raw top-level menu, which can't be
// read over REST (the admin $menu isn't built there). Pass the snapshot
// captured during this admin page load instead.
if ($page === "aurora-admin-menu-creator") {
$data["nativeItems"] = \AuroraAdmin\MenuCreator\MenuCreator::native_snapshot();
}
$root_id = "aurora-admin-" . $pages[$page]["entry"] . "-root";
?>
<div
id="<?php echo esc_attr($root_id); ?>"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the "Available Tools" screen (tools.php) for the "full admin
* takeover" initiative — a real hijacked replacement, not just a reframed
* native page.
*
* tools.php's whole purpose is being an extension point: other plugins add
* their own cards to it via the `tool_box` action (WooCommerce, migration
* tools, etc. have historically used it). A pure from-scratch rebuild would
* silently drop that content the moment Aurora takes over the screen, so
* this still captures and re-renders whatever `tool_box` produces — Aurora
* owns the page shell (header, card styling, layout) but not the content of
* hooks it doesn't control.
*
* Import, Export, Site Health, Export/Erase Personal Data, and the Theme/
* Plugin File Editors are separate screens (their own load-{file} hooks) and
* are not covered by this — Available Tools only.
*
* Native tools.php's "Categories and Tags Converter" card is NOT produced by
* the tool_box hook — it's hardcoded directly in core's own template, gated
* on the `import` capability plus category/tag manage_terms. Replicated here
* with the identical gate so visibility exactly matches what core itself
* would have shown.
*/
class ToolsPage
{
public function __construct()
{
ScreenHijack::register(
"load-tools.php",
"edit_posts", // Matches core's own capability for this screen.
"full_admin_takeover",
"tools",
"aurora-admin-tools-root",
null,
null,
function () {
// Same gate core's own tools.php applies before showing the
// Categories and Tags Converter card.
$show_converter = false;
if (current_user_can("import")) {
$cats = get_taxonomy("category");
$tags = get_taxonomy("post_tag");
$show_converter =
($cats && current_user_can($cats->cap->manage_terms)) ||
($tags && current_user_can($tags->cap->manage_terms));
}
// The native call to do_action('tool_box') already happened during
// this same request (inside the buffer ScreenHijack discards) — this
// is a second, independent invocation solely to capture its output
// for Aurora's own page, so any OTHER plugin's tool_box card still
// shows up here. Standard "buffer a hook to capture its markup"
// pattern; safe for the well-behaved, output-only callbacks this
// hook is meant for.
ob_start();
do_action("tool_box");
$native_tools_html = trim((string) ob_get_clean());
return [
"showCategoriesConverter" => $show_converter,
"importUrl" => esc_url_raw(admin_url("import.php")),
"nativeToolsHtml" => $native_tools_html,
];
}
);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace AuroraAdmin\Posts;
defined("ABSPATH") || exit();
/**
* When the posts list (edit.php) is opened with ?aurora_day=YYYY-MM-DD
* (from the dashboard's Scheduled Content calendar), constrain the main
* query to scheduled posts on that exact day.
*/
class ScheduledDayFilter
{
public function __construct()
{
add_action("pre_get_posts", [self::class, "filter"]);
add_action("in_admin_header", [self::class, "notice"]);
}
private static function day()
{
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only query-string filter for the posts list (like core's own list table filters, e.g. ?post_status=), no state change; value is sanitized and further validated against a strict date pattern below.
$day = isset($_GET["aurora_day"]) ? sanitize_text_field(wp_unslash($_GET["aurora_day"])) : "";
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $day) ? $day : "";
}
public static function filter($query)
{
if (!is_admin() || !$query->is_main_query()) {
return;
}
if (($GLOBALS["pagenow"] ?? "") !== "edit.php") {
return;
}
$day = self::day();
if (!$day) {
return;
}
[$y, $m, $d] = array_map("intval", explode("-", $day));
$query->set("post_status", "future");
$query->set("date_query", [["year" => $y, "month" => $m, "day" => $d]]);
}
/**
* Small heading on the filtered posts list so it's clear you're viewing a
* single day's scheduled content (with a link back to all scheduled).
*/
public static function notice()
{
if (($GLOBALS["pagenow"] ?? "") !== "edit.php") {
return;
}
$day = self::day();
if (!$day) {
return;
}
$label = date_i18n(get_option("date_format"), strtotime($day));
$all = admin_url("edit.php?post_status=future");
?>
<div class="notice notice-info" style="margin-top:12px;">
<p>
<?php printf(
/* translators: %s: formatted date */
esc_html__("Showing scheduled content for %s.", "aurora-admin"),
"<strong>" . esc_html($label) . "</strong>"
); ?>
&nbsp;<a href="<?php echo esc_url($all); ?>"><?php esc_html_e("View all scheduled", "aurora-admin"); ?></a>
</p>
</div>
<?php
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace AuroraAdmin\Rest;
use AuroraAdmin\Analytics\Analytics;
defined("ABSPATH") || exit();
/**
* Aggregates the first-party pageview table into the figures the Analytics
* tab cards need. GET aurora-admin/v1/analytics?from=YYYY-MM-DD&to=YYYY-MM-DD
*/
class AnalyticsData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/analytics", [
"methods" => "GET",
"callback" => [self::class, "handle"],
"permission_callback" => function () {
return current_user_can("read");
},
]);
}
public static function handle($request)
{
global $wpdb;
$table = Analytics::table_name();
$from = $request->get_param("from") ?: gmdate("Y-m-d", strtotime("-7 days"));
$to = $request->get_param("to") ?: gmdate("Y-m-d");
$from_dt = $from . " 00:00:00";
$to_dt = $to . " 23:59:59";
$where = $wpdb->prepare("created_at BETWEEN %s AND %s", $from_dt, $to_dt);
// --- Totals ---
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
$total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE {$where}");
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
$unique = (int) $wpdb->get_var("SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE {$where}");
// --- Daily series ---
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
$rows = $wpdb->get_results("SELECT DATE(created_at) AS d, COUNT(*) AS c FROM {$table} WHERE {$where} GROUP BY DATE(created_at)", OBJECT_K);
$series = [];
$cursor = strtotime($from);
$end = strtotime($to);
while ($cursor <= $end) {
$key = gmdate("Y-m-d", $cursor);
$series[] = [
"date" => gmdate("M j", $cursor),
"count" => isset($rows[$key]) ? (int) $rows[$key]->c : 0,
];
$cursor = strtotime("+1 day", $cursor);
}
// --- Device breakdown ---
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
$devices = $wpdb->get_results("SELECT device, COUNT(*) AS c FROM {$table} WHERE {$where} GROUP BY device ORDER BY c DESC");
$deviceUsage = [];
foreach ($devices as $d) {
$deviceUsage[] = [
"device" => $d->device,
"count" => (int) $d->c,
"pct" => $total ? round(($d->c / $total) * 100) : 0,
];
}
// --- Top pages / referrers / countries ---
$topPages = self::top($wpdb, $table, $where, "url", 6);
$topReferrers = self::top($wpdb, $table, $where, "referrer", 6, true);
$topCountries = self::top($wpdb, $table, $where, "country", 6, true);
// --- Bounce rate & avg session depth ---
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name (Analytics::table_name()), not user input; {$where} was already built via $wpdb->prepare() above.
$sessionCounts = $wpdb->get_results("SELECT session_id, COUNT(*) AS c, TIMESTAMPDIFF(SECOND, MIN(created_at), MAX(created_at)) AS dur FROM {$table} WHERE {$where} GROUP BY session_id");
$sessions = count($sessionCounts);
$bounces = 0;
$totalDur = 0;
foreach ($sessionCounts as $s) {
if ((int) $s->c <= 1) {
$bounces++;
}
$totalDur += (int) $s->dur;
}
$bounceRate = $sessions ? round(($bounces / $sessions) * 100) : 0;
$avgSeconds = $sessions ? round($totalDur / $sessions) : 0;
// --- Active users (last 5 minutes) ---
$activeCutoff = gmdate("Y-m-d H:i:s", current_time("timestamp") - 300);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name, not user input; $activeCutoff is bound via the $wpdb->prepare() %s placeholder. Deliberately not cached — this is a live "active right now" figure.
$active = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE created_at >= %s", $activeCutoff));
return new \WP_REST_Response([
"pageViews" => ["total" => $total, "unique" => $unique, "series" => $series],
"deviceUsage" => $deviceUsage,
"topPages" => $topPages,
"topReferrers" => $topReferrers,
"topCountries" => $topCountries,
"bounce" => [
"rate" => $bounceRate,
"avgTime" => self::format_duration($avgSeconds),
"sessions" => $sessions,
],
"activeUsers" => $active,
], 200);
}
private static function top($wpdb, $table, $where, $col, $limit, $skip_empty = false)
{
$extra = $skip_empty ? " AND {$col} <> ''" : "";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is an internal, hardcoded table name; $col/$limit are internal literals passed only by this file's own three call sites above ("url"/"referrer"/"country", 6 — never from $request), not user input; $where was already built via $wpdb->prepare() by the caller.
$results = $wpdb->get_results("SELECT {$col} AS label, COUNT(*) AS c FROM {$table} WHERE {$where}{$extra} GROUP BY {$col} ORDER BY c DESC LIMIT {$limit}");
$out = [];
foreach ($results as $r) {
$out[] = ["label" => $r->label !== "" ? $r->label : "Direct", "count" => (int) $r->c];
}
return $out;
}
private static function format_duration($seconds)
{
if ($seconds <= 0) {
return "0s";
}
$m = floor($seconds / 60);
$s = $seconds % 60;
return $m > 0 ? "{$m}m {$s}s" : "{$s}s";
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* POST /bug-report — forwards a bug report to the Aurora Dragon Studio
* feedback endpoint, tagged with basic diagnostics (plugin/WP/PHP version,
* active theme, domain) so reports can be triaged without a separate
* ticketing system.
*/
class BugReportData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/bug-report", [
"methods" => "POST",
"callback" => [self::class, "submit"],
"permission_callback" => function () {
return current_user_can("manage_options");
},
"args" => [
"description" => ["type" => "string", "required" => true],
],
]);
}
const API_URL = "https://auroraplugins.com/wp-json/aurora-licensing/v1/report-bug";
public static function submit($request)
{
$description = sanitize_textarea_field((string) $request->get_param("description"));
if (trim($description) === "") {
return new \WP_REST_Response(["success" => false, "message" => "Description is required."], 400);
}
$response = wp_remote_post(self::API_URL, [
"timeout" => 8,
"headers" => ["Content-Type" => "application/json"],
"body" => wp_json_encode([
"description" => $description,
"domain" => (string) wp_parse_url(home_url(), PHP_URL_HOST),
"plugin_version" => defined("AURORA_ADMIN_VERSION") ? AURORA_ADMIN_VERSION : "",
"wp_version" => get_bloginfo("version"),
"php_version" => phpversion(),
"theme" => wp_get_theme()->get("Name"),
]),
]);
if (is_wp_error($response)) {
return new \WP_REST_Response([
"success" => false,
"message" => $response->get_error_message(),
], 502);
}
$decoded = json_decode(wp_remote_retrieve_body($response), true);
if (empty($decoded["success"])) {
return new \WP_REST_Response([
"success" => false,
"message" => $decoded["message"] ?? "Could not send the report.",
], 502);
}
return new \WP_REST_Response(["success" => true], 200);
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's custom Comments page.
*/
class CommentsData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/comments", [
"methods" => "GET",
"callback" => [self::class, "list_comments"],
"permission_callback" => function () {
return current_user_can("moderate_comments");
},
"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", "/comments/status", [
"methods" => "POST",
"callback" => [self::class, "update_status"],
"permission_callback" => function () {
return current_user_can("moderate_comments");
},
"args" => [
"id" => ["type" => "integer", "required" => true],
"status" => ["type" => "string", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/comments/(?P<id>\d+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_comment"],
"permission_callback" => function () {
return current_user_can("moderate_comments");
},
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
}
public static function list_comments($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") ?: 50)));
$search = sanitize_text_field((string) $request->get_param("search"));
$args = [
"status" => $status,
"number" => $per_page,
"offset" => ($page - 1) * $per_page,
"orderby" => "comment_date_gmt",
"order" => "DESC",
"type" => "comment",
];
if ($search !== "") {
$args["search"] = $search;
}
$query = new \WP_Comment_Query();
$comments = $query->query($args);
$total = (int) $query->found_comments;
return new \WP_REST_Response([
"items" => array_map([self::class, "serialize_comment"], $comments),
"stats" => self::stats(),
"pagination" => [
"page" => $page,
"perPage" => $per_page,
"total" => $total,
"totalPages" => max(1, (int) ceil($total / $per_page)),
],
], 200);
}
public static function update_status($request)
{
$id = (int) $request->get_param("id");
$status = self::action_status($request->get_param("status"));
if (!$id || !$status || !get_comment($id)) {
return new \WP_Error("aurora_comment_status_invalid", __("Invalid comment action.", "aurora-admin"), ["status" => 400]);
}
$result = wp_set_comment_status($id, $status);
if (!$result) {
return new \WP_Error("aurora_comment_status_failed", __("Could not update the comment.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Comment updated.", "aurora-admin")], 200);
}
public static function delete_comment($request)
{
$id = (int) $request->get_param("id");
if (!$id || !get_comment($id)) {
return new \WP_Error("aurora_comment_delete_invalid", __("Invalid comment.", "aurora-admin"), ["status" => 400]);
}
$result = wp_delete_comment($id, true);
if (!$result) {
return new \WP_Error("aurora_comment_delete_failed", __("Could not delete the comment.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Comment deleted.", "aurora-admin")], 200);
}
private static function serialize_comment($comment)
{
$post_id = (int) $comment->comment_post_ID;
$status = wp_get_comment_status($comment);
return [
"id" => (int) $comment->comment_ID,
"author" => $comment->comment_author ?: __("Anonymous", "aurora-admin"),
"email" => $comment->comment_author_email,
"avatar" => get_avatar_url($comment, ["size" => 64]),
"content" => wp_trim_words(wp_strip_all_tags($comment->comment_content), 28),
"rawContent" => $comment->comment_content,
"postId" => $post_id,
"postTitle" => get_the_title($post_id) ?: __("(No title)", "aurora-admin"),
"postEditUrl" => get_edit_post_link($post_id, "raw") ?: "",
"date" => mysql2date("M j, Y", $comment->comment_date),
// translators: %s is a human-readable relative time (e.g. "3 hours", "2 days").
"relativeDate" => sprintf(__("%s ago", "aurora-admin"), human_time_diff(strtotime($comment->comment_date_gmt), current_time("timestamp", true))),
"status" => $status,
"statusLabel" => self::status_label($status),
"editUrl" => admin_url("comment.php?action=editcomment&c=" . (int) $comment->comment_ID),
"canEdit" => current_user_can("edit_comment", (int) $comment->comment_ID),
];
}
private static function stats()
{
$counts = wp_count_comments();
$approved = isset($counts->approved) ? (int) $counts->approved : 0;
$pending = isset($counts->moderated) ? (int) $counts->moderated : 0;
$spam = isset($counts->spam) ? (int) $counts->spam : 0;
$trash = isset($counts->trash) ? (int) $counts->trash : 0;
return [
"all" => $approved + $pending + $spam,
"approved" => $approved,
"pending" => $pending,
"spam" => $spam,
"trash" => $trash,
];
}
private static function query_status($status)
{
switch ($status) {
case "approved":
return "approve";
case "pending":
return "hold";
case "spam":
case "trash":
return $status;
default:
return "all";
}
}
private static function action_status($status)
{
switch ($status) {
case "approve":
case "hold":
case "spam":
case "trash":
return $status;
default:
return "";
}
}
private static function status_label($status)
{
switch ($status) {
case "approved":
return __("Approved", "aurora-admin");
case "unapproved":
return __("Pending", "aurora-admin");
case "spam":
return __("Spam", "aurora-admin");
case "trash":
return __("Trash", "aurora-admin");
default:
return ucfirst($status);
}
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* Installs Aurora's standalone companion plugins (File Manager, Database
* Explorer, Site Backup, and any future ones) — each split out of Aurora
* Admin either because WordPress.org's Plugin Directory policy doesn't
* accept that category of functionality in a directory-listed plugin
* (File Manager, confirmed via two rejections), or proactively to reduce
* that same risk (Database Explorer, Site Backup — see
* PROJECT_STATUS.md's per-plugin sections for the full history). Rather
* than a bare download link, this wraps WP_Upgrader/Plugin_Upgrader — the
* same core API the native Plugins > Add New "Install Now" button and the
* Uploads tab both use — pointed at a fixed, hardcoded HTTPS zip URL per
* plugin (never client-supplied, so there's no SSRF surface here: the
* install target isn't user input).
*/
class CompanionPluginData
{
// Add an entry here for each companion plugin. The REST routes below are
// generated per-slug (e.g. /companion-plugins/file-manager/status), so a
// new companion plugin only needs a new entry here plus a matching
// settingsConfig.js field — no new PHP route-registration code.
const PLUGINS = [
"file-manager" => [
"zip_url" => "https://aurora.auroradragon.studio/aurora-file-manager.zip",
"plugin_file" => "aurora-file-manager/aurora-file-manager.php",
"label" => "Aurora File Manager",
],
"database-explorer" => [
"zip_url" => "https://aurora.auroradragon.studio/aurora-database-explorer.zip",
"plugin_file" => "aurora-database-explorer/aurora-database-explorer.php",
"label" => "Aurora Database Explorer",
],
"site-backup" => [
"zip_url" => "https://aurora.auroradragon.studio/aurora-site-backup.zip",
"plugin_file" => "aurora-site-backup/aurora-site-backup.php",
"label" => "Aurora Site Backup",
],
];
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("install_plugins") && current_user_can("activate_plugins");
};
foreach (array_keys(self::PLUGINS) as $slug) {
register_rest_route("aurora-admin/v1", "/companion-plugins/{$slug}/status", [
"methods" => "GET",
"callback" => function () use ($slug) {
return self::status($slug);
},
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/companion-plugins/{$slug}/install", [
"methods" => "POST",
"callback" => function () use ($slug) {
return self::install($slug);
},
"permission_callback" => $permission,
]);
}
}
private static function is_installed($plugin_file)
{
if (!function_exists("get_plugins")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}
return array_key_exists($plugin_file, get_plugins());
}
private static function is_active($plugin_file)
{
return is_plugin_active($plugin_file);
}
public static function status($slug)
{
if (!function_exists("is_plugin_active")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}
$plugin_file = self::PLUGINS[$slug]["plugin_file"];
return new \WP_REST_Response([
"installed" => self::is_installed($plugin_file),
"active" => self::is_installed($plugin_file) && self::is_active($plugin_file),
], 200);
}
/**
* Installs (if not already present) and activates the given companion
* plugin. Uses Plugin_Upgrader::install() against the fixed zip URL — the
* exact same core class WordPress's own plugin-install.php uses, so it
* inherits the same WP_Filesystem credential handling (a host requiring
* FTP/SSH credentials for direct file writes will surface that as a
* normal WP_Error here, not a fatal).
*/
public static function install($slug)
{
$plugin = self::PLUGINS[$slug];
$plugin_file = $plugin["plugin_file"];
if (self::is_installed($plugin_file)) {
if (!self::is_active($plugin_file)) {
$activated = activate_plugin($plugin_file);
if (is_wp_error($activated)) {
return new \WP_REST_Response(["success" => false, "message" => $activated->get_error_message()], 500);
}
}
/* translators: %s: companion plugin name, e.g. "Aurora File Manager" */
return new \WP_REST_Response(["success" => true, "message" => sprintf(__("%s is installed and active.", "aurora-admin"), $plugin["label"])], 200);
}
require_once ABSPATH . "wp-admin/includes/class-wp-upgrader.php";
require_once ABSPATH . "wp-admin/includes/plugin-install.php";
require_once ABSPATH . "wp-admin/includes/plugin.php";
$skin = new \Automatic_Upgrader_Skin();
$upgrader = new \Plugin_Upgrader($skin);
$result = $upgrader->install($plugin["zip_url"]);
if (is_wp_error($result)) {
return new \WP_REST_Response(["success" => false, "message" => $result->get_error_message()], 500);
}
if ($result !== true) {
$error = $skin->get_errors();
return new \WP_REST_Response([
"success" => false,
"message" => is_wp_error($error) && $error->get_error_message() ? $error->get_error_message() : __("Installation failed.", "aurora-admin"),
], 500);
}
if (!self::is_installed($plugin_file)) {
return new \WP_REST_Response(["success" => false, "message" => __("Installed, but the plugin file wasn't found afterward — check Plugins for details.", "aurora-admin")], 500);
}
$activated = activate_plugin($plugin_file);
if (is_wp_error($activated)) {
return new \WP_REST_Response([
"success" => true,
"message" => __("Installed, but couldn't activate automatically — activate it from the Plugins screen.", "aurora-admin"),
], 200);
}
/* translators: %s: companion plugin name, e.g. "Aurora File Manager" */
return new \WP_REST_Response(["success" => true, "message" => sprintf(__("%s installed and activated.", "aurora-admin"), $plugin["label"])], 200);
}
}
+292
View File
@@ -0,0 +1,292 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoint that supplies all data for the Aurora dashboard cards.
* GET aurora-admin/v1/dashboard?from=YYYY-MM-DD&to=YYYY-MM-DD
*
* Everything is computed fresh here from core WordPress APIs — no reuse of
* the original plugin's data layer.
*/
class DashboardData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/dashboard", [
"methods" => "GET",
"callback" => [self::class, "handle"],
"permission_callback" => function () {
return current_user_can("read");
},
"args" => [
"from" => ["type" => "string", "required" => false],
"to" => ["type" => "string", "required" => false],
],
]);
}
public static function handle($request)
{
$to = self::parse_date($request->get_param("to"), "now");
$from = self::parse_date($request->get_param("from"), "-7 days");
return new \WP_REST_Response([
"recentContent" => self::recent_content(),
"scheduledContent" => self::scheduled_content(),
"recentComments" => self::recent_comments(),
"userAnalytics" => self::user_analytics($from, $to),
"mediaAnalytics" => self::media_analytics(),
"serverHealth" => self::server_health(),
], 200);
}
private static function parse_date($value, $fallback)
{
$ts = $value ? strtotime($value) : false;
if ($ts === false) {
$ts = strtotime($fallback);
}
return $ts;
}
private static function recent_content()
{
$counts = wp_count_posts("post");
$recent = get_posts([
"post_type" => "post",
"post_status" => ["publish", "draft", "future"],
"numberposts" => 5,
"orderby" => "date",
"order" => "DESC",
]);
$latest = [];
foreach ($recent as $p) {
$latest[] = [
"id" => $p->ID,
"title" => get_the_title($p),
"author" => get_the_author_meta("display_name", $p->post_author),
"date" => get_the_date("M j, Y", $p),
"status" => $p->post_status,
"editUrl" => get_edit_post_link($p->ID, "raw"),
];
}
return [
"published" => (int) $counts->publish,
"draft" => (int) $counts->draft,
"scheduled" => (int) $counts->future,
"total" => (int) $counts->publish + (int) $counts->draft + (int) $counts->future,
"latest" => $latest,
"listUrl" => admin_url("edit.php"),
];
}
private static function scheduled_content()
{
$future = get_posts([
"post_type" => "post",
"post_status" => "future",
"numberposts" => 100,
"orderby" => "date",
"order" => "ASC",
]);
$days = [];
foreach ($future as $p) {
$days[get_the_date("Y-m-d", $p)][] = [
"id" => $p->ID,
"title" => get_the_title($p),
"time" => get_the_time("g:i a", $p),
];
}
return [
"count" => count($future),
"days" => $days,
];
}
private static function recent_comments()
{
$counts = wp_count_comments();
$recent = get_comments([
"number" => 5,
"status" => "approve",
"orderby" => "comment_date_gmt",
"order" => "DESC",
]);
$latest = [];
foreach ($recent as $c) {
$latest[] = [
"id" => $c->comment_ID,
"author" => $c->comment_author,
"excerpt" => wp_trim_words($c->comment_content, 18),
"postTitle" => get_the_title($c->comment_post_ID),
"date" => mysql2date("M j, Y", $c->comment_date),
];
}
return [
"approved" => (int) $counts->approved,
"pending" => (int) $counts->moderated,
"spam" => (int) $counts->spam,
"total" => (int) $counts->approved,
"latest" => $latest,
"listUrl" => admin_url("admin.php?page=aurora-admin-comments"),
];
}
private static function user_analytics($from, $to)
{
$total = count_users();
$total_users = (int) $total["total_users"];
$from_date = gmdate("Y-m-d 00:00:00", $from);
$to_date = gmdate("Y-m-d 23:59:59", $to);
$in_range = get_users([
"date_query" => [["after" => $from_date, "before" => $to_date, "inclusive" => true]],
"fields" => "ID",
]);
$recent7 = get_users([
"date_query" => [["after" => "-7 days", "inclusive" => true]],
"fields" => "ID",
]);
// Daily registration series across the range
$series = [];
$cursor = strtotime(gmdate("Y-m-d", $from));
$end = strtotime(gmdate("Y-m-d", $to));
$by_day = [];
foreach ($in_range as $uid) {
$reg = get_userdata($uid)->user_registered;
$by_day[mysql2date("Y-m-d", $reg)] = (isset($by_day[mysql2date("Y-m-d", $reg)]) ? $by_day[mysql2date("Y-m-d", $reg)] : 0) + 1;
}
while ($cursor <= $end) {
$key = gmdate("Y-m-d", $cursor);
$series[] = [
"date" => gmdate("M j", $cursor),
"count" => isset($by_day[$key]) ? $by_day[$key] : 0,
];
$cursor = strtotime("+1 day", $cursor);
}
return [
"total" => $total_users,
"inRange" => count($in_range),
"recent7d" => count($recent7),
"series" => $series,
];
}
private static function media_analytics()
{
$attachments = get_posts([
"post_type" => "attachment",
"post_status" => "inherit",
"numberposts" => -1,
"fields" => "ids",
]);
$total_size = 0;
$large = 0;
$recent30 = 0;
$thirty_ago = strtotime("-30 days");
foreach ($attachments as $id) {
$file = get_attached_file($id);
$size = $file && file_exists($file) ? filesize($file) : 0;
$total_size += $size;
if ($size > 5 * 1024 * 1024) {
$large++;
}
if (strtotime(get_post_field("post_date", $id)) >= $thirty_ago) {
$recent30++;
}
}
return [
"totalFiles" => count($attachments),
"totalSize" => self::format_bytes($total_size),
"recent30d" => $recent30,
"unused" => 0,
"largeFiles" => $large,
"diskUsage" => self::format_bytes($total_size),
"manageUrl" => admin_url("upload.php"),
];
}
private static function server_health()
{
$mem_limit = self::to_bytes(ini_get("memory_limit"));
$mem_used = memory_get_usage(true);
$disk_total = @disk_total_space(ABSPATH) ?: 0;
$disk_free = @disk_free_space(ABSPATH) ?: 0;
$disk_used = $disk_total - $disk_free;
$updates = function_exists("wp_get_update_data") ? wp_get_update_data() : ["counts" => []];
$counts = isset($updates["counts"]) ? $updates["counts"] : [];
return [
"wp" => get_bloginfo("version"),
"php" => PHP_VERSION,
"memory" => [
"used" => self::format_bytes($mem_used),
"total" => self::format_bytes($mem_limit),
"pct" => $mem_limit > 0 ? round(($mem_used / $mem_limit) * 100, 1) : 0,
],
"disk" => [
"used" => self::format_bytes($disk_used),
"total" => self::format_bytes($disk_total),
"pct" => $disk_total > 0 ? round(($disk_used / $disk_total) * 100, 1) : 0,
],
"plugins" => isset($counts["plugins"]) ? (int) $counts["plugins"] : 0,
"themes" => isset($counts["themes"]) ? (int) $counts["themes"] : 0,
"core" => isset($counts["wordpress"]) ? (int) $counts["wordpress"] : 0,
"updates" => isset($counts["total"]) ? (int) $counts["total"] : 0,
"ssl" => is_ssl(),
"timezone" => wp_timezone_string(),
];
}
private static function format_bytes($bytes)
{
$bytes = (float) $bytes;
$units = ["B", "KB", "MB", "GB", "TB"];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . " " . $units[$i];
}
private static function to_bytes($val)
{
$val = trim($val);
if ($val === "" || $val === "-1") {
return 0;
}
$last = strtolower($val[strlen($val) - 1]);
$num = (float) $val;
switch ($last) {
case "g":
$num *= 1024;
case "m":
$num *= 1024;
case "k":
$num *= 1024;
}
return $num;
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* Core's REST settings schema (register_initial_settings(), wp-includes/
* option.php) only exposes 2 of options-discussion.php's ~15 real fields
* (default_ping_status/default_comment_status) — everything else on that
* native screen (moderation, threading, notification emails, avatars) has
* no REST route at all. This is the same situation Permalinks was in
* (PermalinksData.php) — a dedicated controller doing raw get_option()/
* update_option() reads/writes instead of going through wp/v2/settings.
*
* Every key this accepts is explicitly allowlisted below — the client
* sends a {values: {...}} object, but only keys this class already knows
* about are ever written, so a modified request can't use this route to
* write an arbitrary WP option.
*/
class DiscussionData
{
const BOOLEAN_KEYS = [
"default_pingback_flag",
"require_name_email",
"comment_registration",
"close_comments_for_old_posts",
"show_comments_cookies_opt_in",
"thread_comments",
"page_comments",
"comments_notify",
"moderation_notify",
"wp_notes_notify",
"comment_moderation",
"comment_previously_approved",
"show_avatars",
];
const INTEGER_KEYS = ["close_comments_days_old", "comments_per_page", "thread_comments_depth", "comment_max_links"];
const TEXTAREA_KEYS = ["moderation_keys", "disallowed_keys"];
const ENUM_KEYS = [
"default_ping_status" => ["open", "closed"],
"default_comment_status" => ["open", "closed"],
"default_comments_page" => ["newest", "oldest"],
"comment_order" => ["asc", "desc"],
"avatar_rating" => ["G", "PG", "R", "X"],
"avatar_default" => [
"mystery",
"blank",
"gravatar_default",
"identicon",
"wavatar",
"monsterid",
"retro",
"robohash",
"initials",
"color",
],
];
// Matches native options-discussion.php's own get_option() defaults —
// everything else defaults to empty/false when never saved.
const DEFAULTS = [
"wp_notes_notify" => true,
"avatar_default" => "mystery",
];
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/discussion-settings", [
"methods" => "GET",
"callback" => [self::class, "get_values"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/discussion-settings", [
"methods" => "POST",
"callback" => [self::class, "update_values"],
"permission_callback" => [self::class, "check_permission"],
"args" => [
"values" => ["type" => "object", "required" => true],
],
]);
}
public static function check_permission()
{
return current_user_can("manage_options");
}
private static function all_keys()
{
return array_merge(self::BOOLEAN_KEYS, self::INTEGER_KEYS, self::TEXTAREA_KEYS, array_keys(self::ENUM_KEYS));
}
public static function get_values()
{
$out = [];
foreach (self::all_keys() as $key) {
$default = self::DEFAULTS[$key] ?? "";
$value = get_option($key, $default);
if (in_array($key, self::BOOLEAN_KEYS, true)) {
$out[$key] = (bool) $value;
} elseif (in_array($key, self::INTEGER_KEYS, true)) {
$out[$key] = (int) $value;
} else {
$out[$key] = (string) $value;
}
}
return new \WP_REST_Response($out, 200);
}
public static function update_values($request)
{
$values = (array) $request->get_param("values");
foreach ($values as $key => $value) {
if (in_array($key, self::BOOLEAN_KEYS, true)) {
update_option($key, $value ? "1" : "");
} elseif (in_array($key, self::INTEGER_KEYS, true)) {
update_option($key, max(0, (int) $value));
} elseif (in_array($key, self::TEXTAREA_KEYS, true)) {
update_option($key, sanitize_textarea_field((string) $value));
} elseif (isset(self::ENUM_KEYS[$key]) && in_array($value, self::ENUM_KEYS[$key], true)) {
update_option($key, $value);
}
// Any other key (unknown/unauthorized) is silently ignored rather
// than erroring — keeps a partial-field save from failing outright.
}
return new \WP_REST_Response(["success" => true], 200);
}
}
+237
View File
@@ -0,0 +1,237 @@
<?php
namespace AuroraAdmin\Rest;
use AuroraAdmin\Media\MediaFolders;
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],
"folderId" => ["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;
}
$folder_param = $request->get_param("folderId");
if ($folder_param !== null && $folder_param !== "") {
$folder_id = (int) $folder_param;
if ($folder_id === MediaFolders::UNCATEGORIZED) {
$in_any_folder = MediaFolders::all_assigned_attachment_ids();
// phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- a WordPress VIP-scale performance advisory, not a WP.org Plugin Directory requirement; "show media not assigned to any folder" has no other query shape without a real inverse-membership index, and Media Folders is bounded by how many attachments a site actually has, not VIP-scale traffic.
$args["post__not_in"] = !empty($in_any_folder) ? $in_any_folder : [0];
} elseif ($folder_id !== MediaFolders::ALL_MEDIA) {
$ids = MediaFolders::attachment_ids_in_folder($folder_id);
$args["post__in"] = !empty($ids) ? $ids : [0];
}
}
$query = new \WP_Query($args);
$folder_map = MediaFolders::folder_ids_for_attachments(wp_list_pluck($query->posts, "ID"));
return new \WP_REST_Response([
"items" => array_map(
function ($post) use ($folder_map) {
return self::serialize_attachment($post, $folder_map[$post->ID] ?? MediaFolders::UNCATEGORIZED);
},
$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, $folder_id = null)
{
$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),
"folderId" => $folder_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,
];
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- {$wpdb->posts} is WordPress core's own table, no user-supplied value in the query; there's no WP_Query/get_posts() shape for "counts grouped by mime-type prefix", which is exactly what this powers (the Media Library's type-filter tab counts).
$rows = $wpdb->get_results(
"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;
}
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- {$wpdb->posts} is WordPress core's own table, no user-supplied value in the query.
$counts["unattached"] = (int) $wpdb->get_var(
"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");
}
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace AuroraAdmin\Rest;
use AuroraAdmin\Media\MediaFolders;
defined("ABSPATH") || exit();
/**
* REST endpoints for Media Folders.
*/
class MediaFolderData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/media-folders", [
"methods" => "GET",
"callback" => [self::class, "get_tree"],
"permission_callback" => [self::class, "can_manage"],
]);
register_rest_route("aurora-admin/v1", "/media-folders", [
"methods" => "POST",
"callback" => [self::class, "create_folder"],
"permission_callback" => [self::class, "can_manage"],
"args" => [
"name" => ["type" => "string", "required" => true],
"parent" => ["type" => "integer", "required" => false],
],
]);
register_rest_route("aurora-admin/v1", "/media-folders/(?P<id>\d+)", [
"methods" => "POST",
"callback" => [self::class, "update_folder"],
"permission_callback" => [self::class, "can_manage"],
"args" => [
"id" => ["type" => "integer", "required" => true],
"name" => ["type" => "string", "required" => false],
"parent" => ["type" => "integer", "required" => false],
"siblingOrder" => ["type" => "array", "required" => false],
],
]);
register_rest_route("aurora-admin/v1", "/media-folders/(?P<id>\d+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_folder"],
"permission_callback" => [self::class, "can_manage"],
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/media-folders/assign", [
"methods" => "POST",
"callback" => [self::class, "assign"],
"permission_callback" => [self::class, "can_manage"],
"args" => [
"folderId" => ["type" => "integer", "required" => true],
"attachmentIds" => ["type" => "array", "required" => true],
],
]);
}
public static function can_manage()
{
return current_user_can("upload_files");
}
public static function get_tree($request)
{
return new \WP_REST_Response(
[
"tree" => MediaFolders::get_tree(),
"allMediaCount" => MediaFolders::total_attachment_count(),
"uncategorizedCount" => MediaFolders::uncategorized_count(),
],
200
);
}
public static function create_folder($request)
{
$name = sanitize_text_field((string) $request->get_param("name"));
$parent = (int) ($request->get_param("parent") ?: 0);
$id = MediaFolders::create_folder($name, $parent);
if (!$id) {
return new \WP_Error("aurora_folder_invalid", __("Folder name is required.", "aurora-admin"), ["status" => 400]);
}
return new \WP_REST_Response(["id" => $id], 201);
}
public static function update_folder($request)
{
$id = (int) $request["id"];
$name = $request->get_param("name");
$parent = $request->get_param("parent");
if ($name !== null && $name !== "") {
MediaFolders::rename_folder($id, sanitize_text_field((string) $name));
}
if ($parent !== null) {
$sibling_order = (array) ($request->get_param("siblingOrder") ?: []);
$moved = MediaFolders::move_folder($id, (int) $parent, $sibling_order);
if (!$moved) {
return new \WP_Error(
"aurora_folder_move_failed",
__("Could not move that folder there.", "aurora-admin"),
["status" => 400]
);
}
}
return new \WP_REST_Response(["success" => true], 200);
}
public static function delete_folder($request)
{
$id = (int) $request["id"];
MediaFolders::delete_folder($id);
return new \WP_REST_Response(["success" => true], 200);
}
public static function assign($request)
{
$folder_id = (int) $request->get_param("folderId");
$ids = (array) $request->get_param("attachmentIds");
$ids = array_values(
array_filter($ids, function ($id) {
return current_user_can("edit_post", (int) $id);
})
);
if (empty($ids)) {
return new \WP_Error(
"aurora_folder_assign_failed",
__("There is nothing to move.", "aurora-admin"),
["status" => 400]
);
}
MediaFolders::assign_attachments($folder_id, $ids);
return new \WP_REST_Response(["success" => true], 200);
}
}
+336
View File
@@ -0,0 +1,336 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's Nav Menus replacement (Appearance > Menus).
* Not to be confused with MenuCreator/MenuCreatorData, which manages
* Aurora's own admin sidebar — this is WordPress's front-end navigation
* menu system (wp_nav_menu, theme locations), a completely different
* feature that happens to share a similar name.
*
* Thin wrapper around core's own nav-menu functions (wp_get_nav_menus(),
* wp_update_nav_menu_item(), etc.) — the same functions options-nav-menus.php
* itself calls, just driven by REST instead of a giant form post.
*/
class NavMenusData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function check_permission()
{
return current_user_can("edit_theme_options");
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/nav-menus", [
[
"methods" => "GET",
"callback" => [self::class, "list_menus"],
"permission_callback" => [self::class, "check_permission"],
],
[
"methods" => "POST",
"callback" => [self::class, "create_menu"],
"permission_callback" => [self::class, "check_permission"],
"args" => ["name" => ["type" => "string", "required" => true]],
],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/(?P<id>\d+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_menu"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/(?P<id>\d+)/items", [
[
"methods" => "GET",
"callback" => [self::class, "list_items"],
"permission_callback" => [self::class, "check_permission"],
],
[
"methods" => "POST",
"callback" => [self::class, "add_item"],
"permission_callback" => [self::class, "check_permission"],
],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/items/(?P<id>\d+)", [
[
"methods" => "PUT",
"callback" => [self::class, "update_item"],
"permission_callback" => [self::class, "check_permission"],
],
[
"methods" => "DELETE",
"callback" => [self::class, "delete_item"],
"permission_callback" => [self::class, "check_permission"],
],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/(?P<id>\d+)/reorder", [
"methods" => "POST",
"callback" => [self::class, "reorder_items"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/locations", [
[
"methods" => "GET",
"callback" => [self::class, "list_locations"],
"permission_callback" => [self::class, "check_permission"],
],
[
"methods" => "POST",
"callback" => [self::class, "assign_location"],
"permission_callback" => [self::class, "check_permission"],
],
]);
register_rest_route("aurora-admin/v1", "/nav-menus/pickers", [
"methods" => "GET",
"callback" => [self::class, "pickers"],
"permission_callback" => [self::class, "check_permission"],
]);
}
public static function list_menus()
{
$menus = wp_get_nav_menus();
$locations = get_nav_menu_locations();
$out = [];
foreach ($menus as $menu) {
$assigned = array_keys($locations, $menu->term_id, true);
$out[] = [
"id" => (int) $menu->term_id,
"name" => $menu->name,
"itemCount" => (int) $menu->count,
"locations" => $assigned,
];
}
return new \WP_REST_Response($out, 200);
}
public static function create_menu($request)
{
$name = sanitize_text_field((string) $request->get_param("name"));
if ($name === "") {
return new \WP_Error("aurora_menu_invalid", __("Menu name is required.", "aurora-admin"), ["status" => 400]);
}
$id = wp_create_nav_menu($name);
if (is_wp_error($id)) {
return new \WP_Error("aurora_menu_create_failed", $id->get_error_message(), ["status" => 500]);
}
return new \WP_REST_Response(["id" => (int) $id, "name" => $name, "itemCount" => 0, "locations" => []], 201);
}
public static function delete_menu($request)
{
$id = (int) $request["id"];
$result = wp_delete_nav_menu($id);
if (is_wp_error($result) || !$result) {
return new \WP_Error("aurora_menu_delete_failed", __("Could not delete the menu.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true], 200);
}
public static function list_items($request)
{
$menu_id = (int) $request["id"];
$items = wp_get_nav_menu_items($menu_id);
if ($items === false) {
return new \WP_Error("aurora_menu_invalid", __("Invalid menu.", "aurora-admin"), ["status" => 404]);
}
return new \WP_REST_Response(array_map([self::class, "serialize_item"], $items), 200);
}
public static function add_item($request)
{
$menu_id = (int) $request["id"];
$body = $request->get_json_params();
$args = self::build_item_args($body);
$item_id = wp_update_nav_menu_item($menu_id, 0, $args);
if (is_wp_error($item_id)) {
return new \WP_Error("aurora_menu_item_failed", $item_id->get_error_message(), ["status" => 500]);
}
$item = get_post($item_id);
return new \WP_REST_Response(self::serialize_item(wp_setup_nav_menu_item($item)), 201);
}
public static function update_item($request)
{
$item_id = (int) $request["id"];
$body = $request->get_json_params();
$menu_id = (int) ($body["menuId"] ?? 0);
$existing = get_post($item_id);
if (!$existing || $existing->post_type !== "nav_menu_item") {
return new \WP_Error("aurora_menu_item_invalid", __("Invalid menu item.", "aurora-admin"), ["status" => 404]);
}
$args = self::build_item_args($body, $item_id);
$result = wp_update_nav_menu_item($menu_id ?: self::menu_id_for_item($item_id), $item_id, $args);
if (is_wp_error($result)) {
return new \WP_Error("aurora_menu_item_failed", $result->get_error_message(), ["status" => 500]);
}
return new \WP_REST_Response(self::serialize_item(wp_setup_nav_menu_item(get_post($item_id))), 200);
}
public static function delete_item($request)
{
$item_id = (int) $request["id"];
$result = wp_delete_post($item_id, true);
if (!$result) {
return new \WP_Error("aurora_menu_item_delete_failed", __("Could not delete the menu item.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true], 200);
}
// Bulk reorder / re-parent after a drag or up/down move in the UI —
// clients send the full flat list of {id, parent, order} once, since
// nav menu item order/hierarchy is only meaningful as a whole set.
public static function reorder_items($request)
{
$menu_id = (int) $request["id"];
$body = $request->get_json_params();
$items = is_array($body["items"] ?? null) ? $body["items"] : [];
foreach ($items as $item) {
$item_id = (int) ($item["id"] ?? 0);
if (!$item_id) {
continue;
}
wp_update_nav_menu_item($menu_id, $item_id, [
"menu-item-parent-id" => (int) ($item["parent"] ?? 0),
"menu-item-position" => (int) ($item["order"] ?? 0),
]);
}
return new \WP_REST_Response(["success" => true], 200);
}
public static function list_locations()
{
$registered = get_registered_nav_menus();
$assigned = get_nav_menu_locations();
$out = [];
foreach ($registered as $slug => $description) {
$out[] = [
"slug" => $slug,
"description" => $description,
"menuId" => isset($assigned[$slug]) ? (int) $assigned[$slug] : null,
];
}
return new \WP_REST_Response($out, 200);
}
public static function assign_location($request)
{
$body = $request->get_json_params();
$slug = sanitize_key((string) ($body["location"] ?? ""));
$menu_id = (int) ($body["menuId"] ?? 0);
if ($slug === "" || !array_key_exists($slug, get_registered_nav_menus())) {
return new \WP_Error("aurora_menu_location_invalid", __("Invalid theme location.", "aurora-admin"), ["status" => 400]);
}
$locations = get_theme_mod("nav_menu_locations", []);
if ($menu_id > 0) {
$locations[$slug] = $menu_id;
} else {
unset($locations[$slug]);
}
set_theme_mod("nav_menu_locations", $locations);
return new \WP_REST_Response(["success" => true], 200);
}
// Data the "add items" panel needs: recent pages/posts/categories to
// pick from, plus a custom-link option handled entirely client-side.
public static function pickers()
{
$pages = get_posts(["post_type" => "page", "numberposts" => 50, "orderby" => "title", "order" => "ASC"]);
$posts = get_posts(["post_type" => "post", "numberposts" => 50, "orderby" => "date", "order" => "DESC"]);
$categories = get_categories(["hide_empty" => false, "number" => 50]);
return new \WP_REST_Response([
"pages" => array_map(fn($p) => ["id" => $p->ID, "title" => $p->post_title ?: __("(no title)", "aurora-admin")], $pages),
"posts" => array_map(fn($p) => ["id" => $p->ID, "title" => $p->post_title ?: __("(no title)", "aurora-admin")], $posts),
"categories" => array_map(fn($c) => ["id" => $c->term_id, "title" => $c->name], $categories),
], 200);
}
private static function serialize_item($item)
{
return [
"id" => (int) $item->ID,
"title" => $item->title,
"url" => $item->url,
"type" => $item->type, // "custom" | "post_type" | "taxonomy"
"objectId" => (int) $item->object_id,
"parent" => (int) $item->menu_item_parent,
"order" => (int) $item->menu_order,
];
}
private static function build_item_args($body, $existing_id = 0)
{
$type = sanitize_key((string) ($body["type"] ?? "custom"));
$title = sanitize_text_field((string) ($body["title"] ?? ""));
$args = [
"menu-item-title" => $title,
"menu-item-status" => "publish",
"menu-item-parent-id" => (int) ($body["parent"] ?? 0),
];
if ($existing_id) {
$args["menu-item-position"] = (int) ($body["order"] ?? 0);
}
if ($type === "custom") {
$args["menu-item-type"] = "custom";
$args["menu-item-url"] = esc_url_raw((string) ($body["url"] ?? "#"));
} else {
$object_id = (int) ($body["objectId"] ?? 0);
$args["menu-item-object-id"] = $object_id;
if ($type === "category") {
$args["menu-item-type"] = "taxonomy";
$args["menu-item-object"] = "category";
} else {
$args["menu-item-type"] = "post_type";
$args["menu-item-object"] = $type === "page" ? "page" : "post";
}
if ($title === "") {
$args["menu-item-title"] = get_the_title($object_id);
}
}
return $args;
}
private static function menu_id_for_item($item_id)
{
$menus = wp_get_object_terms($item_id, "nav_menu");
return !empty($menus) && !is_wp_error($menus) ? (int) $menus[0]->term_id : 0;
}
}
+230
View File
@@ -0,0 +1,230 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's custom Pages page — same pattern as
* PostsData.php, adapted for the "page" post type (no categories, but
* shows the parent page instead).
*/
class PagesData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/pages", [
"methods" => "GET",
"callback" => [self::class, "list_pages"],
"permission_callback" => function () {
return current_user_can("edit_pages");
},
"args" => [
"status" => ["type" => "string", "required" => false],
"search" => ["type" => "string", "required" => false],
"page" => ["type" => "integer", "required" => false],
"perPage" => ["type" => "integer", "required" => false],
],
]);
register_rest_route("aurora-admin/v1", "/pages/trash", [
"methods" => "POST",
"callback" => [self::class, "trash_page"],
"permission_callback" => [self::class, "can_edit_param_page"],
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/pages/restore", [
"methods" => "POST",
"callback" => [self::class, "restore_page"],
"permission_callback" => [self::class, "can_edit_param_page"],
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/pages/(?P<id>\d+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_page"],
"permission_callback" => [self::class, "can_delete_route_page"],
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
}
public static function can_edit_param_page($request)
{
$id = (int) $request->get_param("id");
return $id && current_user_can("edit_page", $id);
}
public static function can_delete_route_page($request)
{
$id = (int) $request["id"];
return $id && current_user_can("delete_page", $id);
}
public static function list_pages($request)
{
$status = self::query_status($request->get_param("status"));
$page = max(1, (int) ($request->get_param("page") ?: 1));
$per_page = min(100, max(10, (int) ($request->get_param("perPage") ?: 20)));
$search = sanitize_text_field((string) $request->get_param("search"));
$args = [
"post_type" => "page",
"post_status" => $status,
"posts_per_page" => $per_page,
"paged" => $page,
"orderby" => "title",
"order" => "ASC",
"ignore_sticky_posts" => true,
];
if ($search !== "") {
$args["s"] = $search;
}
$query = new \WP_Query($args);
return new \WP_REST_Response([
"items" => array_map([self::class, "serialize_page"], $query->posts),
"stats" => self::stats(),
"pagination" => [
"page" => $page,
"perPage" => $per_page,
"total" => (int) $query->found_posts,
"totalPages" => max(1, (int) $query->max_num_pages),
],
], 200);
}
public static function trash_page($request)
{
$id = (int) $request->get_param("id");
$post = get_post($id);
if (!$post || $post->post_type !== "page") {
return new \WP_Error("aurora_page_invalid", __("Invalid page.", "aurora-admin"), ["status" => 400]);
}
$result = wp_trash_post($id);
if (!$result) {
return new \WP_Error("aurora_page_trash_failed", __("Could not trash the page.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Page moved to trash.", "aurora-admin")], 200);
}
public static function restore_page($request)
{
$id = (int) $request->get_param("id");
$post = get_post($id);
if (!$post || $post->post_type !== "page") {
return new \WP_Error("aurora_page_invalid", __("Invalid page.", "aurora-admin"), ["status" => 400]);
}
$result = wp_untrash_post($id);
if (!$result) {
return new \WP_Error("aurora_page_restore_failed", __("Could not restore the page.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Page restored.", "aurora-admin")], 200);
}
public static function delete_page($request)
{
$id = (int) $request["id"];
$post = get_post($id);
if (!$post || $post->post_type !== "page") {
return new \WP_Error("aurora_page_invalid", __("Invalid page.", "aurora-admin"), ["status" => 400]);
}
$result = wp_delete_post($id, true);
if (!$result) {
return new \WP_Error("aurora_page_delete_failed", __("Could not delete the page.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Page deleted.", "aurora-admin")], 200);
}
private static function serialize_page($post)
{
$author = get_userdata($post->post_author);
$status = $post->post_status;
$parent_title = $post->post_parent ? get_the_title($post->post_parent) : "";
return [
"id" => (int) $post->ID,
"title" => get_the_title($post) ?: __("(No title)", "aurora-admin"),
"author" => $author ? $author->display_name : __("Unknown", "aurora-admin"),
"parentTitle" => $parent_title,
"commentCount" => (int) $post->comment_count,
"date" => get_the_date("M j, Y", $post),
"dateLabel" =>
$status === "publish"
? __("Published", "aurora-admin")
: ($status === "future"
? __("Scheduled", "aurora-admin")
: __("Last Modified", "aurora-admin")),
"status" => $status,
"statusLabel" => self::status_label($status),
"editUrl" => get_edit_post_link($post->ID, "raw") ?: "",
"viewUrl" => $status === "publish" ? get_permalink($post) : get_preview_post_link($post),
"canDelete" => current_user_can("delete_page", $post->ID),
];
}
private static function stats()
{
$counts = wp_count_posts("page");
return [
"all" => (int) $counts->publish + (int) $counts->draft + (int) $counts->pending + (int) $counts->private + (int) $counts->future,
"publish" => (int) $counts->publish,
"draft" => (int) $counts->draft,
"pending" => (int) $counts->pending,
"trash" => (int) ($counts->trash ?? 0),
];
}
private static function query_status($status)
{
switch ($status) {
case "publish":
case "draft":
case "pending":
case "trash":
return $status;
case "all":
default:
return ["publish", "draft", "pending", "private", "future"];
}
}
private static function status_label($status)
{
switch ($status) {
case "publish":
return __("Published", "aurora-admin");
case "draft":
return __("Draft", "aurora-admin");
case "pending":
return __("Pending", "aurora-admin");
case "future":
return __("Scheduled", "aurora-admin");
case "private":
return __("Private", "aurora-admin");
case "trash":
return __("Trash", "aurora-admin");
default:
return ucfirst($status);
}
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* Core has no REST route for the permalink structure (rewrite rules aren't
* a registered setting), so options-permalink.php's one real field needs its
* own tiny controller — same options_permalink_structure() logic core's own
* settings page runs, called directly instead of going through wp-admin's
* form handler.
*/
class PermalinksData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/permalinks", [
"methods" => "GET",
"callback" => [self::class, "get_structure"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/permalinks", [
"methods" => "POST",
"callback" => [self::class, "update_structure"],
"permission_callback" => [self::class, "check_permission"],
"args" => [
"structure" => ["type" => "string", "required" => true],
],
]);
}
public static function check_permission()
{
return current_user_can("manage_options");
}
public static function get_structure()
{
return new \WP_REST_Response([
"structure" => get_option("permalink_structure", ""),
], 200);
}
public static function update_structure($request)
{
$structure = sanitize_text_field((string) $request->get_param("structure"));
global $wp_rewrite;
$wp_rewrite->set_permalink_structure($structure);
$wp_rewrite->flush_rules();
return new \WP_REST_Response([
"success" => true,
"structure" => get_option("permalink_structure", ""),
], 200);
}
}
+258
View File
@@ -0,0 +1,258 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's custom Installed Plugins page.
*/
class PluginsData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/plugins", [
"methods" => "GET",
"callback" => [self::class, "list_plugins"],
"permission_callback" => function () {
return current_user_can("activate_plugins");
},
]);
register_rest_route("aurora-admin/v1", "/plugins/activate", [
"methods" => "POST",
"callback" => [self::class, "activate_plugin"],
"permission_callback" => function () {
return current_user_can("activate_plugins");
},
"args" => [
"file" => ["type" => "string", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/plugins/deactivate", [
"methods" => "POST",
"callback" => [self::class, "deactivate_plugin"],
"permission_callback" => function () {
return current_user_can("activate_plugins");
},
"args" => [
"file" => ["type" => "string", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/plugins/delete", [
"methods" => "DELETE",
"callback" => [self::class, "delete_plugin"],
"permission_callback" => function () {
return current_user_can("delete_plugins");
},
"args" => [
"file" => ["type" => "string", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/plugins/update", [
"methods" => "POST",
"callback" => [self::class, "update_plugin"],
"permission_callback" => function () {
return current_user_can("update_plugins");
},
"args" => [
"file" => ["type" => "string", "required" => true],
],
]);
}
public static function list_plugins()
{
self::load_plugin_admin_functions();
self::load_update_functions();
$updates = get_site_transient("update_plugins");
if (!is_object($updates) && function_exists("wp_update_plugins")) {
wp_update_plugins();
$updates = get_site_transient("update_plugins");
}
$update_response = is_object($updates) && isset($updates->response) && is_array($updates->response)
? $updates->response
: [];
$items = [];
foreach (get_plugins() as $file => $plugin) {
$update = isset($update_response[$file]) ? $update_response[$file] : null;
$items[] = [
"file" => $file,
"slug" => self::plugin_slug($file),
"name" => isset($plugin["Name"]) ? $plugin["Name"] : self::plugin_slug($file),
"description" => isset($plugin["Description"]) ? wp_strip_all_tags($plugin["Description"]) : "",
"version" => isset($plugin["Version"]) ? $plugin["Version"] : "",
"author" => isset($plugin["Author"]) ? wp_strip_all_tags($plugin["Author"]) : "",
"pluginUri" => isset($plugin["PluginURI"]) ? esc_url_raw($plugin["PluginURI"]) : "",
"authorUri" => isset($plugin["AuthorURI"]) ? esc_url_raw($plugin["AuthorURI"]) : "",
"active" => is_plugin_active($file),
"networkActive" => is_multisite() && is_plugin_active_for_network($file),
"updateAvailable" => $update !== null,
"newVersion" => $update && isset($update->new_version) ? $update->new_version : "",
"canActivate" => current_user_can("activate_plugin", $file),
"canDelete" => current_user_can("delete_plugins") && !is_plugin_active($file),
"canUpdate" => current_user_can("update_plugins") && $update !== null,
];
}
usort($items, function ($a, $b) {
if ($a["active"] !== $b["active"]) {
return $a["active"] ? -1 : 1;
}
return strcasecmp($a["name"], $b["name"]);
});
return new \WP_REST_Response([
"items" => $items,
"stats" => self::stats($items),
"addUrl" => admin_url("plugin-install.php"),
], 200);
}
public static function activate_plugin($request)
{
self::load_plugin_admin_functions();
$file = self::sanitize_file($request->get_param("file"));
$result = activate_plugin($file);
if (is_wp_error($result)) {
return new \WP_Error("aurora_plugin_activate_failed", $result->get_error_message(), ["status" => 500]);
}
self::clean_plugin_cache();
return new \WP_REST_Response(["success" => true, "message" => __("Plugin activated.", "aurora-admin")], 200);
}
public static function deactivate_plugin($request)
{
self::load_plugin_admin_functions();
$file = self::sanitize_file($request->get_param("file"));
deactivate_plugins($file);
self::clean_plugin_cache();
return new \WP_REST_Response([
"success" => true,
"message" => __("Plugin deactivated.", "aurora-admin"),
"redirect" => $file === "aurora-admin/aurora-admin.php" ? admin_url("plugins.php") : "",
], 200);
}
public static function delete_plugin($request)
{
self::load_plugin_admin_functions();
if (!function_exists("delete_plugins")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}
// delete_plugins() calls request_filesystem_credentials() internally
// (WP_Filesystem setup) — that's defined in file.php, which a REST API
// request doesn't load by default the way a normal wp-admin page load
// does. Without this, deleting a plugin fataled with "Call to
// undefined function request_filesystem_credentials()" instead of
// actually deleting anything. update_plugin() below already required
// this for the same reason; delete_plugin() just never got it.
if (!function_exists("request_filesystem_credentials")) {
require_once ABSPATH . "wp-admin/includes/file.php";
}
$file = self::sanitize_file($request->get_param("file"));
if (is_plugin_active($file)) {
return new \WP_Error("aurora_plugin_active_delete", __("Deactivate the plugin before deleting it.", "aurora-admin"), ["status" => 400]);
}
$result = delete_plugins([$file]);
if (is_wp_error($result)) {
return new \WP_Error("aurora_plugin_delete_failed", $result->get_error_message(), ["status" => 500]);
}
self::clean_plugin_cache();
return new \WP_REST_Response(["success" => true, "message" => __("Plugin deleted.", "aurora-admin")], 200);
}
public static function update_plugin($request)
{
self::load_plugin_admin_functions();
self::load_update_functions();
require_once ABSPATH . "wp-admin/includes/class-wp-upgrader.php";
require_once ABSPATH . "wp-admin/includes/file.php";
require_once ABSPATH . "wp-admin/includes/misc.php";
$file = self::sanitize_file($request->get_param("file"));
$skin = new \Automatic_Upgrader_Skin();
$upgrader = new \Plugin_Upgrader($skin);
$result = $upgrader->upgrade($file);
if (is_wp_error($result)) {
return new \WP_Error("aurora_plugin_update_failed", $result->get_error_message(), ["status" => 500]);
}
if (!$result) {
$error = $skin->get_errors();
$message = is_wp_error($error) && $error->has_errors()
? $error->get_error_message()
: __("Plugin update failed.", "aurora-admin");
return new \WP_Error("aurora_plugin_update_failed", $message, ["status" => 500]);
}
self::clean_plugin_cache();
return new \WP_REST_Response(["success" => true, "message" => __("Plugin updated.", "aurora-admin")], 200);
}
private static function load_plugin_admin_functions()
{
if (!function_exists("get_plugins") || !function_exists("is_plugin_active")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}
}
private static function load_update_functions()
{
if (!function_exists("wp_update_plugins")) {
require_once ABSPATH . "wp-admin/includes/update.php";
}
}
private static function sanitize_file($file)
{
return plugin_basename(sanitize_text_field((string) $file));
}
private static function plugin_slug($file)
{
$parts = explode("/", $file);
return $parts[0];
}
private static function clean_plugin_cache()
{
if (function_exists("wp_clean_plugins_cache")) {
wp_clean_plugins_cache(true);
}
wp_cache_delete("plugins", "plugins");
}
private static function stats($items)
{
$stats = ["total" => count($items), "active" => 0, "inactive" => 0, "updates" => 0];
foreach ($items as $item) {
if ($item["active"]) {
$stats["active"]++;
} else {
$stats["inactive"]++;
}
if ($item["updateAvailable"]) {
$stats["updates"]++;
}
}
return $stats;
}
}
+231
View File
@@ -0,0 +1,231 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's custom Posts page — a real replacement for
* the native edit.php list table (unlike Settings > Posts' old "Modern
* posts table" toggle, which described this but was never actually built;
* that toggle was removed once this landed).
*/
class PostsData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/posts", [
"methods" => "GET",
"callback" => [self::class, "list_posts"],
"permission_callback" => function () {
return current_user_can("edit_posts");
},
"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", "/posts/trash", [
"methods" => "POST",
"callback" => [self::class, "trash_post"],
"permission_callback" => [self::class, "can_edit_param_post"],
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/posts/restore", [
"methods" => "POST",
"callback" => [self::class, "restore_post"],
"permission_callback" => [self::class, "can_edit_param_post"],
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
register_rest_route("aurora-admin/v1", "/posts/(?P<id>\d+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_post"],
"permission_callback" => [self::class, "can_delete_route_post"],
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
}
public static function can_edit_param_post($request)
{
$id = (int) $request->get_param("id");
return $id && current_user_can("edit_post", $id);
}
public static function can_delete_route_post($request)
{
$id = (int) $request["id"];
return $id && current_user_can("delete_post", $id);
}
public static function list_posts($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" => "post",
"post_status" => $status,
"posts_per_page" => $per_page,
"paged" => $page,
"orderby" => "date",
"order" => "DESC",
"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_post"], $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_post($request)
{
$id = (int) $request->get_param("id");
$post = get_post($id);
if (!$post || $post->post_type !== "post") {
return new \WP_Error("aurora_post_invalid", __("Invalid post.", "aurora-admin"), ["status" => 400]);
}
$result = wp_trash_post($id);
if (!$result) {
return new \WP_Error("aurora_post_trash_failed", __("Could not trash the post.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Post moved to trash.", "aurora-admin")], 200);
}
public static function restore_post($request)
{
$id = (int) $request->get_param("id");
$post = get_post($id);
if (!$post || $post->post_type !== "post") {
return new \WP_Error("aurora_post_invalid", __("Invalid post.", "aurora-admin"), ["status" => 400]);
}
$result = wp_untrash_post($id);
if (!$result) {
return new \WP_Error("aurora_post_restore_failed", __("Could not restore the post.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Post restored.", "aurora-admin")], 200);
}
public static function delete_post($request)
{
$id = (int) $request["id"];
$post = get_post($id);
if (!$post || $post->post_type !== "post") {
return new \WP_Error("aurora_post_invalid", __("Invalid post.", "aurora-admin"), ["status" => 400]);
}
$result = wp_delete_post($id, true);
if (!$result) {
return new \WP_Error("aurora_post_delete_failed", __("Could not delete the post.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("Post deleted.", "aurora-admin")], 200);
}
private static function serialize_post($post)
{
$author = get_userdata($post->post_author);
$categories = wp_get_post_categories($post->ID, ["fields" => "names"]);
$status = $post->post_status;
return [
"id" => (int) $post->ID,
"title" => get_the_title($post) ?: __("(No title)", "aurora-admin"),
"author" => $author ? $author->display_name : __("Unknown", "aurora-admin"),
"categories" => is_array($categories) ? $categories : [],
"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_post", $post->ID),
];
}
private static function stats()
{
$counts = wp_count_posts("post");
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);
}
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* The one REST route Aurora's Profile reskin needs beyond core's own
* /wp/v2/users/me (which already covers name/contact/bio fields): core
* deliberately excludes password from that endpoint's schema, so changing
* it needs this tiny wrapper around wp_set_password() — the same function
* the native Your Profile screen itself calls.
*/
class ProfileData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/profile/password", [
"methods" => "POST",
"callback" => [self::class, "update_password"],
"permission_callback" => function () {
return is_user_logged_in();
},
"args" => [
"password" => ["type" => "string", "required" => true],
],
]);
}
public static function update_password($request)
{
$password = (string) $request->get_param("password");
if (strlen($password) < 8) {
return new \WP_Error("aurora_profile_password_short", __("Password must be at least 8 characters.", "aurora-admin"), ["status" => 400]);
}
$user_id = get_current_user_id();
wp_set_password($password, $user_id);
// wp_set_password() invalidates every session's auth cookie (it's
// derived in part from the password hash) — including the one this
// very request came in on. Re-issue it immediately, same as core's own
// user-edit.php does when you change your own password, or the next
// request (the REST response reaching the browser) would already be
// logged out.
wp_clear_auth_cookie();
wp_set_auth_cookie($user_id);
return new \WP_REST_Response(["success" => true], 200);
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's Themes replacement (Appearance > Themes'
* browse/activate/delete — not the Customizer, which Shell already
* deliberately excludes from framing/hijacking).
*/
class ThemesData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function check_permission()
{
return current_user_can("switch_themes");
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/themes", [
"methods" => "GET",
"callback" => [self::class, "list_themes"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/themes/activate", [
"methods" => "POST",
"callback" => [self::class, "activate_theme"],
"permission_callback" => [self::class, "check_permission"],
"args" => ["stylesheet" => ["type" => "string", "required" => true]],
]);
register_rest_route("aurora-admin/v1", "/themes/(?P<stylesheet>[^/]+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_theme"],
"permission_callback" => function () {
return current_user_can("delete_themes");
},
]);
}
public static function list_themes()
{
$themes = wp_get_themes();
$active = get_stylesheet();
$out = [];
foreach ($themes as $stylesheet => $theme) {
$out[] = [
"stylesheet" => $stylesheet,
"name" => $theme->get("Name"),
"version" => $theme->get("Version"),
"description" => $theme->get("Description"),
"screenshot" => $theme->get_screenshot() ?: "",
"active" => $stylesheet === $active,
];
}
// Active theme first, alphabetical after.
usort($out, function ($a, $b) {
if ($a["active"] !== $b["active"]) {
return $b["active"] <=> $a["active"];
}
return strcasecmp($a["name"], $b["name"]);
});
return new \WP_REST_Response($out, 200);
}
public static function activate_theme($request)
{
$stylesheet = sanitize_text_field((string) $request->get_param("stylesheet"));
$theme = wp_get_theme($stylesheet);
if (!$theme->exists()) {
return new \WP_Error("aurora_theme_invalid", __("Theme not found.", "aurora-admin"), ["status" => 404]);
}
switch_theme($stylesheet);
return new \WP_REST_Response(["success" => true, "stylesheet" => $stylesheet], 200);
}
public static function delete_theme($request)
{
$stylesheet = sanitize_text_field((string) $request["stylesheet"]);
if ($stylesheet === get_stylesheet()) {
return new \WP_Error("aurora_theme_active", __("Can't delete the active theme.", "aurora-admin"), ["status" => 400]);
}
require_once ABSPATH . "wp-admin/includes/file.php";
require_once ABSPATH . "wp-admin/includes/theme.php";
$result = delete_theme($stylesheet);
if (is_wp_error($result)) {
return new \WP_Error("aurora_theme_delete_failed", $result->get_error_message(), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true], 200);
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for Aurora's custom Users page — same list-table pattern
* as Posts/Pages/Comments/Media. "Add New User" still opens the native
* user-new.php (account creation involves email verification/role
* assignment forms not worth rebuilding, same reasoning as leaving
* media-new.php/post-new.php alone for Media/Posts/Pages).
*
* Deleting a user reassigns their content to whichever admin performs the
* deletion, rather than presenting WP core's "delete or reassign to
* someone else" choice screen — a deliberate simplification, not an
* oversight: wp_delete_user() with a null $reassign deletes all of the
* user's content too, which is much more destructive and surprising as a
* one-click REST action than always reassigning safely.
*/
class UsersData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/users", [
"methods" => "GET",
"callback" => [self::class, "list_users"],
"permission_callback" => function () {
return current_user_can("list_users");
},
"args" => [
"role" => ["type" => "string", "required" => false],
"search" => ["type" => "string", "required" => false],
"page" => ["type" => "integer", "required" => false],
"perPage" => ["type" => "integer", "required" => false],
],
]);
register_rest_route("aurora-admin/v1", "/users/(?P<id>\d+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_user"],
"permission_callback" => function ($request) {
$id = (int) $request["id"];
return $id && current_user_can("delete_user", $id) && $id !== get_current_user_id();
},
"args" => [
"id" => ["type" => "integer", "required" => true],
],
]);
}
public static function list_users($request)
{
$role = sanitize_key((string) $request->get_param("role"));
$page = max(1, (int) ($request->get_param("page") ?: 1));
$per_page = min(100, max(10, (int) ($request->get_param("perPage") ?: 20)));
$search = sanitize_text_field((string) $request->get_param("search"));
$args = [
"number" => $per_page,
"offset" => ($page - 1) * $per_page,
"orderby" => "display_name",
"order" => "ASC",
"count_total" => true,
];
if ($role !== "" && $role !== "all") {
$args["role"] = $role;
}
if ($search !== "") {
$args["search"] = "*{$search}*";
$args["search_columns"] = ["user_login", "user_email", "display_name"];
}
$query = new \WP_User_Query($args);
return new \WP_REST_Response([
"items" => array_map([self::class, "serialize_user"], $query->get_results()),
"stats" => self::stats(),
"pagination" => [
"page" => $page,
"perPage" => $per_page,
"total" => (int) $query->get_total(),
"totalPages" => max(1, (int) ceil($query->get_total() / $per_page)),
],
], 200);
}
public static function delete_user($request)
{
$id = (int) $request["id"];
$user = get_userdata($id);
if (!$user) {
return new \WP_Error("aurora_user_invalid", __("Invalid user.", "aurora-admin"), ["status" => 400]);
}
require_once ABSPATH . "wp-admin/includes/user.php";
$result = wp_delete_user($id, get_current_user_id());
if (!$result) {
return new \WP_Error("aurora_user_delete_failed", __("Could not delete the user.", "aurora-admin"), ["status" => 500]);
}
return new \WP_REST_Response(["success" => true, "message" => __("User deleted; their content was reassigned to you.", "aurora-admin")], 200);
}
private static function serialize_user($user)
{
$roles = array_map("translate_user_role", $user->roles);
return [
"id" => (int) $user->ID,
"username" => $user->user_login,
"displayName" => $user->display_name,
"email" => $user->user_email,
"avatarUrl" => get_avatar_url($user->ID, ["size" => 64]),
"roles" => array_values($roles),
"postCount" => (int) count_user_posts($user->ID, "post"),
"registeredDate" => mysql2date("M j, Y", $user->user_registered),
"editUrl" => get_edit_user_link($user->ID),
"canDelete" => current_user_can("delete_user", $user->ID) && $user->ID !== get_current_user_id(),
];
}
private static function stats()
{
$counts = count_users();
$by_role = [];
foreach ($counts["avail_roles"] as $role => $count) {
$by_role[$role] = (int) $count;
}
return [
"all" => (int) $counts["total_users"],
"byRole" => $by_role,
];
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoints for a deliberately simplified Widgets replacement:
* list registered sidebars (+ the "Inactive Widgets" pseudo-sidebar core
* itself uses to park deactivated instances), move an existing widget
* instance between sidebars, reorder within a sidebar, and remove an
* already-inactive instance entirely.
*
* What's NOT built here, deliberately: adding a brand-new instance of a
* widget type. Each widget class defines its own default instance settings
* and its own settings form — replicating that generically for every
* registered widget (core's + every theme/plugin's) is a full block-widget
* editor's worth of scope on its own, the same "don't rebuild the real
* editor" call made for the block editor and full drag-and-drop widget
* blocks. Users can still create a new instance via the native block
* widgets screen if this toggle is off; this covers reorganizing what's
* already there.
*/
class WidgetsData
{
const INACTIVE_ID = "wp_inactive_widgets";
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function check_permission()
{
return current_user_can("edit_theme_options");
}
/**
* wp_get_sidebars_widgets() is marked for internal core use only, so we
* read the same option it wraps directly. Unlike core's version this
* doesn't auto-assign never-placed widgets to a default sidebar — an
* edge case acceptable to skip given this is a deliberately simplified
* replacement (see class docblock).
*/
private static function get_sidebars_widgets()
{
$assignments = get_option("sidebars_widgets", []);
unset($assignments["array_version"]);
return $assignments;
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/widgets", [
"methods" => "GET",
"callback" => [self::class, "list_widgets"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/widgets/move", [
"methods" => "POST",
"callback" => [self::class, "move_widget"],
"permission_callback" => [self::class, "check_permission"],
]);
register_rest_route("aurora-admin/v1", "/widgets/(?P<id>[^/]+)", [
"methods" => "DELETE",
"callback" => [self::class, "delete_widget"],
"permission_callback" => [self::class, "check_permission"],
]);
}
public static function list_widgets()
{
global $wp_registered_sidebars, $wp_registered_widgets;
$assignments = self::get_sidebars_widgets();
$sidebars = [];
foreach ($wp_registered_sidebars as $id => $sidebar) {
$sidebars[] = [
"id" => $id,
"name" => $sidebar["name"],
"widgets" => self::serialize_widget_list($assignments[$id] ?? []),
];
}
$sidebars[] = [
"id" => self::INACTIVE_ID,
"name" => __("Inactive Widgets", "aurora-admin"),
"widgets" => self::serialize_widget_list($assignments[self::INACTIVE_ID] ?? []),
];
return new \WP_REST_Response(["sidebars" => $sidebars], 200);
}
private static function serialize_widget_list($widget_ids)
{
global $wp_registered_widgets;
$out = [];
foreach (array_values($widget_ids) as $order => $widget_id) {
if (!preg_match('/^(.+)-(\d+)$/', $widget_id, $m)) {
// Some legacy widgets aren't multi-instance and use a bare id.
$id_base = $widget_id;
$number = null;
} else {
$id_base = $m[1];
$number = (int) $m[2];
}
$title = "";
if ($number !== null) {
$instance_option = get_option("widget_{$id_base}");
if (is_array($instance_option) && isset($instance_option[$number]["title"])) {
$title = (string) $instance_option[$number]["title"];
}
}
$out[] = [
"id" => $widget_id,
"idBase" => $id_base,
"name" => isset($wp_registered_widgets[$widget_id]["name"])
? $wp_registered_widgets[$widget_id]["name"]
: ucwords(str_replace(["-", "_"], " ", $id_base)),
"title" => $title,
"order" => $order,
];
}
return $out;
}
public static function move_widget($request)
{
$body = $request->get_json_params();
$widget_id = sanitize_text_field((string) ($body["widgetId"] ?? ""));
$from = sanitize_key((string) ($body["fromSidebar"] ?? ""));
$to = sanitize_key((string) ($body["toSidebar"] ?? ""));
$order = (int) ($body["order"] ?? -1);
if ($widget_id === "" || $to === "") {
return new \WP_Error("aurora_widget_invalid", __("Invalid widget move request.", "aurora-admin"), ["status" => 400]);
}
$assignments = self::get_sidebars_widgets();
if (!isset($assignments[$to])) {
$assignments[$to] = [];
}
if ($from !== "" && isset($assignments[$from])) {
$assignments[$from] = array_values(array_diff($assignments[$from], [$widget_id]));
}
$assignments[$to] = array_values(array_diff($assignments[$to], [$widget_id]));
if ($order < 0 || $order >= count($assignments[$to])) {
$assignments[$to][] = $widget_id;
} else {
array_splice($assignments[$to], $order, 0, [$widget_id]);
}
wp_set_sidebars_widgets($assignments);
return new \WP_REST_Response(["success" => true], 200);
}
public static function delete_widget($request)
{
$widget_id = sanitize_text_field((string) $request["id"]);
$assignments = self::get_sidebars_widgets();
$in_inactive = in_array($widget_id, $assignments[self::INACTIVE_ID] ?? [], true);
if (!$in_inactive) {
return new \WP_Error(
"aurora_widget_delete_requires_inactive",
__("Move the widget to Inactive Widgets before deleting it.", "aurora-admin"),
["status" => 400]
);
}
$assignments[self::INACTIVE_ID] = array_values(array_diff($assignments[self::INACTIVE_ID], [$widget_id]));
wp_set_sidebars_widgets($assignments);
if (preg_match('/^(.+)-(\d+)$/', $widget_id, $m)) {
$id_base = $m[1];
$number = (int) $m[2];
$instances = get_option("widget_{$id_base}");
if (is_array($instances) && isset($instances[$number])) {
unset($instances[$number]);
update_option("widget_{$id_base}", $instances);
}
}
return new \WP_REST_Response(["success" => true], 200);
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace AuroraAdmin\Roles;
defined("ABSPATH") || exit();
/**
* REST CRUD wrapping WordPress's own roles/capabilities API — no custom
* table, this reads and writes the real wp_user_roles option via WP_Roles.
*/
class RolesData
{
// WordPress has no built-in "is this a default role" check — these are
// the five core roles, hard-coded here as delete-protected.
const PROTECTED_ROLES = ["administrator", "editor", "author", "contributor", "subscriber"];
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("manage_options");
};
register_rest_route("aurora-admin/v1", "/roles", [
[
"methods" => "GET",
"callback" => [self::class, "list_roles"],
"permission_callback" => $permission,
],
[
"methods" => "POST",
"callback" => [self::class, "create"],
"permission_callback" => $permission,
"args" => [
"slug" => ["type" => "string", "required" => true],
"displayName" => ["type" => "string", "required" => true],
],
],
]);
register_rest_route("aurora-admin/v1", "/roles/(?P<slug>[a-z0-9_\-]+)", [
[
"methods" => "PUT",
"callback" => [self::class, "update"],
"permission_callback" => $permission,
],
[
"methods" => "DELETE",
"callback" => [self::class, "delete"],
"permission_callback" => $permission,
],
]);
}
public static function list_roles()
{
// get_editable_roles() lives in wp-admin/includes/user.php, which WP
// only auto-loads for actual wp-admin page requests — REST API requests
// (this endpoint) don't get it for free, so it's undefined there
// without this.
if (!function_exists("get_editable_roles")) {
require_once ABSPATH . "wp-admin/includes/user.php";
}
$editable = get_editable_roles();
$all_caps = self::all_known_capabilities();
$out = [];
foreach ($editable as $slug => $role) {
$user_count = count(get_users(["role" => $slug, "fields" => "ID"]));
$out[] = [
"slug" => $slug,
"name" => translate_user_role($role["name"]),
"capabilities" => array_keys(array_filter($role["capabilities"])),
"userCount" => $user_count,
"isDefault" => in_array($slug, self::PROTECTED_ROLES, true),
];
}
return new \WP_REST_Response(["roles" => $out, "allCapabilities" => $all_caps], 200);
}
public static function create($request)
{
$slug = sanitize_key($request->get_param("slug"));
$display_name = sanitize_text_field($request->get_param("displayName"));
if (!$slug || !$display_name) {
return new \WP_Error("invalid_role", "A role slug and name are required", ["status" => 400]);
}
if (get_role($slug)) {
return new \WP_Error("role_exists", "A role with this slug already exists", ["status" => 409]);
}
$caps = self::sanitize_caps($request->get_param("capabilities"));
add_role($slug, $display_name, array_fill_keys($caps, true));
return new \WP_REST_Response(["slug" => $slug, "name" => $display_name, "capabilities" => $caps], 200);
}
public static function update($request)
{
$slug = $request->get_param("slug");
$role = get_role($slug);
if (!$role) {
return new \WP_Error("not_found", "Role not found", ["status" => 404]);
}
if ($request->get_param("capabilities") !== null) {
$desired = self::sanitize_caps($request->get_param("capabilities"));
foreach (array_keys($role->capabilities) as $existing_cap) {
if (!in_array($existing_cap, $desired, true)) {
$role->remove_cap($existing_cap);
}
}
foreach ($desired as $cap) {
$role->add_cap($cap);
}
}
if ($request->get_param("displayName")) {
global $wp_roles;
$wp_roles->roles[$slug]["name"] = sanitize_text_field($request->get_param("displayName"));
$wp_roles->role_names[$slug] = $wp_roles->roles[$slug]["name"];
update_option($wp_roles->role_key, $wp_roles->roles);
}
return new \WP_REST_Response(["updated" => true], 200);
}
public static function delete($request)
{
$slug = $request->get_param("slug");
if (in_array($slug, self::PROTECTED_ROLES, true)) {
return new \WP_Error("protected_role", "Default WordPress roles can't be deleted", ["status" => 403]);
}
if (!get_role($slug)) {
return new \WP_Error("not_found", "Role not found", ["status" => 404]);
}
remove_role($slug);
return new \WP_REST_Response(["deleted" => true], 200);
}
private static function sanitize_caps($caps)
{
if (!is_array($caps)) {
return [];
}
return array_values(array_unique(array_map("sanitize_key", $caps)));
}
/**
* WordPress has no single "list all capabilities" API — build one from
* every capability already assigned across existing roles, which is what
* every native role-editor plugin does in practice.
*/
private static function all_known_capabilities()
{
$caps = [];
foreach (wp_roles()->roles as $role) {
foreach (array_keys($role["capabilities"]) as $cap) {
$caps[$cap] = true;
}
}
$caps = array_keys($caps);
sort($caps);
return $caps;
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace AuroraAdmin\Security;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Security + performance hardening toggles (Settings > Security & performance).
*
* Each feature is gated on its own setting and wired here by adding/removing
* core hooks — no output is buffered or rewritten, so anything left off
* behaves exactly like stock WordPress. Settings are read once at construct
* time (plugins_loaded), the same pattern the rest of the plugin uses.
*/
class Security
{
public function __construct()
{
if (Settings::get("disable_xmlrpc")) {
self::disable_xmlrpc();
}
if (Settings::get("disable_comments")) {
self::disable_comments();
}
if (Settings::get("remove_head_junk")) {
self::clean_head();
}
if (Settings::get("disable_emojis")) {
self::disable_emojis();
}
if (Settings::get("disable_oembed")) {
self::disable_oembed();
}
$heartbeat = Settings::get("heartbeat_mode", "default");
if ($heartbeat === "disabled") {
add_action("init", function () {
wp_deregister_script("heartbeat");
}, 1);
} elseif ($heartbeat === "slow") {
add_filter("heartbeat_settings", function ($settings) {
$settings["interval"] = 60;
return $settings;
});
}
}
private static function disable_xmlrpc()
{
add_filter("xmlrpc_enabled", "__return_false");
// Drop the X-Pingback header that advertises the endpoint.
add_filter("wp_headers", function ($headers) {
unset($headers["X-Pingback"]);
return $headers;
});
add_filter("pings_open", "__return_false", 20);
}
private static function disable_comments()
{
// Front end: closed everywhere, and existing comments never render.
add_filter("comments_open", "__return_false", 20);
add_filter("pings_open", "__return_false", 20);
add_filter("comments_array", "__return_empty_array", 20);
// Strip comment support from every post type.
add_action("init", function () {
foreach (get_post_types() as $post_type) {
if (post_type_supports($post_type, "comments")) {
remove_post_type_support($post_type, "comments");
remove_post_type_support($post_type, "trackbacks");
}
}
}, 100);
// Admin: remove the Comments menu, the dashboard widget, and the toolbar
// bubble so there's no dangling entry point.
add_action("admin_menu", function () {
remove_menu_page("edit-comments.php");
});
add_action("wp_dashboard_setup", function () {
remove_meta_box("dashboard_recent_comments", "dashboard", "normal");
});
add_action("admin_bar_menu", function ($bar) {
$bar->remove_node("comments");
}, 999);
}
private static function clean_head()
{
remove_action("wp_head", "rsd_link");
remove_action("wp_head", "wlwmanifest_link");
remove_action("wp_head", "wp_generator");
remove_action("wp_head", "wp_shortlink_wp_head");
add_filter("the_generator", "__return_empty_string");
}
private static function disable_emojis()
{
remove_action("wp_head", "print_emoji_detection_script", 7);
remove_action("admin_print_scripts", "print_emoji_detection_script");
remove_action("wp_print_styles", "print_emoji_styles");
remove_action("admin_print_styles", "print_emoji_styles");
remove_filter("the_content_feed", "wp_staticize_emoji");
remove_filter("comment_text_rss", "wp_staticize_emoji");
remove_filter("wp_mail", "wp_staticize_emoji_for_email");
add_filter("tiny_mce_plugins", function ($plugins) {
return is_array($plugins) ? array_diff($plugins, ["wpemoji"]) : [];
});
// Drop the s.w.org DNS-prefetch the emoji script adds.
add_filter("wp_resource_hints", function ($hints, $relation_type) {
if ($relation_type === "dns-prefetch") {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- "emoji_svg_url" is a WordPress core filter (wp-includes/formatting.php), not a hook this plugin owns; it is applied here only to read core's own emoji SVG base URL so the matching s.w.org dns-prefetch hint can be identified and removed.
$emoji_svg = apply_filters("emoji_svg_url", "https://s.w.org/images/core/emoji/");
$hints = array_filter($hints, function ($h) use ($emoji_svg) {
return strpos((string) $h, $emoji_svg) === false && strpos((string) $h, "s.w.org") === false;
});
}
return $hints;
}, 10, 2);
}
private static function disable_oembed()
{
// Core registers these on wp_head at a non-default priority (the
// discovery links sit at priority 4, not 10), so match whatever priority
// they're actually hooked at rather than assuming the default.
self::remove_wp_head_action("wp_oembed_add_discovery_links");
self::remove_wp_head_action("wp_oembed_add_host_js");
add_filter("embed_oembed_discover", "__return_false");
remove_action("rest_api_init", "wp_oembed_register_route");
// Stop the front-end wp-embed.min.js from loading.
add_action("wp_footer", function () {
wp_dequeue_script("wp-embed");
});
}
/**
* Remove a wp_head callback at whatever priority (or priorities) it's
* registered at — core hooks the oEmbed discovery links at a non-default
* priority, and it can end up registered at more than one, so loop until
* none remain (bounded, so a stubborn callback can't spin forever).
*/
private static function remove_wp_head_action($function)
{
$guard = 0;
while (($priority = has_action("wp_head", $function)) !== false && $guard++ < 20) {
remove_action("wp_head", $function, $priority);
}
}
}
+255
View File
@@ -0,0 +1,255 @@
<?php
namespace AuroraAdmin\Shell;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Turns WordPress's global $menu / $submenu structures into a clean,
* JSON-friendly array the Vue sidebar can render.
*
* This is the deliberate, readable replacement for the original plugin's
* reverse-engineered menu reconstruction. It reads the same source of
* truth WordPress itself uses to paint the admin menu, so third-party
* plugin menu items appear automatically.
*/
class MenuSerializer
{
/**
* @return array List of top-level menu items, each:
* [ id, title, url, icon, count, separator(bool), submenu[] ]
*/
public static function serialize()
{
global $menu, $submenu;
if (!is_array($menu)) {
return [];
}
$items = [];
foreach ($menu as $item) {
// $item indexes (WordPress core):
// 0 => menu title (may contain HTML), 1 => capability,
// 2 => menu slug, 4 => classes, 6 => icon
$raw_title = isset($item[0]) ? $item[0] : "";
$capability = isset($item[1]) ? $item[1] : "";
$slug = isset($item[2]) ? $item[2] : "";
$classes = isset($item[4]) ? $item[4] : "";
$icon = isset($item[6]) ? $item[6] : "";
// Separators have a wp-menu-separator class and no real title.
if (strpos($classes, "wp-menu-separator") !== false) {
$items[] = ["separator" => true];
continue;
}
if (empty($capability) || !current_user_can($capability)) {
continue;
}
// Aurora's own top-level page is represented in the sidebar's
// dedicated branded nav section (see Shell::print_mount()'s
// "auroraNav"), not in this WP-native-menu list — skip it here so it
// isn't shown twice.
if ($slug === "aurora-admin") {
continue;
}
$title_data = self::clean_title($raw_title);
$entry = [
"id" => $slug,
"title" => $title_data["title"],
"url" => self::resolve_url($slug),
"icon" => self::resolve_icon($icon),
"count" => $title_data["count"],
"separator" => false,
"submenu" => [],
];
if (isset($submenu[$slug]) && is_array($submenu[$slug])) {
foreach ($submenu[$slug] as $sub) {
$sub_cap = isset($sub[1]) ? $sub[1] : "";
if (empty($sub_cap) || !current_user_can($sub_cap)) {
continue;
}
$sub_title = self::clean_title(isset($sub[0]) ? $sub[0] : "");
$sub_slug = isset($sub[2]) ? $sub[2] : "";
// Aurora's own sub-apps (Menu Creator, Comments, etc.) are
// presented in the sidebar's dedicated Aurora nav section
// instead — skip them here so they aren't duplicated. Most are
// parented under "aurora-admin" itself, already skipped above
// wholesale, but "aurora-admin-plugins" is parented under the
// real "plugins.php" menu and needs this explicit check.
if (strpos($sub_slug, "aurora-admin-") === 0) {
continue;
}
$entry["submenu"][] = [
"id" => $sub_slug,
"title" => $sub_title["title"],
"url" => self::resolve_url($sub_slug, $slug),
"count" => $sub_title["count"],
];
}
}
$items[] = $entry;
}
// Apply the Menu Creator's saved order (if any) for the current user.
// Configured items come first in their saved order; anything not in the
// config — separators, and newly installed plugins' menu items — keeps
// its original position after them, so new menu items never disappear.
$order = \AuroraAdmin\MenuCreator\MenuCreator::ordered_slugs();
if ($order) {
$rank = array_flip($order);
$tail = count($order);
$decorated = [];
foreach ($items as $i => $entry) {
$slug = isset($entry["id"]) ? $entry["id"] : "";
$r = $slug !== "" && isset($rank[$slug]) ? $rank[$slug] : $tail + $i;
$decorated[] = ["r" => $r, "i" => $i, "entry" => $entry];
}
usort($decorated, function ($a, $b) {
return $a["r"] <=> $b["r"] ?: $a["i"] <=> $b["i"];
});
$items = array_map(function ($d) {
return $d["entry"];
}, $decorated);
}
return $items;
}
/**
* Raw top-level menu items ({native_id,title,icon}) for the Menu Creator
* editor. Unlike serialize() this must be captured BEFORE MenuCreator hides
* anything (so hidden items still appear and can be toggled back on) — and
* it can't run over REST, where the admin $menu isn't built. See
* MenuCreator::capture_native().
*/
public static function top_level_items()
{
global $menu;
if (!is_array($menu)) {
return [];
}
$items = [];
foreach ($menu as $item) {
$classes = isset($item[4]) ? $item[4] : "";
if (strpos($classes, "wp-menu-separator") !== false) {
continue;
}
$cap = isset($item[1]) ? $item[1] : "";
$slug = isset($item[2]) ? $item[2] : "";
if ($slug === "" || $slug === "aurora-admin" || empty($cap) || !current_user_can($cap)) {
continue;
}
$title = self::clean_title(isset($item[0]) ? $item[0] : "");
$items[] = [
"native_id" => $slug,
"title" => $title["title"],
"icon" => self::resolve_icon(isset($item[6]) ? $item[6] : ""),
];
}
return $items;
}
/**
* Strips the markup WordPress packs into menu titles (update/count
* bubbles) and pulls the numeric count out separately.
*
* @return array{title:string,count:int|null}
*/
private static function clean_title($raw)
{
$count = null;
if (preg_match('/<span[^>]*>(\d+)/', $raw, $matches)) {
$count = (int) $matches[1];
}
// WordPress menu titles are "Label <span ...>count bubble</span>" — the
// human label is always the plain text before the first tag, so take
// that rather than stripping tags (which would leave the count digits
// dangling in the title).
$label = $raw;
$first_tag = strpos($raw, "<");
if ($first_tag !== false) {
$label = substr($raw, 0, $first_tag);
}
$title = trim(wp_strip_all_tags($label));
return ["title" => $title, "count" => $count];
}
/**
* Builds the admin URL for a menu slug, mirroring how WordPress decides
* between a direct file (foo.php) and a registered page (admin.php?page=).
*/
private static function resolve_url($slug, $parent_slug = "")
{
if (empty($slug)) {
return "";
}
// Plugins is the one screen still on the redirect mechanism (plugins.php
// can't be cleanly output-buffered — see SubApps::redirect_native_pages()
// for why) — its sidebar link still needs rewriting to the Aurora URL.
// Posts/Pages/Media/Users/Comments hijack their native URLs in place
// instead (see HijackedNativePages), so their native sidebar links
// already point at the right place with no rewrite needed.
if ($slug === "plugins.php" && Settings::get("modern_plugins", true)) {
return admin_url("plugins.php?page=aurora-admin-plugins");
}
// Already an absolute URL.
if (preg_match('#^https?://#', $slug)) {
return $slug;
}
// A slug pointing at a real admin file, possibly with a query string.
if (strpos($slug, ".php") !== false) {
return admin_url($slug);
}
// Submenu of a file-based parent (e.g. edit.php) — the child slug is a
// file too, handled above; otherwise it's a page registered under that
// parent's screen, reached via the parent file with ?page=.
if (!empty($parent_slug) && strpos($parent_slug, ".php") !== false) {
return admin_url(
$parent_slug . (strpos($parent_slug, "?") !== false ? "&" : "?") . "page=" . $slug
);
}
return admin_url("admin.php?page=" . $slug);
}
/**
* Normalizes the menu icon into something the Vue side can render:
* a dashicon class name, an image URL, or null for "use a default".
*/
private static function resolve_icon($icon)
{
if (empty($icon) || $icon === "none" || $icon === "div") {
return null;
}
if (strpos($icon, "dashicons-") === 0) {
return ["type" => "dashicon", "value" => $icon];
}
if (strpos($icon, "data:image") === 0 || preg_match('#^https?://#', $icon)) {
return ["type" => "image", "value" => $icon];
}
return null;
}
}
+276
View File
@@ -0,0 +1,276 @@
<?php
namespace AuroraAdmin\Shell;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
use AuroraAdmin\Pages\ScreenHijack;
defined("ABSPATH") || exit();
/**
* The site-wide immersive admin shell.
*
* Renders a custom Vue sidebar + toolbar on every admin screen and frames
* WordPress's own content area inside a styled container. Crucially it does
* NOT relocate or re-implement WP's content — native pages render normally
* inside the frame, so they keep working without any reskinning.
*
* Native chrome (admin menu, toolbar, footer) is hidden via a small set of
* explicit ID selectors gated on an html.aurora-loading class. A failsafe
* reveals native admin again if the Vue app never signals ready, so a
* broken build can never lock the user out of wp-admin.
*/
class Shell
{
public function __construct()
{
// Print the chrome-hiding CSS + loading class as early as possible so
// the native menu/toolbar never flash before the shell takes over.
add_action("admin_head", [self::class, "print_early_styles"], 0);
add_action("admin_enqueue_scripts", [self::class, "enqueue"]);
// Mount point + serialized data, output at the end of the admin body.
add_action("admin_footer", [self::class, "print_mount"]);
}
/**
* Whether the shell should run on the current request. Skipped on the
* Customizer (its own full-screen live-preview UI with a fundamentally
* different structure Aurora doesn't attempt to frame). The Site Editor
* used to be excluded here too — it's now handled instead: it turns out
* to be built on the same @wordpress/interface package as the block
* editor (post.php/post-new.php both get body.block-editor-page too),
* so shell-frame.css/windowChrome.js's existing block-editor handling
* covers it once #site-editor (its actual position:fixed, full-viewport
* root — unlike the post editor, its .interface-interface-skeleton is
* merely position:relative, nested inside #site-editor) is framed the
* same way .interface-interface-skeleton is everywhere else.
*/
private static function is_enabled()
{
if (!is_admin()) {
return false;
}
$screen = function_exists("get_current_screen") ? get_current_screen() : null;
if ($screen && in_array($screen->id, ["customize"], true)) {
return false;
}
// Settings > General > "Disable theme": roles the shell is turned off
// for — those users get the native WordPress admin.
$disabled_roles = Settings::get("disable_theme", []);
if (is_array($disabled_roles) && $disabled_roles) {
$user = wp_get_current_user();
if ($user && $user->exists() && array_intersect((array) $user->roles, $disabled_roles)) {
return false;
}
}
return apply_filters("aurora_admin_shell_enabled", true);
}
/**
* Whether the current screen is one of Aurora's own pages (vs a native
* WordPress admin page). Used to decide whether dark mode themes the
* content area or leaves it in WP's native light theme.
*/
private static function is_own_page()
{
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, used only to decide light/dark chrome theming for the current screen; no state change.
$page = isset($_GET["page"]) ? sanitize_text_field(wp_unslash($_GET["page"])) : "";
if (strpos($page, "aurora-admin") === 0) {
return true;
}
// The dashboard (index.php) is taken over by Aurora's own dashboard, so
// treat it as one of our pages: dark frame, and the native-dark theme
// sheet is skipped there.
if (isset($GLOBALS["pagenow"]) && $GLOBALS["pagenow"] === "index.php") {
return true;
}
// ScreenHijack-based pages (Posts, Pages, Media, Users, Comments) keep
// their native URL — no ?page=aurora-admin to key off — so they'd
// otherwise be misdetected as native pages and get native-dark.css's
// own-page exclusion skipped, letting its generic `a` color rules leak
// into the real Aurora UI these screens render.
return ScreenHijack::is_hijack_active();
}
public static function print_early_styles()
{
if (!self::is_enabled()) {
return;
}
$native_class = self::is_own_page() ? "" : "aurora-native-page";
?>
<script>
// Synchronous: marks the document active before first paint so the
// hide-native-chrome CSS below applies with no flash. This class
// persists for the whole session (chrome stays hidden); aurora-ready
// is added later by the Vue shell only to reveal the framed content.
document.documentElement.classList.add("aurora-active");
<?php if ($native_class): ?>
document.documentElement.classList.add("<?php echo esc_js($native_class); ?>");
<?php endif; ?>
// Failsafe: if the Vue shell never signals ready, drop aurora-active
// so native admin returns and a failed build can't lock the user out.
window.setTimeout(function () {
if (!document.documentElement.classList.contains("aurora-ready")) {
document.documentElement.classList.remove("aurora-active");
}
}, 8000);
</script>
<style id="aurora-admin-early">
/* Hide native chrome for the whole active session (not just loading),
so it stays hidden once the shell is ready. */
html.aurora-active #adminmenumain,
html.aurora-active #wpadminbar,
html.aurora-active #wpfooter {
display: none !important;
}
html.aurora-active.wp-toolbar {
padding-top: 0 !important;
}
/* Keep the framed content hidden only until the shell mounts, to avoid
a flash of unframed/unpositioned content. No !important and no
margin rules here, so the framing rules in shell-frame.css win
cleanly once aurora-ready is set. */
html.aurora-active:not(.aurora-ready) #wpbody {
opacity: 0;
}
html.aurora-ready #wpbody {
opacity: 1;
transition: opacity 0.15s ease;
}
</style>
<?php
}
public static function enqueue()
{
if (!self::is_enabled()) {
return;
}
// The sidebar renders WordPress's own dashicon menu glyphs, so make
// sure the dashicons font is present (core registers it under this
// handle; it's normally enqueued already, this just guarantees it).
wp_enqueue_style("dashicons");
Assets::enqueue("aurora-admin-shell", "entries/shell.js");
// Full dark theme for native WP pages. Loaded only on native pages
// (Aurora's own pages style themselves) and inert unless dark mode is
// active (every rule is gated on html.aurora-dark in the file itself).
if (!self::is_own_page()) {
// Version static assets by file mtime so edits bust the browser cache
// even when the plugin version hasn't changed.
$native_dark = AURORA_ADMIN_PATH . "admin/assets/native-dark.css";
$css_ver = file_exists($native_dark) ? (string) filemtime($native_dark) : AURORA_ADMIN_VERSION;
wp_enqueue_style(
"aurora-admin-native-dark",
AURORA_ADMIN_URL . "admin/assets/native-dark.css",
["aurora-admin-shell-0"],
$css_ver
);
// Progressive enhancement (boxed sections on the Updates page, etc.)
$native_js = AURORA_ADMIN_PATH . "admin/assets/native-enhance.js";
$js_ver = file_exists($native_js) ? (string) filemtime($native_js) : AURORA_ADMIN_VERSION;
wp_enqueue_script(
"aurora-admin-native-enhance",
AURORA_ADMIN_URL . "admin/assets/native-enhance.js",
[],
$js_ver,
true
);
}
}
public static function print_mount()
{
if (!self::is_enabled()) {
return;
}
$current_user = wp_get_current_user();
// WP core sets this global on pages that are logically "under" a menu
// section but aren't themselves a listed submenu item — e.g.
// theme-install.php sets $parent_file = 'themes.php' (see wp-admin/
// theme-install.php), media-new.php sets 'upload.php', user-new.php
// sets 'users.php'. Native admin uses it to keep the *parent* menu
// section highlighted/expanded while on these "hidden child" screens;
// the sidebar needs the same signal, since MenuSerializer::serialize()
// only scrapes $submenu (which these pages deliberately aren't part
// of — WP's own native left menu doesn't list them as links either).
global $parent_file;
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"adminUrl" => esc_url_raw(admin_url()),
"siteUrl" => esc_url_raw(home_url("/")),
"logoutUrl" => esc_url_raw(wp_logout_url()),
"userName" => $current_user->display_name,
"userEmail" => $current_user->user_email,
"avatar" => get_avatar_url($current_user->ID, ["size" => 64]),
"currentUrl" => esc_url_raw(self::current_admin_url()),
"activeParentFile" => is_string($parent_file) ? $parent_file : "",
// Secrets stripped — this is serialized for every admin user.
"settings" => Settings::get_safe(),
"menu" => MenuSerializer::serialize(),
"auroraNav" => self::aurora_nav(),
];
?>
<div
id="aurora-admin-shell-root"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
private static function current_admin_url()
{
$request = isset($_SERVER["REQUEST_URI"]) ? sanitize_text_field(wp_unslash($_SERVER["REQUEST_URI"])) : "";
return home_url($request);
}
/**
* Aurora's own branded nav section, rendered in a dedicated block at the
* bottom of the sidebar (separate from the WP-native menu list above it).
* A small static list rather than menu-scraped, since these are our own
* pages, not WordPress's.
*/
private static function aurora_nav()
{
$items = [
["id" => "aurora-admin", "title" => __("Settings", "aurora-admin"), "icon" => "dashicons-admin-generic"],
["id" => "aurora-admin-modules", "title" => __("Modules", "aurora-admin"), "icon" => "dashicons-screenoptions"],
["id" => "aurora-admin-menu-creator", "title" => __("Menu Creator", "aurora-admin"), "icon" => "dashicons-menu-alt"],
["id" => "aurora-admin-notices", "title" => __("Admin Notices", "aurora-admin"), "icon" => "dashicons-megaphone"],
["id" => "aurora-admin-roles", "title" => __("Role Editor", "aurora-admin"), "icon" => "dashicons-groups"],
["id" => "aurora-admin-activity-log", "title" => __("Activity Log", "aurora-admin"), "icon" => "dashicons-clock"],
["id" => "aurora-admin-bug-report", "title" => __("Report a Bug", "aurora-admin"), "icon" => "dashicons-warning"],
];
$nav = [];
foreach ($items as $item) {
if (!current_user_can("manage_options")) {
continue;
}
$nav[] = [
"id" => $item["id"],
"title" => $item["title"],
"url" => esc_url_raw(admin_url("admin.php?page=" . $item["id"])),
"icon" => ["type" => "dashicon", "value" => $item["icon"]],
];
}
return $nav;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace AuroraAdmin\Utility;
defined("ABSPATH") || exit();
/**
* Reads the Vite build manifest and enqueues a single entry's JS (as an
* ES module) and CSS. Kept deliberately simple — one entry in, manifest
* lookup, two enqueue calls out. No scoping/theme tricks live here.
*/
class Assets
{
private static $manifest = null;
private static function get_manifest()
{
if (self::$manifest !== null) {
return self::$manifest;
}
$path = AURORA_ADMIN_PATH . "app/dist/.vite/manifest.json";
if (!file_exists($path)) {
self::$manifest = [];
return self::$manifest;
}
$contents = file_get_contents($path);
$decoded = json_decode($contents, true);
self::$manifest = is_array($decoded) ? $decoded : [];
return self::$manifest;
}
/**
* Enqueues a built entry's JS as a module script plus its CSS, if any.
*
* @param string $handle WP script/style handle prefix, e.g. "aurora-admin-dashboard"
* @param string $entry Manifest key, e.g. "src/entries/dashboard.js"
*/
public static function enqueue($handle, $entry)
{
$manifest = self::get_manifest();
if (!isset($manifest[$entry])) {
return;
}
$entry_data = $manifest[$entry];
$base_url = AURORA_ADMIN_URL . "app/dist/";
if (isset($entry_data["file"])) {
wp_enqueue_script($handle, $base_url . $entry_data["file"], [], AURORA_ADMIN_VERSION, true);
add_filter("script_loader_tag", function ($tag, $tag_handle) use ($handle) {
if ($tag_handle !== $handle) {
return $tag;
}
return str_replace(" src", " type=\"module\" src", $tag);
}, 10, 2);
}
// Vite splits shared code (e.g. the AppShell used by every page) into
// its own chunk with its own CSS file. The entry's own "css" key only
// lists CSS pulled in directly by that entry — CSS from anything it
// imports has to be collected by walking "imports" too, otherwise
// shared-component styles silently never get linked on the page.
$css_files = [];
self::collect_css($manifest, $entry, $css_files);
$index = 0;
foreach (array_unique($css_files) as $css_file) {
wp_enqueue_style($handle . "-" . $index, $base_url . $css_file, [], AURORA_ADMIN_VERSION);
$index++;
}
}
private static function collect_css($manifest, $entry, &$css_files)
{
if (!isset($manifest[$entry])) {
return;
}
$entry_data = $manifest[$entry];
if (!empty($entry_data["css"]) && is_array($entry_data["css"])) {
foreach ($entry_data["css"] as $css_file) {
$css_files[] = $css_file;
}
}
if (!empty($entry_data["imports"]) && is_array($entry_data["imports"])) {
foreach ($entry_data["imports"] as $imported_key) {
self::collect_css($manifest, $imported_key, $css_files);
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace AuroraAdmin\WhiteLabel;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Applies the White Label settings:
* - admin_favicon: replaces the favicon across wp-admin
* - plugin_name: renames the plugin in the plugins list and the admin menu
* - text_replacements: find/replace pairs applied to translated admin text
*/
class WhiteLabel
{
/** @var array<string,string>|null Cached find=>replace map for gettext. */
private static $replacements = null;
public function __construct()
{
add_action("admin_head", [self::class, "favicon"]);
add_filter("all_plugins", [self::class, "rename_plugin"]);
add_action("admin_menu", [self::class, "rename_menu"], 999);
add_filter("gettext", [self::class, "replace_text"], 20, 3);
}
public static function favicon()
{
$url = Settings::get("admin_favicon", "");
if (!$url) {
return;
}
printf('<link rel="icon" href="%s" />' . "\n", esc_url($url));
}
public static function rename_plugin($plugins)
{
$name = trim((string) Settings::get("plugin_name", ""));
if ($name === "") {
return $plugins;
}
$basename = "aurora-admin/aurora-admin.php";
if (isset($plugins[$basename])) {
$plugins[$basename]["Name"] = $name;
$plugins[$basename]["Title"] = $name;
}
return $plugins;
}
public static function rename_menu()
{
$name = trim((string) Settings::get("plugin_name", ""));
if ($name === "") {
return;
}
global $menu;
if (!is_array($menu)) {
return;
}
foreach ($menu as $index => $item) {
if (isset($item[2]) && $item[2] === "aurora-admin") {
$menu[$index][0] = $name;
break;
}
}
}
public static function replace_text($translation, $text, $domain)
{
// Front end and login screens stay untouched.
if (!is_admin()) {
return $translation;
}
if (self::$replacements === null) {
self::$replacements = [];
$pairs = Settings::get("text_replacements", []);
if (is_array($pairs)) {
foreach ($pairs as $pair) {
if (
is_array($pair) &&
isset($pair["find"], $pair["replace"]) &&
$pair["find"] !== ""
) {
self::$replacements[$pair["find"]] = (string) $pair["replace"];
}
}
}
}
if (!self::$replacements) {
return $translation;
}
return strtr($translation, self::$replacements);
}
}
+1363
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "aurora-admin-app",
"version": "3.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"vue": "^3.5.26"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7"
}
}
+60
View File
@@ -0,0 +1,60 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
// 0-100. When null, renders an indeterminate/pulsing bar instead of a
// determinate fill — used for phases with no cheap way to compute a
// percentage (e.g. "building the file list" before zipping starts).
percent: { type: Number, default: null },
label: { type: String, default: '' },
});
const clamped = computed(() => (props.percent === null ? null : Math.max(0, Math.min(100, props.percent))));
</script>
<template>
<div class="progress-bar">
<div v-if="label" class="progress-bar__label">{{ label }}</div>
<div class="progress-bar__track" :class="{ 'progress-bar__track--indeterminate': clamped === null }">
<div
v-if="clamped !== null"
class="progress-bar__fill"
:style="{ width: clamped + '%' }"
/>
</div>
</div>
</template>
<style scoped>
.progress-bar__label {
font-size: 0.8125rem;
color: var(--aurora-text-muted);
margin-bottom: 6px;
}
.progress-bar__track {
height: 8px;
border-radius: 999px;
background: var(--aurora-bg-subtle);
overflow: hidden;
position: relative;
}
.progress-bar__fill {
height: 100%;
border-radius: 999px;
background: var(--aurora-accent);
transition: width 0.3s ease;
}
.progress-bar__track--indeterminate::before {
content: '';
position: absolute;
top: 0; bottom: 0;
width: 40%;
border-radius: 999px;
background: var(--aurora-accent);
animation: progress-bar-indeterminate 1.2s ease-in-out infinite;
}
@keyframes progress-bar-indeterminate {
0% { left: -40%; }
100% { left: 100%; }
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<script setup>
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
label: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
});
defineEmits(['update:modelValue']);
</script>
<template>
<label class="toggle" :class="{ 'toggle--disabled': disabled }">
<span v-if="label" class="toggle__label">{{ label }}</span>
<button
type="button"
class="toggle__switch"
:class="{ 'toggle__switch--on': modelValue }"
role="switch"
:aria-checked="modelValue"
:disabled="disabled"
@click="!disabled && $emit('update:modelValue', !modelValue)"
>
<span class="toggle__thumb" />
</button>
</label>
</template>
<style scoped>
.toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
cursor: pointer;
}
.toggle__label {
font-size: 0.875rem;
}
.toggle__switch {
position: relative;
width: 2.5rem;
height: 1.4rem;
border-radius: 999px;
border: none;
background: var(--aurora-border);
cursor: pointer;
padding: 0;
transition: background-color 0.15s ease;
flex-shrink: 0;
}
.toggle__switch--on {
background: var(--aurora-accent);
}
.toggle--disabled { cursor: default; opacity: 0.55; }
.toggle--disabled .toggle__switch { cursor: default; }
.toggle__thumb {
position: absolute;
top: 0.15rem;
left: 0.15rem;
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: #ffffff;
transition: transform 0.15s ease;
}
.toggle__switch--on .toggle__thumb {
transform: translateX(1.1rem);
}
</style>
+98
View File
@@ -0,0 +1,98 @@
import { ref, onUnmounted } from 'vue';
// Phase weights for a rough overall-progress percentage. Backup and
// restore jobs use different phase sets (see BackupEngine::effective_phases
// on the PHP side) — only the phases relevant to whichever job is active
// contribute, everything else is ignored.
const PHASE_WEIGHTS = {
db_dump: 40,
file_zip: 40,
upload_remote: 20,
download_remote: 20,
db_restore: 50,
file_restore: 50,
};
const PHASE_LABELS = {
pending: 'Starting…',
db_dump: 'Backing up the database…',
file_zip: 'Archiving files…',
upload_remote: 'Uploading to remote storage…',
download_remote: 'Downloading from remote storage…',
db_restore: 'Restoring the database…',
file_restore: 'Restoring files…',
done: 'Done',
failed: 'Failed',
cancelled: 'Cancelled',
};
/**
* Drives a backup/restore job to completion by repeatedly calling the
* REST "step" route — a setTimeout-recursion loop (not setInterval), so a
* slow step can never overlap the next poll for the same job. The
* server-side wp_cron safety net keeps the job moving even if this stops
* polling (e.g. the tab is closed mid-backup) — this composable is purely
* the foreground "watch it happen live" experience, not the only thing
* keeping a job alive.
*/
export function useJobPolling(api) {
const job = ref(null);
const isPolling = ref(false);
const error = ref('');
let timer = null;
let stopped = false;
const percent = () => {
if (!job.value) return null;
const weight = PHASE_WEIGHTS[job.value.phase];
if (!weight) return job.value.status === 'done' ? 100 : null;
// No fine-grained progress within a phase is cheap to compute
// client-side (server tracks byte/row offsets, not percentages) — an
// indeterminate bar per phase, weighted by how much of the whole job
// that phase represents, is a reasonable approximation without a
// second round trip just to compute a number.
return null;
};
const label = () => {
if (!job.value) return '';
if (job.value.status === 'failed') return job.value.error_message || 'Failed';
return PHASE_LABELS[job.value.phase] || PHASE_LABELS[job.value.status] || '';
};
const poll = async (jobId) => {
if (stopped) return;
try {
const res = await api(`/backup/jobs/${jobId}/step`, { method: 'POST' });
job.value = res.job;
} catch (e) {
error.value = e.message || 'Request failed';
isPolling.value = false;
return;
}
if (!job.value || ['done', 'failed', 'cancelled'].includes(job.value.status)) {
isPolling.value = false;
return;
}
timer = setTimeout(() => poll(jobId), 1500);
};
const start = (jobId) => {
stopped = false;
isPolling.value = true;
error.value = '';
poll(jobId);
};
const stop = () => {
stopped = true;
isPolling.value = false;
if (timer) clearTimeout(timer);
};
onUnmounted(stop);
return { job, isPolling, error, start, stop, percent, label };
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import ActivityLog from '@/pages/activity-log/ActivityLog.vue';
const mountEl = document.getElementById('aurora-admin-activity-log-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(ActivityLog, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import AddUser from '@/pages/users/AddUser.vue';
const mountEl = document.getElementById('aurora-admin-add-user-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(AddUser, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import AdminNotices from '@/pages/admin-notices/AdminNotices.vue';
const mountEl = document.getElementById('aurora-admin-admin-notices-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(AdminNotices, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import BugReport from '@/pages/bug-report/BugReport.vue';
const mountEl = document.getElementById('aurora-admin-bug-report-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(BugReport, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Comments from '@/pages/comments/Comments.vue';
const mountEl = document.getElementById('aurora-admin-comments-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Comments, { serverData: data }).mount(mountEl);
}
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue';
// Pulls in the shared --aurora-* theme variables. Harmless if the site-wide
// shell already loaded them (identical values); keeps this page self-sufficient.
import '@/shell/shell-frame.css';
import DashboardPage from '@/pages/dashboard/Dashboard.vue';
const mountEl = document.getElementById('aurora-admin-dashboard-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(DashboardPage, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Export from '@/pages/export/Export.vue';
const mountEl = document.getElementById('aurora-admin-export-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Export, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Import from '@/pages/import/Import.vue';
const mountEl = document.getElementById('aurora-admin-import-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Import, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import MediaUpload from '@/pages/media-upload/MediaUpload.vue';
const mountEl = document.getElementById('aurora-admin-media-upload-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(MediaUpload, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Media from '@/pages/media/Media.vue';
const mountEl = document.getElementById('aurora-admin-media-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Media, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import MenuCreator from '@/pages/menu-creator/MenuCreator.vue';
const mountEl = document.getElementById('aurora-admin-menu-creator-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(MenuCreator, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Modules from '@/pages/modules/Modules.vue';
const mountEl = document.getElementById('aurora-admin-modules-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Modules, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import NativeSettings from '@/pages/native-settings/NativeSettings.vue';
const mountEl = document.getElementById('aurora-admin-native-settings-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(NativeSettings, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import NavMenus from '@/pages/nav-menus/NavMenus.vue';
const mountEl = document.getElementById('aurora-admin-nav-menus-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(NavMenus, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Pages from '@/pages/pages/Pages.vue';
const mountEl = document.getElementById('aurora-admin-pages-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Pages, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Plugins from '@/pages/plugins/Plugins.vue';
const mountEl = document.getElementById('aurora-admin-plugins-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Plugins, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Posts from '@/pages/posts/Posts.vue';
const mountEl = document.getElementById('aurora-admin-posts-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Posts, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Profile from '@/pages/profile/Profile.vue';
const mountEl = document.getElementById('aurora-admin-profile-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Profile, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import RoleEditor from '@/pages/role-editor/RoleEditor.vue';
const mountEl = document.getElementById('aurora-admin-role-editor-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(RoleEditor, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import SettingsPage from '@/pages/settings/Settings.vue';
const mountEl = document.getElementById('aurora-admin-settings-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(SettingsPage, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import SetupWizard from '@/pages/setup-wizard/SetupWizard.vue';
const mountEl = document.getElementById('aurora-admin-setup-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(SetupWizard, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import SiteShell from '@/shell/SiteShell.vue';
const mountEl = document.getElementById('aurora-admin-shell-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(SiteShell, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Themes from '@/pages/themes/Themes.vue';
const mountEl = document.getElementById('aurora-admin-themes-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Themes, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Tools from '@/pages/tools/Tools.vue';
const mountEl = document.getElementById('aurora-admin-tools-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Tools, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Users from '@/pages/users/Users.vue';
const mountEl = document.getElementById('aurora-admin-users-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Users, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Widgets from '@/pages/widgets/Widgets.vue';
const mountEl = document.getElementById('aurora-admin-widgets-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Widgets, { serverData: data }).mount(mountEl);
}
+86
View File
@@ -0,0 +1,86 @@
<script setup>
defineProps({
icon: { type: String, default: '' },
title: { type: String, required: true },
subtitle: { type: String, default: '' },
active: { type: Boolean, default: false },
});
defineEmits(['click']);
</script>
<template>
<button
type="button"
class="app-list-item"
:class="{ 'app-list-item--active': active }"
@click="$emit('click')"
>
<span v-if="icon" class="app-list-item__icon">
<span class="dashicons" :class="icon" />
</span>
<span class="app-list-item__text">
<span class="app-list-item__title">{{ title }}</span>
<span v-if="subtitle" class="app-list-item__subtitle">{{ subtitle }}</span>
</span>
<slot name="trailing" />
</button>
</template>
<style scoped>
.app-list-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px;
border: none;
background: none;
cursor: pointer;
border-radius: var(--aurora-radius-md);
text-align: left;
}
.app-list-item:hover {
background: var(--aurora-bg-subtle);
}
.app-list-item--active {
background: var(--aurora-accent-soft);
}
.app-list-item__icon {
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: var(--aurora-radius-md);
background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted);
display: flex;
align-items: center;
justify-content: center;
}
.app-list-item--active .app-list-item__icon {
color: var(--aurora-accent);
}
.app-list-item__text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.app-list-item__title {
font-size: 0.875rem;
font-weight: 600;
color: var(--aurora-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-list-item--active .app-list-item__title {
color: var(--aurora-accent);
}
.app-list-item__subtitle {
font-size: 0.75rem;
color: var(--aurora-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+204
View File
@@ -0,0 +1,204 @@
<script setup>
defineProps({
title: { type: String, required: true },
searchPlaceholder: { type: String, default: 'Search…' },
countLabel: { type: String, default: '' },
page: { type: Number, default: 1 },
hasPrev: { type: Boolean, default: false },
hasNext: { type: Boolean, default: false },
// Right-panel placeholder, shown until something is selected.
detailIcon: { type: String, default: 'dashicons-admin-generic' },
detailTitle: { type: String, default: '' },
detailText: { type: String, default: '' },
hasSelection: { type: Boolean, default: false },
});
defineEmits(['prev-page', 'next-page']);
const search = defineModel('search', { type: String, default: '' });
</script>
<template>
<div class="app-list-shell">
<!-- Left: list panel -->
<aside class="app-list-shell__nav">
<div class="app-list-shell__nav-head">
<h1 class="app-list-shell__title">{{ title }}</h1>
<div class="app-list-shell__actions">
<slot name="header-actions" />
</div>
</div>
<div class="app-list-shell__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="text" :placeholder="searchPlaceholder" />
</div>
<div v-if="$slots.filters" class="app-list-shell__filters">
<slot name="filters" />
</div>
<div v-if="countLabel || hasPrev || hasNext" class="app-list-shell__meta">
<span>{{ countLabel }}</span>
<span v-if="hasPrev || hasNext" class="app-list-shell__pager">
Page {{ page }}
<button type="button" :disabled="!hasPrev" @click="$emit('prev-page')">‹</button>
<button type="button" :disabled="!hasNext" @click="$emit('next-page')">›</button>
</span>
</div>
<div class="app-list-shell__list">
<slot name="list" />
</div>
</aside>
<!-- Right: detail panel -->
<section class="app-list-shell__content">
<slot v-if="hasSelection" name="detail" />
<div v-else class="app-list-shell__empty">
<span class="app-list-shell__empty-icon">
<span class="dashicons" :class="detailIcon" />
</span>
<h2 class="app-list-shell__empty-title">{{ detailTitle }}</h2>
<p class="app-list-shell__empty-text">{{ detailText }}</p>
<slot name="detail-empty-actions" />
</div>
</section>
</div>
</template>
<style scoped>
.app-list-shell {
display: flex;
align-items: flex-start;
gap: 20px;
/* A fixed height (not just min-height) so the two panes below can each
scroll independently within it — previously this only set a minimum
and let the whole shell grow with content, meaning both panes shared
one page-level scrollbar. Selecting an item near the bottom of a long
list left the detail panel's newly-loaded content rendered above the
current scroll position, looking like nothing happened until you
scrolled all the way back up. */
height: calc(100vh - var(--aurora-toolbar-h) - 60px);
/* -20px cancels the scroller's 20px padding (shell-frame.css's two-layer
scroll rule) so the shell still bleeds to the rounded frame's inner
edge. Was -32px back when #wpbody-content padded the content directly. */
margin: -20px;
padding: 22px;
box-sizing: border-box;
/* No background — html/body already paint the page gradient; this was
a redundant duplicate layer on top of it. */
}
/* Left — no background/border of its own; the page gradient shows
straight through so it reads as one surface instead of a boxed panel. */
.app-list-shell__nav {
width: 340px;
flex-shrink: 0;
height: 100%;
overflow-y: auto;
border-radius: var(--aurora-radius-lg);
padding: 24px 20px;
box-sizing: border-box;
}
.app-list-shell__nav-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 18px;
}
.app-list-shell__title { font-size: 1.35rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
.app-list-shell__actions { display: flex; gap: 6px; align-items: center; }
.app-list-shell__actions :deep(button) {
display: inline-flex; align-items: center; justify-content: center;
height: 30px; padding: 0 10px; border: none; border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
font-size: 0.75rem; font-weight: 500; cursor: pointer; gap: 6px;
}
.app-list-shell__actions :deep(button.is-primary) {
background: var(--aurora-accent); color: #fff;
}
.app-list-shell__actions :deep(button):hover { opacity: 0.85; }
.app-list-shell__search {
display: flex; align-items: center; gap: 8px;
background: var(--aurora-bg-subtle);
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md); padding: 8px 12px; margin-bottom: 14px;
color: var(--aurora-text-muted);
}
.app-list-shell__search input {
border: none; background: none; outline: none; color: var(--aurora-text);
font-size: 0.8125rem; width: 100%;
}
.app-list-shell__filters {
display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap;
}
.app-list-shell__filters :deep(button) {
border: none; border-radius: 999px; padding: 5px 12px;
font-size: 0.75rem; font-weight: 500; cursor: pointer;
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
}
.app-list-shell__filters :deep(button.is-active) {
/* --aurora-app-bg can be a gradient (invalid for `color`), so this one
inverted-contrast badge uses the flat fallback token instead. */
background: var(--aurora-text); color: var(--aurora-app-bg-solid);
}
.app-list-shell__meta {
display: flex; align-items: center; justify-content: space-between;
font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.03em;
text-transform: uppercase; color: var(--aurora-text-muted);
margin-bottom: 8px; padding: 0 2px;
}
.app-list-shell__pager { display: flex; align-items: center; gap: 4px; text-transform: none; }
.app-list-shell__pager button {
border: none; background: none; color: var(--aurora-text-muted);
cursor: pointer; font-size: 0.9rem; padding: 0 2px;
}
.app-list-shell__pager button:disabled { opacity: 0.3; cursor: default; }
.app-list-shell__list { display: flex; flex-direction: column; gap: 2px; }
/* Right — no background/border of its own, same reasoning. */
.app-list-shell__content {
flex: 1; min-width: 0;
height: 100%;
overflow-y: auto;
border-radius: var(--aurora-radius-lg);
padding: 28px 32px;
min-height: 480px;
box-sizing: border-box;
}
.app-list-shell__empty {
height: 100%;
min-height: 420px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; gap: 6px;
}
.app-list-shell__empty-icon {
width: 56px; height: 56px; border-radius: var(--aurora-radius-md);
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
display: flex; align-items: center; justify-content: center;
font-size: 22px; margin-bottom: 10px;
}
.app-list-shell__empty-title { font-size: 1.05rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
.app-list-shell__empty-text { font-size: 0.8125rem; color: var(--aurora-text-muted); max-width: 320px; margin: 0; }
@media (max-width: 1000px) {
/* Stacked instead of side-by-side, so the fixed-height/independent-
scroll treatment above (built for two side-by-side panes) would just
squash both panes into half-height boxes — switch to natural height
with each pane capped and independently scrollable instead. */
.app-list-shell { flex-direction: column; height: auto; }
.app-list-shell__nav {
width: auto;
height: auto;
max-height: 40vh;
}
.app-list-shell__content {
height: auto;
max-height: 60vh;
}
}
</style>
+324
View File
@@ -0,0 +1,324 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
headers: { 'X-WP-Nonce': props.serverData.restNonce },
}).then((r) => {
if (!r.ok) throw new Error('Request failed');
return r.json();
});
// Two tabs: "Admin" = site/content activity, "User" = account activity
// (login/logout/registration/profile updates) — object_type 'user' is the
// dividing line the REST route's `scope` param filters on.
const TABS = [
{ key: 'admin', label: 'Admin' },
{ key: 'user', label: 'User' },
];
const ADMIN_ACTIONS = [
['', 'All Actions'],
['created', 'Created'],
['updated', 'Updated'],
['deleted', 'Deleted'],
['activated', 'Activated'],
['deactivated', 'Deactivated'],
['switched', 'Switched'],
];
const USER_ACTIONS = [
['', 'All Actions'],
['registered', 'Registered'],
['profile_updated', 'Profile updated'],
['login', 'Logged in'],
['logout', 'Logged out'],
];
const OBJECT_TYPES = [
['', 'All Types'],
['post', 'Post'],
['page', 'Page'],
['attachment', 'Attachment'],
['plugin', 'Plugin'],
['theme', 'Theme'],
['settings', 'Settings'],
];
const TYPE_LABELS = {
post: 'Post',
page: 'Page',
attachment: 'Attachment',
user: 'User',
plugin: 'Plugin',
theme: 'Theme',
settings: 'Settings',
};
const ACTION_LABELS = {
created: 'Created',
updated: 'Updated',
deleted: 'Deleted',
registered: 'Registered',
profile_updated: 'Profile updated',
login: 'Logged in',
logout: 'Logged out',
activated: 'Activated',
deactivated: 'Deactivated',
switched: 'Switched',
};
const typeLabel = (t) => TYPE_LABELS[t] || (t ? t.charAt(0).toUpperCase() + t.slice(1) : '—');
const actionLabel = (a) => ACTION_LABELS[a] || a;
const scope = ref('admin');
const actionOptions = computed(() => (scope.value === 'user' ? USER_ACTIONS : ADMIN_ACTIONS));
const items = ref([]);
const loading = ref(true);
const total = ref(0);
const page = ref(1);
const totalPages = ref(1);
const actionFilter = ref('');
const objectFilter = ref('');
const search = ref('');
const load = async () => {
loading.value = true;
try {
const params = new URLSearchParams({ scope: scope.value });
if (actionFilter.value) params.set('action_type', actionFilter.value);
if (scope.value === 'admin' && objectFilter.value) params.set('object_type', objectFilter.value);
if (search.value.trim()) params.set('search', search.value.trim());
params.set('page', page.value);
const data = await api(`/activity-log?${params.toString()}`);
items.value = data.items;
total.value = data.total;
totalPages.value = data.totalPages;
} finally {
loading.value = false;
}
};
onMounted(load);
watch([actionFilter, objectFilter, search], () => {
page.value = 1;
load();
});
watch(page, load);
const setScope = (key) => {
if (scope.value === key) return;
scope.value = key;
actionFilter.value = '';
objectFilter.value = '';
page.value = 1;
load();
};
const formatDate = (s) => (s ? new Date(s.replace(' ', 'T')).toLocaleString() : '');
const rowRange = computed(() => {
if (!total.value) return '0';
const start = (page.value - 1) * 20 + 1;
const end = Math.min(page.value * 20, total.value);
return `${start}–${end}`;
});
const goPage = (p) => {
page.value = Math.min(Math.max(1, p), totalPages.value);
};
</script>
<template>
<main class="activity-page">
<header class="activity-page__header">
<div>
<p class="activity-page__eyebrow">Aurora Admin</p>
<h1>Activity Log</h1>
</div>
<button type="button" class="icon-button" title="Refresh" :disabled="loading" @click="load">
<span class="dashicons dashicons-update" />
</button>
</header>
<div class="activity-page__tabs">
<button
v-for="tab in TABS"
:key="tab.key"
type="button"
class="filter-pill"
:class="{ 'is-active': scope === tab.key }"
@click="setScope(tab.key)"
>
{{ tab.label }}
</button>
</div>
<section class="activity-page__toolbar" aria-label="Activity filters and search">
<div class="activity-page__filters">
<select v-model="actionFilter" class="activity-page__select">
<option v-for="[v, l] in actionOptions" :key="v" :value="v">{{ l }}</option>
</select>
<select v-if="scope === 'admin'" v-model="objectFilter" class="activity-page__select">
<option v-for="[v, l] in OBJECT_TYPES" :key="v" :value="v">{{ l }}</option>
</select>
</div>
<label class="activity-page__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Search activity…" />
</label>
</section>
<section class="activity-table" aria-label="Activity Log">
<div class="activity-table__head">
<div>Date</div>
<div>Author</div>
<div>IP Address</div>
<div>Type</div>
<div>Action</div>
<div>Description</div>
</div>
<div v-if="loading" class="activity-table__empty">Loading activity...</div>
<div v-else-if="!items.length" class="activity-table__empty">No activity found.</div>
<article v-for="i in items" v-else :key="i.id" class="activity-row">
<div class="activity-row__cell" data-label="Date">{{ formatDate(i.createdAt) }}</div>
<div class="activity-row__cell activity-row__author" data-label="Author">
<template v-if="i.userId">
<img class="activity-row__avatar" :src="i.userAvatar" :alt="i.userName" width="32" height="32" />
<div class="activity-row__author-info">
<a :href="i.userEditUrl">{{ i.userName }}</a>
<span v-if="i.userRole" class="activity-row__role">{{ i.userRole }}</span>
<span v-if="i.userEmail" class="activity-row__email">{{ i.userEmail }}</span>
</div>
</template>
<span v-else>System</span>
</div>
<div class="activity-row__cell" data-label="IP Address">{{ i.ipAddress || '—' }}</div>
<div class="activity-row__cell" data-label="Type">
<span class="type-badge">{{ typeLabel(i.objectType) }}</span>
</div>
<div class="activity-row__cell" data-label="Action">{{ actionLabel(i.actionType) }}</div>
<div class="activity-row__cell activity-row__desc" data-label="Description">{{ i.objectLabel || '—' }}</div>
</article>
<footer v-if="!loading && items.length" class="activity-table__footer">
<div>{{ rowRange }} of {{ total }}</div>
<div class="activity-table__pager">
<button type="button" :disabled="page <= 1" @click="goPage(1)">
<span class="dashicons dashicons-controls-skipback" />
</button>
<button type="button" :disabled="page <= 1" @click="goPage(page - 1)">
<span class="dashicons dashicons-arrow-left-alt2" />
</button>
<span>Page {{ page }} of {{ totalPages }}</span>
<button type="button" :disabled="page >= totalPages" @click="goPage(page + 1)">
<span class="dashicons dashicons-arrow-right-alt2" />
</button>
<button type="button" :disabled="page >= totalPages" @click="goPage(totalPages)">
<span class="dashicons dashicons-controls-skipforward" />
</button>
</div>
</footer>
</section>
</main>
</template>
<style scoped>
.activity-page { min-height: calc(100vh - 112px); padding: 0; color: var(--aurora-text); background: transparent; }
.activity-page__header {
display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 18px;
}
.activity-page__eyebrow {
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
font-weight: 800; letter-spacing: 0; text-transform: uppercase;
}
.activity-page h1 { margin: 0; color: var(--aurora-text); font-size: 1.45rem; line-height: 1.15; }
.icon-button {
width: 42px; min-height: 42px; border: 1px solid var(--aurora-border); border-radius: var(--aurora-radius-sm);
color: var(--aurora-text); background: var(--aurora-bg-subtle); display: inline-flex;
align-items: center; justify-content: center; cursor: pointer;
}
.icon-button:hover:not(:disabled) { border-color: var(--aurora-text-muted); }
.icon-button:disabled { opacity: 0.45; cursor: not-allowed; }
.activity-page__tabs { display: flex; gap: 8px; margin-bottom: 14px; }
.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 16px; font-size: 0.78rem; font-weight: 800; cursor: pointer;
}
.filter-pill.is-active { border-color: var(--aurora-text-muted); background: var(--aurora-frame-bg); color: var(--aurora-text); }
.activity-page__toolbar { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 12px; }
.activity-page__filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.activity-page__select {
padding: 7px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.78rem;
}
.activity-page__search {
width: min(280px, 100%); min-height: 38px; border: 1px solid var(--aurora-border); border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle); display: flex; align-items: center; gap: 8px; padding: 0 12px; color: var(--aurora-text-muted);
}
.activity-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;
}
.activity-page__search input::placeholder { color: var(--aurora-text-muted); }
.activity-table { overflow: hidden; border: 1px solid var(--aurora-frame-border); border-radius: var(--aurora-radius-sm); background: var(--aurora-frame-bg); }
.activity-table__head, .activity-row {
display: grid;
grid-template-columns: 190px minmax(160px, 0.8fr) 130px 110px 140px minmax(180px, 1.6fr);
align-items: center;
}
.activity-table__head {
min-height: 44px; border-bottom: 1px solid var(--aurora-border); color: var(--aurora-text);
font-size: 0.79rem; font-weight: 850;
}
.activity-table__head > div { min-height: 44px; padding: 0 16px; display: flex; align-items: center; }
.activity-row { min-height: 76px; border-bottom: 1px solid var(--aurora-border); }
.activity-row:hover { background: var(--aurora-bg-subtle); }
.activity-row__cell {
min-width: 0; padding: 12px 16px; color: var(--aurora-text-muted); font-size: 0.8rem;
line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.activity-row__desc { color: var(--aurora-text); white-space: normal; }
.activity-row__author { display: flex; align-items: center; gap: 10px; white-space: normal; }
.activity-row__avatar { border-radius: 50%; flex-shrink: 0; display: block; }
.activity-row__author-info { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.activity-row__author-info a {
color: var(--aurora-accent); font-weight: 700; font-size: 0.8125rem; text-decoration: none;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.activity-row__author-info a:hover { text-decoration: underline; }
.activity-row__role, .activity-row__email {
font-size: 0.71rem; color: var(--aurora-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.type-badge {
display: inline-block; border-radius: 999px; background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
padding: 2px 10px; font-size: 0.68rem; font-weight: 850; box-shadow: inset 0 0 0 1px var(--aurora-border);
}
.activity-table__empty { padding: 38px 18px; color: var(--aurora-text-muted); font-size: 0.85rem; text-align: center; }
.activity-table__footer {
min-height: 50px; 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;
}
.activity-table__pager { display: flex; align-items: center; gap: 8px; }
.activity-table__pager button {
width: 28px; height: 28px; border: 0; border-radius: var(--aurora-radius-sm);
background: transparent; color: var(--aurora-text-muted); cursor: pointer;
}
.activity-table__pager button:not(:disabled):hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
.activity-table__pager button:disabled { opacity: 0.4; cursor: not-allowed; }
@media (max-width: 1100px) {
.activity-table__head { display: none; }
.activity-row { grid-template-columns: 1fr; padding: 10px 0; }
.activity-row__cell { white-space: normal; }
.activity-row__cell::before { content: attr(data-label) ': '; font-weight: 700; color: var(--aurora-text); }
}
</style>
@@ -0,0 +1,217 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import AppListShell from '../_shared/AppListShell.vue';
import AppListItem from '../_shared/AppListItem.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path, opts = {}) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(opts.headers || {}),
},
}).then((r) => {
if (!r.ok) throw new Error('Request failed');
return r.json();
});
const notices = ref([]);
const loading = ref(true);
const search = ref('');
const selectedId = ref(null);
const isCreating = ref(false);
const roleOptions = ref([]);
const load = async () => {
loading.value = true;
try {
notices.value = await api('/notices');
} finally {
loading.value = false;
}
};
onMounted(() => {
load();
roleOptions.value = props.serverData.roles || [];
});
const filtered = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return notices.value;
return notices.value.filter((n) => n.title.toLowerCase().includes(q));
});
const selected = computed(() => notices.value.find((n) => n.id === selectedId.value) || null);
const draft = reactive({
title: '', message: '', type: 'info', roles: [], pages: '', dismissible: true, active: true,
});
const selectNotice = (n) => {
selectedId.value = n.id;
isCreating.value = false;
Object.assign(draft, {
title: n.title, message: n.message, type: n.type, roles: [...n.roles],
pages: n.pages.join(', '), dismissible: n.dismissible, active: n.active,
});
};
const newNotice = () => {
selectedId.value = null;
isCreating.value = true;
Object.assign(draft, { title: 'New notice', message: '', type: 'info', roles: [], pages: '', dismissible: true, active: true });
};
const toggleRole = (slug) => {
const idx = draft.roles.indexOf(slug);
if (idx === -1) draft.roles.push(slug);
else draft.roles.splice(idx, 1);
};
const saving = ref(false);
const save = async () => {
saving.value = true;
try {
const payload = {
title: draft.title,
message: draft.message,
type: draft.type,
roles: draft.roles,
pages: draft.pages.split(',').map((p) => p.trim()).filter(Boolean),
dismissible: draft.dismissible,
active: draft.active,
};
if (isCreating.value) {
const created = await api('/notices', { method: 'POST', body: JSON.stringify(payload) });
await load();
selectNotice(created);
} else if (selectedId.value) {
await api(`/notices/${selectedId.value}`, { method: 'PUT', body: JSON.stringify(payload) });
await load();
}
} finally {
saving.value = false;
}
};
const remove = async () => {
if (!selectedId.value) return;
await api(`/notices/${selectedId.value}`, { method: 'DELETE' });
selectedId.value = null;
await load();
};
</script>
<template>
<AppListShell
v-model:search="search"
title="Admin Notices"
search-placeholder="Search notices…"
detail-icon="dashicons-megaphone"
detail-title="Notice Editor"
detail-text="Select a notice from the list to edit it, or create a new one to get started."
:has-selection="!!selected || isCreating"
>
<template #header-actions>
<button type="button" @click="newNotice">+</button>
</template>
<template #detail-empty-actions>
<button type="button" class="is-primary" style="margin-top: 14px;" @click="newNotice">
Create Your First Notice
</button>
</template>
<template #list>
<AppListItem
v-for="n in filtered"
:key="n.id"
icon="dashicons-megaphone"
:title="n.title"
:subtitle="n.active ? n.type : `${n.type} (inactive)`"
:active="n.id === selectedId"
@click="selectNotice(n)"
/>
</template>
<template #detail>
<div class="notice-editor">
<div class="notice-editor__row">
<input v-model="draft.title" type="text" placeholder="Notice title" class="notice-editor__title" />
<select v-model="draft.type">
<option value="info">Info</option>
<option value="success">Success</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
</div>
<textarea v-model="draft.message" placeholder="Message" rows="4" />
<div class="notice-editor__section">
<h3>Target roles (empty = all)</h3>
<div class="notice-editor__roles">
<label v-for="r in roleOptions" :key="r.value" class="notice-editor__role">
<input type="checkbox" :checked="draft.roles.includes(r.value)" @change="toggleRole(r.value)" />
{{ r.label }}
</label>
</div>
</div>
<div class="notice-editor__section">
<h3>Target pages (comma-separated, e.g. index.php, edit.php — empty = all)</h3>
<input v-model="draft.pages" type="text" placeholder="index.php, edit.php" />
</div>
<div class="notice-editor__toggles">
<label><input type="checkbox" v-model="draft.dismissible" /> Dismissible</label>
<label><input type="checkbox" v-model="draft.active" /> Active</label>
</div>
<div class="notice-editor__actions">
<button type="button" class="is-danger" :disabled="!selectedId" @click="remove">Delete</button>
<button type="button" class="is-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</template>
</AppListShell>
</template>
<style scoped>
.notice-editor { display: flex; flex-direction: column; gap: 18px; }
.notice-editor__row { display: flex; gap: 10px; }
.notice-editor__title {
flex: 1; font-size: 1.05rem; font-weight: 700; padding: 8px 10px;
border: 1px solid var(--aurora-border); border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.notice-editor select, .notice-editor input[type='text'], .notice-editor textarea {
padding: 8px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.8125rem;
font-family: inherit; width: 100%; box-sizing: border-box;
}
.notice-editor__section h3 {
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em;
color: var(--aurora-text-muted); margin: 0 0 8px;
}
.notice-editor__roles { display: flex; flex-wrap: wrap; gap: 12px; }
.notice-editor__role { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; color: var(--aurora-text); }
.notice-editor__toggles { display: flex; gap: 20px; font-size: 0.8125rem; color: var(--aurora-text); }
.notice-editor__toggles label { display: flex; align-items: center; gap: 6px; }
.notice-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
.notice-editor__actions button {
border: none; border-radius: var(--aurora-radius-sm); padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
background: var(--aurora-bg-subtle); color: var(--aurora-text);
}
.notice-editor__actions .is-primary { background: var(--aurora-accent); color: #fff; }
.notice-editor__actions .is-danger { color: #ef4444; }
.notice-editor__actions button:disabled { opacity: 0.5; cursor: default; }
</style>
+128
View File
@@ -0,0 +1,128 @@
<script setup>
import { reactive, ref } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const description = ref('');
const sending = ref(false);
const notice = ref('');
const error = ref('');
const diagnostics = reactive({
pluginVersion: props.serverData.pluginVersion || '',
wpVersion: props.serverData.wpVersion || '',
phpVersion: props.serverData.phpVersion || '',
theme: props.serverData.theme || '',
});
const send = async () => {
if (!description.value.trim()) return;
sending.value = true;
error.value = '';
notice.value = '';
try {
const res = await fetch(`${props.serverData.restUrl}aurora-admin/v1/bug-report`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
},
body: JSON.stringify({ description: description.value }),
});
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data?.message || 'Could not send your report.');
notice.value = 'Thanks — your report was sent.';
description.value = '';
} catch (e) {
error.value = e.message;
} finally {
sending.value = false;
}
};
</script>
<template>
<main class="bug-report-page">
<header class="bug-report-page__header">
<p class="bug-report-page__eyebrow">Support</p>
<h1>Report a Bug</h1>
<p class="bug-report-page__sub">
Tell us what went wrong. We'll get your diagnostics automatically —
just describe the issue below.
</p>
</header>
<div v-if="notice" class="bug-report-notice bug-report-notice--success">{{ notice }}</div>
<div v-if="error" class="bug-report-notice bug-report-notice--error">{{ error }}</div>
<form class="bug-report-form" @submit.prevent="send">
<label class="bug-report-field">
<span>What happened?</span>
<textarea
v-model="description"
rows="8"
placeholder="Describe the bug, what you expected, and the steps to reproduce it…"
required
/>
</label>
<div class="bug-report-diagnostics">
<h2>Diagnostics (sent automatically)</h2>
<dl>
<div><dt>Plugin version</dt><dd>{{ diagnostics.pluginVersion || '—' }}</dd></div>
<div><dt>WordPress version</dt><dd>{{ diagnostics.wpVersion || '—' }}</dd></div>
<div><dt>PHP version</dt><dd>{{ diagnostics.phpVersion || '—' }}</dd></div>
<div><dt>Active theme</dt><dd>{{ diagnostics.theme || '—' }}</dd></div>
</dl>
</div>
<div class="bug-report-form__actions">
<button type="submit" class="button-primary" :disabled="sending || !description.trim()">
{{ sending ? 'Sending…' : 'Send Report' }}
</button>
</div>
</form>
</main>
</template>
<style scoped>
.bug-report-page { max-width: 640px; margin: 0 auto; }
.bug-report-page__header { margin-bottom: 16px; }
.bug-report-page__eyebrow {
margin: 0 0 4px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--aurora-text-muted);
}
.bug-report-page h1 { margin: 0; font-size: 1.6rem; font-weight: 700; color: var(--aurora-text); }
.bug-report-page__sub { margin: 8px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
.bug-report-notice { padding: 10px 14px; border-radius: var(--aurora-radius-sm); margin-bottom: 16px; font-size: 0.875rem; }
.bug-report-notice--success { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.bug-report-notice--error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.bug-report-form { display: flex; flex-direction: column; gap: 20px; }
.bug-report-field { display: flex; flex-direction: column; gap: 6px; }
.bug-report-field span { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-text); }
.bug-report-field textarea {
padding: 10px 12px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.875rem;
font-family: inherit; resize: vertical;
}
.bug-report-diagnostics {
background: var(--aurora-bg-subtle); border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-md); padding: 14px 16px;
}
.bug-report-diagnostics h2 {
margin: 0 0 10px; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.03em; color: var(--aurora-text-muted); font-weight: 600;
}
.bug-report-diagnostics dl { margin: 0; display: flex; flex-direction: column; gap: 6px; }
.bug-report-diagnostics dl > div { display: flex; justify-content: space-between; font-size: 0.8125rem; }
.bug-report-diagnostics dt { color: var(--aurora-text-muted); }
.bug-report-diagnostics dd { margin: 0; color: var(--aurora-text); font-weight: 500; }
.bug-report-form__actions { display: flex; justify-content: flex-end; }
</style>
+701
View File
@@ -0,0 +1,701 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const comments = ref([]);
const stats = ref({ all: 0, approved: 0, pending: 0, spam: 0, trash: 0 });
const pagination = ref({ page: 1, perPage: 50, 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 compact = ref(false);
const apiBase = computed(() => `${props.serverData.restUrl}aurora-admin/v1`);
const request = async (path, options = {}) => {
const response = await fetch(`${apiBase.value}${path}`, {
credentials: 'same-origin',
...options,
headers: {
'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(`/comments?${params.toString()}`);
comments.value = data.items || [];
stats.value = data.stats || stats.value;
pagination.value = data.pagination || pagination.value;
} catch (err) {
error.value = err.message || 'Could not load comments';
} 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: 'approved', label: 'Approved', count: stats.value.approved },
{ key: 'pending', label: 'Pending', count: stats.value.pending },
{ key: 'spam', label: 'Spam', count: stats.value.spam },
{ key: 'trash', label: 'Trash', count: stats.value.trash },
]);
const runStatus = async (comment, nextStatus) => {
if (!comment || busyId.value) return;
busyId.value = comment.id;
error.value = '';
notice.value = '';
try {
const data = await request('/comments/status', {
method: 'POST',
body: JSON.stringify({ id: comment.id, status: nextStatus }),
});
notice.value = data.message || 'Comment updated.';
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyId.value = 0;
}
};
const deleteComment = async (comment) => {
if (!comment || busyId.value) return;
busyId.value = comment.id;
error.value = '';
notice.value = '';
try {
const data = await request(`/comments/${comment.id}`, { method: 'DELETE' });
notice.value = data.message || 'Comment 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 initials = (name) =>
(name || '?')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('');
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="comments-page">
<header class="comments-page__header">
<div>
<p class="comments-page__eyebrow">Discussion</p>
<h1>Comments</h1>
</div>
<div class="comments-page__actions">
<button
type="button"
class="icon-button"
:class="{ 'is-active': compact }"
title="Toggle compact view"
@click="compact = !compact"
>
<span class="dashicons dashicons-menu-alt3" />
</button>
<button type="button" class="icon-button" title="Refresh comments" :disabled="loading" @click="load">
<span class="dashicons dashicons-update" />
</button>
</div>
</header>
<section class="comments-page__toolbar" aria-label="Comment filters and search">
<div class="comments-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="comments-page__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Search comments..." />
</label>
</section>
<div v-if="notice" class="comments-notice comments-notice--success">{{ notice }}</div>
<div v-if="error" class="comments-notice comments-notice--error">{{ error }}</div>
<section class="comments-table" :class="{ 'is-compact': compact }" aria-label="Comments">
<div class="comments-table__head">
<div>Author <span class="dashicons dashicons-filter" /></div>
<div>Comment <span class="dashicons dashicons-filter" /></div>
<div>Post <span class="dashicons dashicons-filter" /></div>
<div>Date <span class="dashicons dashicons-filter" /></div>
<div>Status <span class="dashicons dashicons-filter" /></div>
<div>Actions</div>
</div>
<div v-if="loading" class="comments-table__empty">Loading comments...</div>
<div v-else-if="!comments.length" class="comments-table__empty">No comments match your filters.</div>
<article v-for="comment in comments" v-else :key="comment.id" class="comment-row">
<div class="comment-row__author">
<img v-if="comment.avatar" :src="comment.avatar" alt="" class="comment-row__avatar" />
<div v-else class="comment-row__initials">{{ initials(comment.author) }}</div>
<div class="comment-row__person">
<strong>{{ comment.author }}</strong>
<span>{{ comment.email || 'No email' }}</span>
</div>
</div>
<div class="comment-row__comment" data-label="Comment">
{{ comment.content || 'No comment text.' }}
</div>
<div class="comment-row__post" data-label="Post">
<a v-if="comment.postEditUrl" :href="comment.postEditUrl">{{ comment.postTitle }}</a>
<span v-else>{{ comment.postTitle }}</span>
</div>
<div class="comment-row__cell" data-label="Date">
<strong>{{ comment.relativeDate }}</strong>
<span>{{ comment.date }}</span>
</div>
<div class="comment-row__cell" data-label="Status">
<span class="status-badge" :class="`status-badge--${comment.status}`">
<i />
{{ comment.statusLabel }}
</span>
</div>
<div class="comment-row__actions">
<button
v-if="comment.status !== 'approved'"
type="button"
class="icon-button icon-button--inline"
title="Approve"
:disabled="busyId === comment.id"
@click="runStatus(comment, 'approve')"
>
<span class="dashicons dashicons-yes" />
</button>
<button
v-if="comment.status === 'approved'"
type="button"
class="icon-button icon-button--inline"
title="Mark pending"
:disabled="busyId === comment.id"
@click="runStatus(comment, 'hold')"
>
<span class="dashicons dashicons-hidden" />
</button>
<a class="icon-button icon-button--inline" title="Edit comment" :href="comment.editUrl">
<span class="dashicons dashicons-edit" />
</a>
<button
v-if="comment.status !== 'spam'"
type="button"
class="icon-button icon-button--inline"
title="Mark spam"
:disabled="busyId === comment.id"
@click="runStatus(comment, 'spam')"
>
<span class="dashicons dashicons-warning" />
</button>
<button
v-if="comment.status !== 'trash'"
type="button"
class="icon-button icon-button--inline"
title="Move to trash"
:disabled="busyId === comment.id"
@click="runStatus(comment, 'trash')"
>
<span class="dashicons dashicons-trash" />
</button>
<button
v-else
type="button"
class="icon-button icon-button--inline"
title="Delete permanently"
:disabled="busyId === comment.id"
@click="deleteComment(comment)"
>
<span class="dashicons dashicons-no-alt" />
</button>
</div>
</article>
<footer v-if="!loading && comments.length" class="comments-table__footer">
<div>Page Size: {{ pagination.perPage }}</div>
<div>{{ rowRange }} of {{ pagination.total }}</div>
<div class="comments-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>
.comments-page {
min-height: calc(100vh - 112px);
padding: 0;
color: var(--aurora-text);
background: transparent;
}
.comments-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 24px;
}
.comments-page__eyebrow {
margin: 0 0 7px;
color: var(--aurora-text-muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.comments-page h1 {
margin: 0;
color: var(--aurora-text);
font-size: 1.45rem;
line-height: 1.15;
}
.comments-page__actions,
.comments-page__toolbar,
.comments-page__filters,
.comment-row__actions {
display: flex;
align-items: center;
}
.comments-page__actions {
gap: 10px;
}
.comments-page__toolbar {
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.comments-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);
}
.comments-page__search {
width: min(320px, 100%);
min-height: 38px;
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle);
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
color: var(--aurora-text-muted);
}
.comments-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;
}
.comments-page__search input::placeholder {
color: var(--aurora-text-muted);
}
.icon-button {
width: 42px;
min-height: 42px;
border: 1px solid var(--aurora-border);
border-radius: var(--aurora-radius-sm);
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.is-active,
.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;
}
.comments-notice {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
font-size: 0.78rem;
font-weight: 750;
}
.comments-table {
overflow: hidden;
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-sm);
background: var(--aurora-frame-bg);
}
.comments-table__head,
.comment-row {
display: grid;
grid-template-columns: minmax(190px, 0.9fr) minmax(320px, 1.6fr) minmax(190px, 1fr) 110px 150px 142px;
align-items: center;
}
.comments-table__head {
min-height: 48px;
border-bottom: 1px solid var(--aurora-border);
color: var(--aurora-text);
font-size: 0.79rem;
font-weight: 850;
}
.comments-table__head > div {
min-height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.comment-row {
min-height: 76px;
border-bottom: 1px solid var(--aurora-border);
}
.comment-row:hover {
background: var(--aurora-bg-subtle);
}
.comment-row__author {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
gap: 12px;
align-items: center;
min-width: 0;
padding: 14px 16px;
}
.comment-row__avatar,
.comment-row__initials {
width: 36px;
height: 36px;
border-radius: 50%;
}
.comment-row__avatar {
display: block;
}
.comment-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;
}
.comment-row__person {
min-width: 0;
}
.comment-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;
}
.comment-row__person span,
.comment-row__cell span {
display: block;
overflow: hidden;
color: var(--aurora-text-muted);
font-size: 0.74rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-row__comment,
.comment-row__post,
.comment-row__cell {
min-width: 0;
padding: 14px 16px;
color: var(--aurora-text-muted);
font-size: 0.8rem;
line-height: 1.45;
}
.comment-row__comment {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-row__post a,
.comment-row__post span {
display: block;
overflow: hidden;
color: var(--aurora-text);
font-weight: 800;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-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);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
font-size: 0.74rem;
font-weight: 850;
}
.status-badge i {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.8;
}
.status-badge--approved {
background: var(--aurora-frame-bg);
color: var(--aurora-text);
box-shadow: inset 0 0 0 1px var(--aurora-border);
}
.comment-row__actions {
gap: 8px;
padding: 14px 16px;
}
.comments-table.is-compact .comment-row {
min-height: 58px;
}
.comments-table.is-compact .comment-row__author,
.comments-table.is-compact .comment-row__comment,
.comments-table.is-compact .comment-row__post,
.comments-table.is-compact .comment-row__cell,
.comments-table.is-compact .comment-row__actions {
padding-top: 9px;
padding-bottom: 9px;
}
.comments-table.is-compact .comment-row__avatar,
.comments-table.is-compact .comment-row__initials {
width: 30px;
height: 30px;
}
.comments-table__empty {
padding: 38px 18px;
color: var(--aurora-text-muted);
font-size: 0.85rem;
text-align: center;
}
.comments-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;
}
.comments-table__pager {
display: flex;
align-items: center;
gap: 8px;
}
.comments-table__pager button {
width: 28px;
height: 28px;
border: 0;
border-radius: var(--aurora-radius-sm);
background: transparent;
color: var(--aurora-text-muted);
cursor: pointer;
}
.comments-table__pager button:not(:disabled):hover {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
.comments-table__pager button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 1280px) {
.comments-table__head,
.comment-row {
grid-template-columns: minmax(170px, 0.9fr) minmax(260px, 1.4fr) minmax(160px, 0.9fr) 100px 130px 132px;
}
}
@media (max-width: 1080px) {
.comments-table__head {
display: none;
}
.comment-row {
grid-template-columns: 1fr;
align-items: stretch;
padding: 8px 0;
}
.comment-row__comment,
.comment-row__post,
.comment-row__cell,
.comment-row__actions {
padding: 8px 16px;
}
.comment-row__cell,
.comment-row__post {
display: flex;
justify-content: space-between;
gap: 16px;
}
.comment-row__cell::before,
.comment-row__post::before {
content: attr(data-label);
color: var(--aurora-text-muted);
font-weight: 800;
}
.comments-table__footer,
.comments-page__header,
.comments-page__toolbar {
align-items: stretch;
flex-direction: column;
}
.comments-table__footer {
padding: 14px 16px;
gap: 10px;
}
.comments-page__search {
width: 100%;
}
}
</style>
+259
View File
@@ -0,0 +1,259 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useDashboardData } from './useDashboardData.js';
import { useAnalyticsData } from './useAnalyticsData.js';
import RecentContent from './cards/RecentContent.vue';
import ScheduledContent from './cards/ScheduledContent.vue';
import RecentComments from './cards/RecentComments.vue';
import UserAnalytics from './cards/UserAnalytics.vue';
import MediaAnalytics from './cards/MediaAnalytics.vue';
import ServerHealth from './cards/ServerHealth.vue';
import PageViews from './cards/PageViews.vue';
import ActiveUsers from './cards/ActiveUsers.vue';
import DeviceUsage from './cards/DeviceUsage.vue';
import Engagement from './cards/Engagement.vue';
import TopList from './cards/TopList.vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const { data, loading, load } = useDashboardData(props.serverData);
const { data: analytics, error: analyticsError, load: loadAnalytics } = useAnalyticsData(props.serverData);
// Date range — default last 7 days, with simple presets.
const today = new Date();
const range = ref([new Date(new Date().setDate(today.getDate() - 7)), new Date()]);
const presetsOpen = ref(false);
const fmtLabel = (d) =>
d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
const reload = (from, to) => {
load(from, to);
loadAnalytics(from, to);
};
const applyPreset = (days) => {
const to = new Date();
const from = new Date();
from.setDate(to.getDate() - days);
range.value = [from, to];
presetsOpen.value = false;
reload(from, to);
};
const activeTab = ref('overview');
onMounted(() => reload(range.value[0], range.value[1]));
</script>
<template>
<div class="aurora-dashboard">
<header class="dash__head">
<div>
<h1 class="dash__title">Dashboard</h1>
<p class="dash__subtitle">Overview of your site activity</p>
</div>
<div class="dash__range">
<button type="button" class="dash__range-btn" @click="presetsOpen = !presetsOpen">
<span class="dash__range-icon">▦</span>
{{ fmtLabel(range[0]) }} — {{ fmtLabel(range[1]) }}
</button>
<div v-if="presetsOpen" class="dash__presets">
<button type="button" @click="applyPreset(7)">Last 7 days</button>
<button type="button" @click="applyPreset(30)">Last 30 days</button>
<button type="button" @click="applyPreset(90)">Last 90 days</button>
</div>
</div>
</header>
<div class="dash__tabs">
<button
type="button"
:class="['dash__tab', { 'dash__tab--active': activeTab === 'overview' }]"
@click="activeTab = 'overview'"
>
Overview
</button>
<button
type="button"
:class="['dash__tab', { 'dash__tab--active': activeTab === 'analytics' }]"
@click="activeTab = 'analytics'"
>
Analytics
</button>
</div>
<div v-if="activeTab === 'overview'" class="dash__grid">
<div class="dash__cell" style="grid-column: span 4">
<RecentContent :data="data ? data.recentContent : {}" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<ScheduledContent :data="data ? data.scheduledContent : {}" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<RecentComments :data="data ? data.recentComments : {}" />
</div>
<div class="dash__cell" style="grid-column: span 6">
<UserAnalytics :data="data ? data.userAnalytics : {}" />
</div>
<div class="dash__cell" style="grid-column: span 6">
<MediaAnalytics :data="data ? data.mediaAnalytics : {}" />
</div>
<div class="dash__cell" style="grid-column: span 6">
<ServerHealth :data="data ? data.serverHealth : {}" />
</div>
</div>
<div v-else-if="analyticsError" class="dash__analytics-error">
Could not load analytics. Try refreshing the page.
</div>
<div v-else class="dash__grid">
<div class="dash__cell" style="grid-column: span 8">
<PageViews :data="analytics ? analytics.pageViews : {}" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<ActiveUsers :count="analytics ? analytics.activeUsers : 0" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<DeviceUsage :items="analytics ? analytics.deviceUsage : []" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<Engagement :bounce="analytics ? analytics.bounce : {}" />
</div>
<div class="dash__cell" style="grid-column: span 4">
<TopList
title="Top Countries"
subtitle="Where visitors are from"
:items="analytics ? analytics.topCountries : []"
empty-text="No location data yet."
/>
</div>
<div class="dash__cell" style="grid-column: span 6">
<TopList
title="Top Pages"
subtitle="Most viewed pages"
:items="analytics ? analytics.topPages : []"
/>
</div>
<div class="dash__cell" style="grid-column: span 6">
<TopList
title="Top Referrers"
subtitle="Where traffic comes from"
:items="analytics ? analytics.topReferrers : []"
/>
</div>
</div>
</div>
</template>
<style scoped>
.aurora-dashboard {
padding: 24px var(--aurora-gutter-r, 44px) 40px 32px;
box-sizing: border-box;
}
.dash__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.dash__title {
margin: 0;
font-size: 1.6rem;
font-weight: 700;
color: var(--aurora-text);
letter-spacing: -0.02em;
}
.dash__subtitle { margin: 4px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
.dash__range { position: relative; }
.dash__range-btn {
display: flex; align-items: center; gap: 8px;
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-card-border);
color: var(--aurora-text);
border-radius: var(--aurora-radius-md);
padding: 8px 12px;
font-size: 0.8125rem;
cursor: pointer;
}
.dash__range-icon { color: var(--aurora-text-muted); }
.dash__presets {
position: absolute; right: 0; top: calc(100% + 6px);
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-card-border);
border-radius: var(--aurora-radius-md);
padding: 6px;
z-index: 10;
min-width: 160px;
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
}
.dash__presets button {
display: block; width: 100%; text-align: left;
background: none; border: none; color: var(--aurora-text);
padding: 8px 10px; border-radius: var(--aurora-radius-sm); cursor: pointer; font-size: 0.8125rem;
}
.dash__presets button:hover { background: var(--aurora-bg-subtle); }
.dash__tabs {
display: inline-flex;
gap: 2px;
background: var(--aurora-bg-subtle);
border-radius: var(--aurora-radius-md);
padding: 3px;
margin-bottom: 20px;
}
.dash__analytics-error {
padding: 48px 24px;
text-align: center;
color: var(--aurora-text-muted);
font-size: 0.875rem;
}
.dash__tab {
border: none; background: none; cursor: pointer;
padding: 6px 16px; border-radius: var(--aurora-radius-sm);
font-size: 0.8125rem; font-weight: 500;
color: var(--aurora-text-muted);
}
.dash__tab--active { background: var(--aurora-card-bg); color: var(--aurora-text); }
.dash__grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 18px;
}
.dash__cell { min-width: 0; display: flex; }
.dash__cell > * { width: 100%; }
.dash__analytics-empty {
color: var(--aurora-text-muted);
font-size: 0.875rem;
padding: 40px 0;
text-align: center;
}
@media (max-width: 1100px) {
.dash__cell { grid-column: span 12 !important; }
}
</style>
<style>
/* Shared bits used inside the card slots (head-right badges, footer links).
Scoped under .aurora-dashboard so they can't collide with WordPress or
leak elsewhere, while still reaching slotted content in child cards. */
.aurora-dashboard .dot {
display: inline-block;
width: 7px; height: 7px;
border-radius: 50%;
margin-right: 4px;
vertical-align: middle;
}
.aurora-dashboard .dot--accent { background: var(--aurora-accent); }
.aurora-dashboard .dot--green { background: #34d399; }
.aurora-dashboard .viewall {
font-size: 0.75rem;
color: var(--aurora-text-muted);
text-decoration: none;
}
.aurora-dashboard .viewall:hover { color: var(--aurora-accent); }
</style>
@@ -0,0 +1,35 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
defineProps({ count: { type: Number, default: 0 } });
</script>
<template>
<AuroraCard title="Active Users" subtitle="Visitors in the last 5 minutes">
<div class="au">
<span class="au__pulse" />
<div class="au__num">{{ count }}</div>
<div class="au__label">active now</div>
</div>
</AuroraCard>
</template>
<style scoped>
.au {
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 24px 0; height: 100%;
}
.au__pulse {
width: 10px; height: 10px; border-radius: 50%;
background: #34d399; margin-bottom: 14px;
box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.6);
animation: au-pulse 1.8s infinite;
}
@keyframes au-pulse {
0% { box-shadow: 0 0 0 0 rgba(52,211,153,0.5); }
70% { box-shadow: 0 0 0 12px rgba(52,211,153,0); }
100% { box-shadow: 0 0 0 0 rgba(52,211,153,0); }
}
.au__num { font-size: 2.5rem; font-weight: 700; color: var(--aurora-text); line-height: 1; }
.au__label { margin-top: 6px; font-size: 0.8125rem; color: var(--aurora-text-muted); }
</style>
@@ -0,0 +1,18 @@
<script setup>
import { computed } from 'vue';
import AuroraCard from '../components/AuroraCard.vue';
import BarList from '../components/BarList.vue';
const props = defineProps({ items: { type: Array, default: () => [] } });
const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
const list = computed(() =>
props.items.map((d) => ({ label: `${cap(d.device)} · ${d.pct}%`, count: d.count }))
);
</script>
<template>
<AuroraCard title="Device Usage" subtitle="How visitors browse">
<BarList :items="list" empty-text="No visits in this range yet." />
</AuroraCard>
</template>
@@ -0,0 +1,18 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import StatRow from '../components/StatRow.vue';
defineProps({ bounce: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="Engagement" subtitle="Bounce rate & time on site">
<StatRow
:stats="[
{ value: (bounce.rate || 0) + '%', label: 'Bounce Rate' },
{ value: bounce.avgTime || '0s', label: 'Avg. Time' },
{ value: bounce.sessions || 0, label: 'Sessions' },
]"
/>
</AuroraCard>
</template>
@@ -0,0 +1,61 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
defineProps({ data: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="Media Analytics" subtitle="Media library overview">
<div class="ma__top">
<div class="ma__big">
<div class="ma__big-num">{{ data.totalFiles || 0 }}</div>
<div class="ma__big-label">Total Files</div>
</div>
<div class="ma__big">
<div class="ma__big-num">{{ data.totalSize || '0 B' }}</div>
<div class="ma__big-label">Total Size</div>
</div>
</div>
<div class="ma__grid">
<div class="ma__box">
<div class="ma__box-num">{{ data.recent30d || 0 }}</div>
<div class="ma__box-label">Recent (30d)</div>
</div>
<div class="ma__box">
<div class="ma__box-num">{{ data.unused || 0 }}</div>
<div class="ma__box-label">Unused</div>
</div>
<div class="ma__box">
<div class="ma__box-num">{{ data.largeFiles || 0 }}</div>
<div class="ma__box-label">Large Files</div>
</div>
<div class="ma__box">
<div class="ma__box-num">{{ data.diskUsage || '0 B' }}</div>
<div class="ma__box-label">Disk Usage</div>
</div>
</div>
<template #footer>
<a :href="data.manageUrl" class="viewall">Manage Media ›</a>
</template>
</AuroraCard>
</template>
<style scoped>
.ma__top {
display: grid; grid-template-columns: 1fr 1fr; text-align: center;
margin-bottom: 18px;
}
.ma__big-num { font-size: 1.75rem; font-weight: 700; color: var(--aurora-text); }
.ma__big-label { margin-top: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); }
.ma__grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.ma__box {
background: var(--aurora-bg-subtle);
border-radius: var(--aurora-radius-md);
padding: 14px;
text-align: center;
}
.ma__box-num { font-size: 1.25rem; font-weight: 700; color: var(--aurora-text); }
.ma__box-label { margin-top: 2px; font-size: 0.6875rem; color: var(--aurora-text-muted); }
</style>
@@ -0,0 +1,26 @@
<script setup>
import AuroraCard from '../components/AuroraCard.vue';
import StatRow from '../components/StatRow.vue';
import AreaChart from '../components/AreaChart.vue';
defineProps({ data: { type: Object, default: () => ({}) } });
</script>
<template>
<AuroraCard title="Page Views" subtitle="Traffic over the selected range">
<StatRow
:boxed="false"
:stats="[
{ value: data.total || 0, label: 'Total Views' },
{ value: data.unique || 0, label: 'Unique Visitors' },
]"
/>
<div class="pv__chart">
<AreaChart :series="data.series || []" :height="150" />
</div>
</AuroraCard>
</template>
<style scoped>
.pv__chart { margin-top: 20px; }
</style>

Some files were not shown because too many files have changed in this diff Show More