Initial commit: Aurora Admin plugin
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
app/node_modules/
|
||||||
|
app/dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Aurora Admin v2 — Project Status
|
||||||
|
|
||||||
|
_Last updated: 2026-07-03_
|
||||||
|
|
||||||
|
## What this project is
|
||||||
|
|
||||||
|
A ground-up rebuild of the "uiXpress" WordPress admin-theme plugin, relaunched
|
||||||
|
as **Aurora Admin**. The original plugin's look/feel and feature set are the
|
||||||
|
target, but **no original code is reused** — all Vue components, PHP classes,
|
||||||
|
Tailwind config, and build tooling are freshly written. This lives in
|
||||||
|
`aurora-admin-v2/` (the earlier in-place rebrand attempt, `aurora-admin-working/`,
|
||||||
|
is superseded and no longer the active codebase).
|
||||||
|
|
||||||
|
## Workflow rules established
|
||||||
|
|
||||||
|
- **Always verify live in Chrome** before declaring a fix done — the user was
|
||||||
|
burned by blind-iteration guesses costing time/money. Use the Chrome MCP
|
||||||
|
tools (`tabs_context_mcp`, `navigate`, `computer`/screenshot, `javascript_exec`)
|
||||||
|
to inspect real DOM/computed styles rather than guessing from source.
|
||||||
|
- **Zip packaging**: Windows `Compress-Archive` corrupts the WP upload (backslash
|
||||||
|
path separators break PHP's unzip). Always build the zip via PowerShell
|
||||||
|
`System.IO.Compression.ZipFile`, normalizing every entry path with
|
||||||
|
`.Replace('\', '/')`. Output goes to
|
||||||
|
`aurora-admin-v2-dist\aurora-admin\aurora-admin-v2.zip`.
|
||||||
|
- **Old build caching gotcha**: WordPress serves whatever plugin files were last
|
||||||
|
installed — if the user says "didn't fix it," suspect a stale zip still
|
||||||
|
installed before suspecting the code. Verify the live script hash via
|
||||||
|
`document.querySelector('script[src*="dashboard"]')` when in doubt.
|
||||||
|
- Site under test: `http://admin.local/wp-admin/`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **PHP** (`admin/src/...`, namespaced `AuroraAdmin\...`), bootstrapped from
|
||||||
|
`aurora-admin.php`, instantiated in `admin/src/App/AuroraAdmin.php`.
|
||||||
|
- **Vue 3** app (`app/src/...`), Vite multi-entry build → `manifest.json`,
|
||||||
|
read by `admin/src/Utility/Assets.php::enqueue()` which also walks the
|
||||||
|
manifest's `imports` chain to pull in shared-chunk CSS.
|
||||||
|
- **Single global stylesheet**: `app/src/shell/shell-frame.css` — defines all
|
||||||
|
`--aurora-*` CSS variables (incl. `.aurora-dark` overrides) and the only
|
||||||
|
rules that touch WP structural IDs (`#wpcontent`, `#wpbody-content`). No
|
||||||
|
element resets/utility classes leak onto native WP pages (this was the
|
||||||
|
root cause of the original rebrand's Tailwind-leakage bug — avoided here
|
||||||
|
by design).
|
||||||
|
- **Immersive shell** (`admin/src/Shell/Shell.php` + `app/src/shell/SiteShell.vue`
|
||||||
|
+ `Sidebar.vue` + `Toolbar.vue`): hides native WP chrome, mounts a Vue
|
||||||
|
sidebar/toolbar on every admin page. `MenuSerializer.php` converts WP's
|
||||||
|
`$menu`/`$submenu` globals into JSON for the sidebar.
|
||||||
|
- **Dashboard takeover** (`admin/src/Pages/DashboardTakeover.php` +
|
||||||
|
`app/src/pages/dashboard/Dashboard.vue`): hijacks native `index.php` via
|
||||||
|
`in_admin_header`, hides the native `.wrap`, renders the Aurora dashboard
|
||||||
|
in its place. 12-col grid; cards are single `<section class="aurora-card">`
|
||||||
|
elements with inline `grid-column: span N` (NOT wrapped — component
|
||||||
|
attribute fallthrough for styles was unreliable).
|
||||||
|
- **Settings page** (`admin/src/Pages/SettingsPage.php` + `app/src/pages/
|
||||||
|
settings/Settings.vue`): owns the top-level "Aurora Admin" admin menu
|
||||||
|
directly (no submenu). Two-box layout: `.settings__nav` (category list) +
|
||||||
|
`.settings__content` (fields), both boxed/bordered on a darker backdrop.
|
||||||
|
Field schema is declarative in `app/src/pages/settings/settingsConfig.js`,
|
||||||
|
rendered by `app/src/pages/settings/components/SettingsField.vue` (types:
|
||||||
|
text, toggle, color, image, repeater, multiselect, segmented, remote-sites).
|
||||||
|
- **Settings persistence**: `admin/src/Options/Settings.php` — partial-payload
|
||||||
|
save via `merge_on_save()` sanitize callback (merges into existing option,
|
||||||
|
doesn't overwrite). `Settings::get_safe()` strips `license_key` and remote
|
||||||
|
site passwords for use on non-admin-gated pages; `Settings::get()` (full)
|
||||||
|
only used on the manage_options-gated settings page itself.
|
||||||
|
- **Global search**: `app/src/shell/components/SearchModal.vue`, Ctrl+K,
|
||||||
|
debounced `wp/v2/search` REST calls, gated on `disable_global_search` setting.
|
||||||
|
- **Remote site switcher**: toolbar dropdown, gated on `canSwitchSites` (from
|
||||||
|
`remote_site_switcher_capability` setting) + non-empty `remote_sites`.
|
||||||
|
- **Scheduled content**: `app/src/pages/dashboard/cards/ScheduledContent.vue`
|
||||||
|
calendar cells (squares, date top-left, count centered) are clickable —
|
||||||
|
navigate to `edit.php?post_status=future&aurora_day=YYYY-MM-DD` if that day
|
||||||
|
has scheduled posts, else show a toast ("no scheduled content"). Server side:
|
||||||
|
`admin/src/Posts/ScheduledDayFilter.php` filters the `edit.php` query via
|
||||||
|
`pre_get_posts` and prints an info notice.
|
||||||
|
- **Analytics**: `admin/src/Analytics/Analytics.php` (first-party pageview
|
||||||
|
tracker, own DB table `wp_aurora_admin_pageviews`, bot detection, gated on
|
||||||
|
`enable_analytics`/`track_admins` settings) + `admin/src/Rest/AnalyticsData.php`
|
||||||
|
(aggregation REST endpoint: totals, series, device/referrer/country
|
||||||
|
breakdowns, bounce rate, active users).
|
||||||
|
- **White label**: `admin/src/WhiteLabel/WhiteLabel.php` — custom favicon,
|
||||||
|
rename plugin in Plugins list, rename top-level menu item, gettext
|
||||||
|
find/replace via `text_replacements` setting.
|
||||||
|
- **Native page dark styling**: `admin/assets/native-dark.css` (large static
|
||||||
|
sheet, all rules gated `html.aurora-dark.aurora-ready`, scoped under
|
||||||
|
`#wpbody-content`) + `admin/assets/native-enhance.js` (progressive JS
|
||||||
|
enhancement — currently only `enhanceUpdates()`, which groups the flat
|
||||||
|
Updates page sections into `.aurora-native-card` wrapper divs on
|
||||||
|
`body.update-core-php`).
|
||||||
|
|
||||||
|
## Key bugs fixed (root causes, in case they resurface)
|
||||||
|
|
||||||
|
- **`.card` global class collision**: original Dashboard cards used
|
||||||
|
`class="card"`, colliding with WP core's `.card { max-width: 520px }`,
|
||||||
|
capping card width regardless of grid span. Fixed by renaming to
|
||||||
|
`.aurora-card*` everywhere. Lesson: never use unprefixed class names on
|
||||||
|
Aurora components — always prefix `aurora-`.
|
||||||
|
- **Grid-column not applying on wrapped cards**: settled on unwrapped
|
||||||
|
`<section class="aurora-card" :style="{gridColumn: ...}">` — component
|
||||||
|
boundaries ate the style binding in earlier attempts.
|
||||||
|
- **Two-box settings layout**: took two wrong iterations (splitting
|
||||||
|
header/fields instead of nav/content) before matching the user's reference
|
||||||
|
image. If revisiting settings layout, re-confirm against a screenshot
|
||||||
|
before assuming intent.
|
||||||
|
- **Redundant outer frame border on settings page**: `#wpbody-content`'s
|
||||||
|
frame border/radius doubled up with the new two-box layout; fixed with a
|
||||||
|
`body.toplevel_page_aurora-admin` override in `shell-frame.css` that drops
|
||||||
|
the outer frame there only.
|
||||||
|
|
||||||
|
## Current / in-progress task
|
||||||
|
|
||||||
|
**User's last request: "now the updates page doesn't match the style as the
|
||||||
|
dashboard page."**
|
||||||
|
|
||||||
|
Status: diagnosis started but not finished. I navigated to
|
||||||
|
`update-core.php` and confirmed the page renders with the shell (minify
|
||||||
|
button, View Website/Dashboard icons, search, dark toggle) and that
|
||||||
|
`native-enhance.js` is already grouping the page into three
|
||||||
|
`.aurora-native-card` boxes (Current version / Plugins / Themes). I had
|
||||||
|
**not yet** done a side-by-side computed-style comparison against
|
||||||
|
`.aurora-card` (used on the Dashboard, e.g. "Recent content" / "Site Health"
|
||||||
|
cards) to find the actual mismatch — could be background shade, border
|
||||||
|
radius, padding, title typography, or hover state. No code changes made yet
|
||||||
|
for this specific request.
|
||||||
|
|
||||||
|
### Next step (concrete)
|
||||||
|
|
||||||
|
Via Chrome `javascript_exec`, pull `getComputedStyle()` for a live
|
||||||
|
`.aurora-card` element on the Dashboard and a live `.aurora-native-card`
|
||||||
|
element on the Updates page — compare `background-color`, `border-color`,
|
||||||
|
`border-radius`, `padding`, `box-shadow`, and heading font-size/weight. Then
|
||||||
|
patch `.aurora-native-card` rules in `admin/assets/native-dark.css` (and/or
|
||||||
|
structure in `native-enhance.js` if the mismatch is structural, e.g. missing
|
||||||
|
a title wrapper element) to match `.aurora-card` in `Dashboard.vue`. Verify
|
||||||
|
live, then rebuild/sync to dist, re-zip, and have the user reinstall.
|
||||||
|
|
||||||
|
## Not-yet-done (deferred, no active request)
|
||||||
|
|
||||||
|
- Full field sets for settings categories beyond General/Analytics/White
|
||||||
|
Label (original plugin had more).
|
||||||
|
- Extending `native-enhance.js` boxing to other native screens (Tools, etc.)
|
||||||
|
beyond Updates.
|
||||||
|
- Porting the real license-key activation flow (built previously in the old
|
||||||
|
`aurora-admin-working` project, not yet ported to v2 — currently just a
|
||||||
|
text field).
|
||||||
|
- Light-mode boxing for native pages (`native-dark.css`/`native-enhance.js`
|
||||||
|
currently only styled for dark mode; light mode native pages are untouched
|
||||||
|
WP default).
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
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 {
|
||||||
|
border-color: var(--aurora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ----------------------------------------------------------- */
|
||||||
|
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);
|
||||||
|
border-color: var(--aurora-border);
|
||||||
|
color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
border-color: var(--aurora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 16px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
html.aurora-dark.aurora-ready body.update-core-php #wpbody-content .aurora-native-card h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?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 = "1";
|
||||||
|
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("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("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 '',
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function record($action_type, $object_type, $object_id, $object_label, $details = [])
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$user = wp_get_current_user();
|
||||||
|
|
||||||
|
$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" => $user && $user->exists() ? $user->ID : null,
|
||||||
|
"user_name" => $user && $user->exists() ? $user->display_name : "",
|
||||||
|
"details" => $details ? wp_json_encode($details) : null,
|
||||||
|
"created_at" => current_time("mysql"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function on_save_post($post_id, $post, $update)
|
||||||
|
{
|
||||||
|
// save_post fires on autosaves and revisions too — neither represents a
|
||||||
|
// real user-visible change, so both are skipped here.
|
||||||
|
if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!in_array($post->post_status, ["publish", "draft", "pending", "future", "private"], true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::record($update ? "post_updated" : "post_created", "post", $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") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::record("post_deleted", "post", $post_id, $post->post_title ?: "(no title)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function on_user_register($user_id)
|
||||||
|
{
|
||||||
|
$user = get_userdata($user_id);
|
||||||
|
self::record("user_registered", "user", $user_id, $user ? $user->user_login : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function on_profile_update($user_id)
|
||||||
|
{
|
||||||
|
$user = get_userdata($user_id);
|
||||||
|
self::record("user_updated", "user", $user_id, $user ? $user->user_login : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function on_login($user_login, $user)
|
||||||
|
{
|
||||||
|
self::record("login", "user", $user->ID, $user_login);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function on_plugin_activated($plugin)
|
||||||
|
{
|
||||||
|
self::record("plugin_activated", "plugin", null, $plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function on_plugin_deactivated($plugin)
|
||||||
|
{
|
||||||
|
self::record("plugin_deactivated", "plugin", null, $plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function on_theme_switch($new_name)
|
||||||
|
{
|
||||||
|
self::record("theme_switched", "theme", null, $new_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function on_settings_updated()
|
||||||
|
{
|
||||||
|
self::record("settings_updated", "settings", null, "Aurora Admin settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?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" => [
|
||||||
|
"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 = [];
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
|
||||||
|
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql));
|
||||||
|
|
||||||
|
$sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||||
|
$rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, $offset])));
|
||||||
|
|
||||||
|
$items = array_map(function ($row) {
|
||||||
|
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" => $row->user_id ? (int) $row->user_id : null,
|
||||||
|
"userName" => $row->user_name,
|
||||||
|
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<?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"]) ? $_SERVER["HTTP_USER_AGENT"] : "";
|
||||||
|
if (self::is_bot($ua)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$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])) {
|
||||||
|
return preg_replace("/[^a-f0-9]/", "", substr($_COOKIE[self::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"]) ? $_SERVER["REQUEST_URI"] : "/";
|
||||||
|
return wp_parse_url($uri, PHP_URL_PATH) ?: "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function referrer()
|
||||||
|
{
|
||||||
|
$ref = isset($_SERVER["HTTP_REFERER"]) ? $_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.
|
||||||
|
foreach (["HTTP_CF_IPCOUNTRY", "GEOIP_COUNTRY_CODE", "HTTP_X_COUNTRY_CODE"] as $h) {
|
||||||
|
if (!empty($_SERVER[$h]) && strlen($_SERVER[$h]) === 2) {
|
||||||
|
return strtoupper(sanitize_text_field($_SERVER[$h]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
namespace AuroraAdmin\App;
|
||||||
|
|
||||||
|
use AuroraAdmin\Options\Settings;
|
||||||
|
use AuroraAdmin\Shell\Shell;
|
||||||
|
use AuroraAdmin\Pages\DashboardTakeover;
|
||||||
|
use AuroraAdmin\Pages\SettingsPage;
|
||||||
|
use AuroraAdmin\Rest\DashboardData;
|
||||||
|
use AuroraAdmin\Analytics\Analytics;
|
||||||
|
use AuroraAdmin\Rest\AnalyticsData;
|
||||||
|
use AuroraAdmin\Posts\ScheduledDayFilter;
|
||||||
|
use AuroraAdmin\WhiteLabel\WhiteLabel;
|
||||||
|
use AuroraAdmin\Pages\SubApps;
|
||||||
|
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\DatabaseExplorer\DatabaseExplorerData;
|
||||||
|
|
||||||
|
defined("ABSPATH") || exit();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 DashboardData();
|
||||||
|
new Analytics();
|
||||||
|
new AnalyticsData();
|
||||||
|
new ScheduledDayFilter();
|
||||||
|
new WhiteLabel();
|
||||||
|
new SubApps();
|
||||||
|
new MenuCreator();
|
||||||
|
new MenuCreatorData();
|
||||||
|
new Notices();
|
||||||
|
new NoticesData();
|
||||||
|
new RolesData();
|
||||||
|
new ActivityLog();
|
||||||
|
new ActivityLogData();
|
||||||
|
new DatabaseExplorerData();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
namespace AuroraAdmin\DatabaseExplorer;
|
||||||
|
|
||||||
|
defined("ABSPATH") || exit();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-heavy admin dev tool for browsing this install's real database —
|
||||||
|
* table list with row counts/sizes, paginated row browsing, and a raw
|
||||||
|
* SELECT-only query runner. manage_options-gated throughout.
|
||||||
|
*
|
||||||
|
* Table/column identifiers can't go through $wpdb->prepare() (it only
|
||||||
|
* binds values, not identifiers), so the actual safety boundary here is
|
||||||
|
* validating every identifier against $wpdb's own known table list before
|
||||||
|
* use, then backtick-escaping it — never interpolating a client-supplied
|
||||||
|
* identifier unchecked.
|
||||||
|
*/
|
||||||
|
class DatabaseExplorerData
|
||||||
|
{
|
||||||
|
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", "/db-tables", [
|
||||||
|
"methods" => "GET",
|
||||||
|
"callback" => [self::class, "list_tables"],
|
||||||
|
"permission_callback" => $permission,
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route("aurora-admin/v1", "/db-tables/(?P<table>[a-zA-Z0-9_]+)", [
|
||||||
|
"methods" => "GET",
|
||||||
|
"callback" => [self::class, "browse_table"],
|
||||||
|
"permission_callback" => $permission,
|
||||||
|
"args" => [
|
||||||
|
"page" => ["type" => "integer", "required" => false, "default" => 1],
|
||||||
|
"per_page" => ["type" => "integer", "required" => false, "default" => 25],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route("aurora-admin/v1", "/db-query", [
|
||||||
|
"methods" => "POST",
|
||||||
|
"callback" => [self::class, "run_query"],
|
||||||
|
"permission_callback" => $permission,
|
||||||
|
"args" => [
|
||||||
|
"query" => ["type" => "string", "required" => true],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function known_tables()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
return $wpdb->get_col("SHOW TABLES");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function list_tables()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$rows = $wpdb->get_results("SHOW TABLE STATUS", ARRAY_A);
|
||||||
|
|
||||||
|
$tables = array_map(function ($row) {
|
||||||
|
return [
|
||||||
|
"name" => $row["Name"],
|
||||||
|
"rows" => (int) $row["Rows"],
|
||||||
|
"sizeBytes" => (int) $row["Data_length"] + (int) $row["Index_length"],
|
||||||
|
];
|
||||||
|
}, $rows ?: []);
|
||||||
|
|
||||||
|
return new \WP_REST_Response($tables, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function browse_table($request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$table = self::validate_table($request->get_param("table"));
|
||||||
|
if (!$table) {
|
||||||
|
return new \WP_Error("invalid_table", "Unknown table", ["status" => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$escaped = self::escape_identifier($table);
|
||||||
|
$per_page = max(1, min(100, (int) $request->get_param("per_page") ?: 25));
|
||||||
|
$page = max(1, (int) $request->get_param("page") ?: 1);
|
||||||
|
$offset = ($page - 1) * $per_page;
|
||||||
|
|
||||||
|
$columns = $wpdb->get_results("SHOW COLUMNS FROM {$escaped}", ARRAY_A);
|
||||||
|
$total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$escaped}");
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
$wpdb->prepare("SELECT * FROM {$escaped} LIMIT %d OFFSET %d", $per_page, $offset),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
return new \WP_REST_Response([
|
||||||
|
"columns" => array_map(function ($c) {
|
||||||
|
return ["name" => $c["Field"], "type" => $c["Type"]];
|
||||||
|
}, $columns),
|
||||||
|
"rows" => $rows,
|
||||||
|
"total" => $total,
|
||||||
|
"page" => $page,
|
||||||
|
"perPage" => $per_page,
|
||||||
|
"totalPages" => (int) ceil($total / $per_page),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function run_query($request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$query = trim((string) $request->get_param("query"));
|
||||||
|
|
||||||
|
if (!preg_match('/^(SELECT|SHOW|DESCRIBE|EXPLAIN)\b/i', $query)) {
|
||||||
|
return new \WP_Error(
|
||||||
|
"unsafe_query",
|
||||||
|
"Only SELECT / SHOW / DESCRIBE / EXPLAIN queries can be run here.",
|
||||||
|
["status" => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Guard against stacked statements — a single semicolon at the very
|
||||||
|
// end is fine, anything after it is not.
|
||||||
|
if (preg_match('/;.*\S/s', $query)) {
|
||||||
|
return new \WP_Error("unsafe_query", "Multiple statements aren't allowed.", ["status" => 400]);
|
||||||
|
}
|
||||||
|
if (!preg_match('/\bLIMIT\s+\d+/i', $query)) {
|
||||||
|
$query = rtrim(rtrim($query), ";") . " LIMIT 200";
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $wpdb->get_results($query, ARRAY_A);
|
||||||
|
if ($rows === null) {
|
||||||
|
return new \WP_Error("query_error", $wpdb->last_error ?: "Query failed", ["status" => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \WP_REST_Response(["rows" => $rows, "count" => count($rows)], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function validate_table($table)
|
||||||
|
{
|
||||||
|
$table = preg_replace("/[^a-zA-Z0-9_]/", "", (string) $table);
|
||||||
|
return in_array($table, self::known_tables(), true) ? $table : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function escape_identifier($identifier)
|
||||||
|
{
|
||||||
|
return "`" . str_replace("`", "``", $identifier) . "`";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?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";
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
add_action("init", [self::class, "maybe_install"]);
|
||||||
|
// Late priority so it runs after all other plugins have registered
|
||||||
|
// their own menu items — there's nothing left to reorder/hide otherwise.
|
||||||
|
add_action("admin_menu", [self::class, "apply"], PHP_INT_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies every published config whose target roles match the current
|
||||||
|
* user: hides items marked hidden and relabels items with a custom label.
|
||||||
|
*/
|
||||||
|
public static function apply()
|
||||||
|
{
|
||||||
|
global $menu, $wpdb;
|
||||||
|
|
||||||
|
if (!is_admin() || !function_exists("wp_get_current_user")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = wp_get_current_user();
|
||||||
|
if (!$user || !$user->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = self::table_name();
|
||||||
|
$configs = $wpdb->get_results("SELECT roles, items FROM {$table} WHERE status = 'published'");
|
||||||
|
if (!$configs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($configs as $config) {
|
||||||
|
$roles = json_decode($config->roles ?: "[]", true);
|
||||||
|
if (!is_array($roles) || !$roles) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!array_intersect((array) $user->roles, $roles)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = json_decode($config->items ?: "[]", true);
|
||||||
|
if (!is_array($items)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = [];
|
||||||
|
foreach ($menu as $item) {
|
||||||
|
if (!empty($item["separator"]) || empty($item["id"])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$flat[] = ["native_id" => $item["id"], "title" => $item["title"], "icon" => $item["icon"]];
|
||||||
|
foreach ($item["submenu"] ?? [] as $sub) {
|
||||||
|
if (empty($sub["id"])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$flat[] = ["native_id" => $sub["id"], "title" => $item["title"] . " › " . $sub["title"], "icon" => null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
$rows = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT * FROM {$table} WHERE status = %s ORDER BY updated_at DESC",
|
||||||
|
$status
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
$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;
|
||||||
|
$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");
|
||||||
|
|
||||||
|
$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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::get_one(new \WP_REST_Request("GET", "", ["id" => $wpdb->insert_id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
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")));
|
||||||
|
}
|
||||||
|
|
||||||
|
$wpdb->update(MenuCreator::table_name(), $fields, ["id" => $id]);
|
||||||
|
|
||||||
|
return self::get_one($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function delete($request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
namespace AuroraAdmin\Notices;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$user = wp_get_current_user();
|
||||||
|
if (!$user || !$user->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current_page = isset($GLOBALS["pagenow"]) ? $GLOBALS["pagenow"] : "";
|
||||||
|
$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 ($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) : ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?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;
|
||||||
|
$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");
|
||||||
|
$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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$wpdb->update(Notices::table_name(), $fields, ["id" => $id]);
|
||||||
|
|
||||||
|
$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;
|
||||||
|
$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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?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"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 with secrets stripped (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" => [
|
||||||
|
"dark_mode" => ["type" => "string"],
|
||||||
|
"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 secrets: the license key and remote
|
||||||
|
* site application passwords. The settings page itself (manage_options
|
||||||
|
* only) uses get() and receives the full value.
|
||||||
|
*/
|
||||||
|
public static function get_safe()
|
||||||
|
{
|
||||||
|
$settings = self::get();
|
||||||
|
|
||||||
|
unset($settings["license_key"]);
|
||||||
|
|
||||||
|
if (isset($settings["remote_sites"]) && is_array($settings["remote_sites"])) {
|
||||||
|
$settings["remote_sites"] = array_values(array_map(function ($site) {
|
||||||
|
return [
|
||||||
|
"url" => isset($site["url"]) ? $site["url"] : "",
|
||||||
|
"username" => isset($site["username"]) ? $site["username"] : "",
|
||||||
|
];
|
||||||
|
}, $settings["remote_sites"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
namespace AuroraAdmin\Pages;
|
||||||
|
|
||||||
|
use AuroraAdmin\Utility\Assets;
|
||||||
|
use AuroraAdmin\Options\Settings;
|
||||||
|
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
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-root"
|
||||||
|
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
|
||||||
|
></div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
namespace AuroraAdmin\Pages;
|
||||||
|
|
||||||
|
use AuroraAdmin\Utility\Assets;
|
||||||
|
|
||||||
|
defined("ABSPATH") || exit();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers Aurora's 5 secondary full-page apps (Menu Creator, Admin
|
||||||
|
* Notices, Role Editor, Activity Log, Database Explorer). 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" — then
|
||||||
|
* immediately hidden from WordPress's own submenu via remove_submenu_page(),
|
||||||
|
* the standard register-but-hide trick. This keeps them out of the
|
||||||
|
* WP-native menu list (MenuSerializer never sees them, nothing to filter)
|
||||||
|
* while still being reachable, permission-checked pages; they're presented
|
||||||
|
* instead in the sidebar's dedicated Aurora nav section (see Shell::aurora_nav()).
|
||||||
|
*/
|
||||||
|
class SubApps
|
||||||
|
{
|
||||||
|
const PAGES = [
|
||||||
|
"aurora-admin-menu-creator" => ["title" => "Menu Creator", "entry" => "menu-creator"],
|
||||||
|
"aurora-admin-notices" => ["title" => "Admin Notices", "entry" => "admin-notices"],
|
||||||
|
"aurora-admin-roles" => ["title" => "Role Editor", "entry" => "role-editor"],
|
||||||
|
"aurora-admin-activity-log" => ["title" => "Activity Log", "entry" => "activity-log"],
|
||||||
|
"aurora-admin-database" => ["title" => "Database Explorer", "entry" => "database-explorer"],
|
||||||
|
];
|
||||||
|
|
||||||
|
private static $hook_suffixes = [];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
add_action("admin_menu", [self::class, "register_menus"]);
|
||||||
|
add_action("admin_menu", [self::class, "hide_from_native_submenu"], 999);
|
||||||
|
add_action("admin_enqueue_scripts", [self::class, "maybe_enqueue"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register_menus()
|
||||||
|
{
|
||||||
|
foreach (self::PAGES as $slug => $page) {
|
||||||
|
$hook = add_submenu_page(
|
||||||
|
"aurora-admin",
|
||||||
|
__($page["title"], "aurora-admin"),
|
||||||
|
__($page["title"], "aurora-admin"),
|
||||||
|
"manage_options",
|
||||||
|
$slug,
|
||||||
|
[self::class, "render"]
|
||||||
|
);
|
||||||
|
self::$hook_suffixes[$slug] = $hook;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hide_from_native_submenu()
|
||||||
|
{
|
||||||
|
foreach (array_keys(self::PAGES) as $slug) {
|
||||||
|
remove_submenu_page("aurora-admin", $slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function maybe_enqueue($hook_suffix)
|
||||||
|
{
|
||||||
|
$slug = array_search($hook_suffix, self::$hook_suffixes, true);
|
||||||
|
if ($slug === false || !isset(self::PAGES[$slug])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assets::enqueue("aurora-admin-" . self::PAGES[$slug]["entry"], "entries/" . self::PAGES[$slug]["entry"] . ".js");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render()
|
||||||
|
{
|
||||||
|
$page = isset($_GET["page"]) ? sanitize_text_field(wp_unslash($_GET["page"])) : "";
|
||||||
|
if (!isset(self::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,
|
||||||
|
];
|
||||||
|
$root_id = "aurora-admin-" . self::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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?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()
|
||||||
|
{
|
||||||
|
$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>"
|
||||||
|
); ?>
|
||||||
|
<a href="<?php echo esc_url($all); ?>"><?php esc_html_e("View all scheduled", "aurora-admin"); ?></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?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 ---
|
||||||
|
$total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE {$where}");
|
||||||
|
$unique = (int) $wpdb->get_var("SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE {$where}");
|
||||||
|
|
||||||
|
// --- Daily series ---
|
||||||
|
$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 ---
|
||||||
|
$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 ---
|
||||||
|
$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);
|
||||||
|
$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} <> ''" : "";
|
||||||
|
$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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("edit-comments.php"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?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()
|
||||||
|
{
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
namespace AuroraAdmin\Shell;
|
||||||
|
|
||||||
|
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] : "";
|
||||||
|
|
||||||
|
$entry["submenu"][] = [
|
||||||
|
"id" => $sub_slug,
|
||||||
|
"title" => $sub_title["title"],
|
||||||
|
"url" => self::resolve_url($sub_slug, $slug),
|
||||||
|
"count" => $sub_title["count"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<?php
|
||||||
|
namespace AuroraAdmin\Shell;
|
||||||
|
|
||||||
|
use AuroraAdmin\Utility\Assets;
|
||||||
|
use AuroraAdmin\Options\Settings;
|
||||||
|
|
||||||
|
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 a few
|
||||||
|
* full-screen native experiences that own the whole viewport.
|
||||||
|
*/
|
||||||
|
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, ["site-editor", "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()
|
||||||
|
{
|
||||||
|
$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.
|
||||||
|
return isset($GLOBALS["pagenow"]) && $GLOBALS["pagenow"] === "index.php";
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Who may see the remote-site switcher in the toolbar (Settings >
|
||||||
|
// General > Remote Site Switcher Capability, default manage_options).
|
||||||
|
$switcher_cap = Settings::get("remote_site_switcher_capability", "");
|
||||||
|
if (!is_string($switcher_cap) || trim($switcher_cap) === "") {
|
||||||
|
$switcher_cap = "manage_options";
|
||||||
|
}
|
||||||
|
|
||||||
|
$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()),
|
||||||
|
// Secrets stripped — this is serialized for every admin user.
|
||||||
|
"settings" => Settings::get_safe(),
|
||||||
|
"canSwitchSites" => current_user_can($switcher_cap),
|
||||||
|
"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"]) ? $_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-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-database", "title" => __("Database Explorer", "aurora-admin"), "icon" => "dashicons-database"],
|
||||||
|
];
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1363
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "aurora-admin-app",
|
||||||
|
"version": "2.0.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['update:modelValue']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label class="toggle">
|
||||||
|
<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"
|
||||||
|
@click="$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__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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import '@/shell/shell-frame.css';
|
||||||
|
import DatabaseExplorer from '@/pages/database-explorer/DatabaseExplorer.vue';
|
||||||
|
|
||||||
|
const mountEl = document.getElementById('aurora-admin-database-explorer-root');
|
||||||
|
|
||||||
|
if (mountEl) {
|
||||||
|
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
|
||||||
|
createApp(DatabaseExplorer, { serverData: data }).mount(mountEl);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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: 12px;
|
||||||
|
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: 9px;
|
||||||
|
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>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<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;
|
||||||
|
min-height: calc(100vh - var(--aurora-toolbar-h) - 60px);
|
||||||
|
margin: -32px;
|
||||||
|
padding: 22px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--aurora-app-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left — list box */
|
||||||
|
.app-list-shell__nav {
|
||||||
|
width: 340px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--aurora-frame-bg);
|
||||||
|
border: 1px solid var(--aurora-frame-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
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: 7px;
|
||||||
|
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: 10px; 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) {
|
||||||
|
background: var(--aurora-text); color: var(--aurora-app-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 — content box */
|
||||||
|
.app-list-shell__content {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
background: var(--aurora-frame-bg);
|
||||||
|
border: 1px solid var(--aurora-frame-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
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: 14px;
|
||||||
|
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) {
|
||||||
|
.app-list-shell { flex-direction: column; }
|
||||||
|
.app-list-shell__nav { width: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, 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) =>
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ACTION_TYPES = [
|
||||||
|
['', 'All Actions'],
|
||||||
|
['post_created', 'Post created'],
|
||||||
|
['post_updated', 'Post updated'],
|
||||||
|
['post_deleted', 'Post deleted'],
|
||||||
|
['user_registered', 'User registered'],
|
||||||
|
['user_updated', 'User updated'],
|
||||||
|
['login', 'Login'],
|
||||||
|
['plugin_activated', 'Plugin activated'],
|
||||||
|
['plugin_deactivated', 'Plugin deactivated'],
|
||||||
|
['theme_switched', 'Theme switched'],
|
||||||
|
['settings_updated', 'Settings updated'],
|
||||||
|
];
|
||||||
|
const OBJECT_TYPES = [
|
||||||
|
['', 'All Types'],
|
||||||
|
['post', 'Post'],
|
||||||
|
['user', 'User'],
|
||||||
|
['plugin', 'Plugin'],
|
||||||
|
['theme', 'Theme'],
|
||||||
|
['settings', 'Settings'],
|
||||||
|
];
|
||||||
|
|
||||||
|
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 selectedId = ref(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (actionFilter.value) params.set('action_type', actionFilter.value);
|
||||||
|
if (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 selected = computed(() => items.value.find((i) => i.id === selectedId.value) || null);
|
||||||
|
|
||||||
|
const formatDate = (s) => (s ? new Date(s.replace(' ', 'T')).toLocaleString() : '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppListShell
|
||||||
|
v-model:search="search"
|
||||||
|
title="Activity Log"
|
||||||
|
search-placeholder="Search activities…"
|
||||||
|
:count-label="`${total} ITEMS`"
|
||||||
|
:page="page"
|
||||||
|
:has-prev="page > 1"
|
||||||
|
:has-next="page < totalPages"
|
||||||
|
detail-icon="dashicons-clock"
|
||||||
|
detail-title="Activity Details"
|
||||||
|
detail-text="Select an activity from the list to view detailed information."
|
||||||
|
:has-selection="!!selected"
|
||||||
|
@prev-page="page--"
|
||||||
|
@next-page="page++"
|
||||||
|
>
|
||||||
|
<template #header-actions>
|
||||||
|
<button type="button" @click="load">
|
||||||
|
<span class="dashicons dashicons-update" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #filters>
|
||||||
|
<select v-model="actionFilter" class="activity-log__select">
|
||||||
|
<option v-for="[v, l] in ACTION_TYPES" :key="v" :value="v">{{ l }}</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="objectFilter" class="activity-log__select">
|
||||||
|
<option v-for="[v, l] in OBJECT_TYPES" :key="v" :value="v">{{ l }}</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #list>
|
||||||
|
<p v-if="!loading && !items.length" style="font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 20px 4px; text-align: center;">
|
||||||
|
No activities found
|
||||||
|
</p>
|
||||||
|
<AppListItem
|
||||||
|
v-for="i in items"
|
||||||
|
:key="i.id"
|
||||||
|
icon="dashicons-clock"
|
||||||
|
:title="i.objectLabel || i.actionType"
|
||||||
|
:subtitle="`${i.userName || 'System'} · ${formatDate(i.createdAt)}`"
|
||||||
|
:active="i.id === selectedId"
|
||||||
|
@click="selectedId = i.id"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #detail>
|
||||||
|
<div v-if="selected" class="activity-detail">
|
||||||
|
<h2>{{ selected.objectLabel || selected.actionType }}</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Action</dt><dd>{{ selected.actionType }}</dd>
|
||||||
|
<dt>Object type</dt><dd>{{ selected.objectType }}</dd>
|
||||||
|
<dt>User</dt><dd>{{ selected.userName || 'System' }}</dd>
|
||||||
|
<dt>Date</dt><dd>{{ formatDate(selected.createdAt) }}</dd>
|
||||||
|
</dl>
|
||||||
|
<pre v-if="selected.details">{{ JSON.stringify(selected.details, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</AppListShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.activity-log__select {
|
||||||
|
padding: 5px 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||||
|
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.activity-detail h2 { margin: 0 0 16px; font-size: 1.1rem; color: var(--aurora-text); }
|
||||||
|
.activity-detail dl { display: grid; grid-template-columns: 120px 1fr; gap: 8px 12px; font-size: 0.8125rem; margin: 0; }
|
||||||
|
.activity-detail dt { color: var(--aurora-text-muted); }
|
||||||
|
.activity-detail dd { margin: 0; color: var(--aurora-text); }
|
||||||
|
.activity-detail pre {
|
||||||
|
margin-top: 18px; padding: 12px; background: var(--aurora-bg-subtle);
|
||||||
|
border-radius: 8px; font-size: 0.75rem; overflow-x: auto; 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: 8px;
|
||||||
|
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: 8px; 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: 8px; 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>
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
<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, 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 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: 10px;
|
||||||
|
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: 10px;
|
||||||
|
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: 6px; 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: 10px;
|
||||||
|
padding: 3px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.dash__tab {
|
||||||
|
border: none; background: none; cursor: pointer;
|
||||||
|
padding: 6px 16px; border-radius: 8px;
|
||||||
|
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: 14px;
|
||||||
|
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>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup>
|
||||||
|
import AuroraCard from '../components/AuroraCard.vue';
|
||||||
|
import StatRow from '../components/StatRow.vue';
|
||||||
|
|
||||||
|
defineProps({ data: { type: Object, default: () => ({}) } });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuroraCard title="Recent Comments" subtitle="Latest comments and discussions">
|
||||||
|
<template #head-right>
|
||||||
|
<span class="dot dot--accent" /> {{ data.total || 0 }} total
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<StatRow
|
||||||
|
:stats="[
|
||||||
|
{ value: data.approved || 0, label: 'Approved' },
|
||||||
|
{ value: data.pending || 0, label: 'Pending' },
|
||||||
|
{ value: data.spam || 0, label: 'Spam' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ul class="cm__list">
|
||||||
|
<li v-for="c in data.latest" :key="c.id" class="cm__item">
|
||||||
|
<p class="cm__excerpt">{{ c.excerpt }}</p>
|
||||||
|
<div class="cm__meta">on <span class="cm__post">{{ c.postTitle }}</span> · {{ c.date }}</div>
|
||||||
|
</li>
|
||||||
|
<li v-if="!data.latest || !data.latest.length" class="cm__empty">No comments yet.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<a :href="data.listUrl" class="viewall">View all ›</a>
|
||||||
|
</template>
|
||||||
|
</AuroraCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cm__list { list-style: none; margin: 16px 0 0; padding: 0; }
|
||||||
|
.cm__item { padding: 8px 0; }
|
||||||
|
.cm__excerpt { margin: 0; font-size: 0.8125rem; color: var(--aurora-text); }
|
||||||
|
.cm__meta { margin-top: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); }
|
||||||
|
.cm__post { color: var(--aurora-accent); }
|
||||||
|
.cm__empty { font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 12px 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup>
|
||||||
|
import AuroraCard from '../components/AuroraCard.vue';
|
||||||
|
import StatRow from '../components/StatRow.vue';
|
||||||
|
|
||||||
|
const props = defineProps({ data: { type: Object, default: () => ({}) } });
|
||||||
|
|
||||||
|
const statusLabel = (s) =>
|
||||||
|
({ publish: 'Published', draft: 'Draft', future: 'Scheduled' }[s] || s);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuroraCard title="Recent content" subtitle="Latest content activity">
|
||||||
|
<template #head-right>
|
||||||
|
<span class="dot dot--accent" /> {{ data.total || 0 }} total
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<StatRow
|
||||||
|
:stats="[
|
||||||
|
{ value: data.published || 0, label: 'Published' },
|
||||||
|
{ value: data.draft || 0, label: 'Draft' },
|
||||||
|
{ value: data.scheduled || 0, label: 'Scheduled' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ul class="rc__list">
|
||||||
|
<li v-for="p in data.latest" :key="p.id" class="rc__item">
|
||||||
|
<div class="rc__main">
|
||||||
|
<a :href="p.editUrl" class="rc__title">{{ p.title || '(no title)' }}</a>
|
||||||
|
<div class="rc__meta">{{ p.author }} · {{ p.date }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="rc__badge" :class="`rc__badge--${p.status}`">
|
||||||
|
{{ statusLabel(p.status) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li v-if="!data.latest || !data.latest.length" class="rc__empty">No content yet.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<a :href="data.listUrl" class="viewall">View all ›</a>
|
||||||
|
</template>
|
||||||
|
</AuroraCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rc__list { list-style: none; margin: 16px 0 0; padding: 0; }
|
||||||
|
.rc__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.rc__title {
|
||||||
|
color: var(--aurora-text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.rc__title:hover { color: var(--aurora-accent); }
|
||||||
|
.rc__meta { font-size: 0.75rem; color: var(--aurora-text-muted); margin-top: 2px; }
|
||||||
|
.rc__badge {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.rc__badge--publish { color: #34d399; }
|
||||||
|
.rc__badge--future { color: var(--aurora-accent); }
|
||||||
|
.rc__badge--draft { color: var(--aurora-text-muted); }
|
||||||
|
.rc__empty { font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 12px 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import AuroraCard from '../components/AuroraCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps({ data: { type: Object, default: () => ({}) } });
|
||||||
|
|
||||||
|
const view = ref(new Date());
|
||||||
|
|
||||||
|
const monthLabel = computed(() =>
|
||||||
|
view.value.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const weekdays = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
||||||
|
|
||||||
|
// Build a calendar grid (Mon-first) for the viewed month.
|
||||||
|
const grid = computed(() => {
|
||||||
|
const year = view.value.getFullYear();
|
||||||
|
const month = view.value.getMonth();
|
||||||
|
const first = new Date(year, month, 1);
|
||||||
|
const startOffset = (first.getDay() + 6) % 7; // Mon = 0
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const cells = [];
|
||||||
|
for (let i = 0; i < startOffset; i++) cells.push(null);
|
||||||
|
const today = new Date();
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||||
|
const scheduled = props.data.days && props.data.days[key] ? props.data.days[key].length : 0;
|
||||||
|
const isToday =
|
||||||
|
d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
|
||||||
|
cells.push({ d, key, scheduled, isToday });
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
});
|
||||||
|
|
||||||
|
const move = (delta) => {
|
||||||
|
const next = new Date(view.value);
|
||||||
|
next.setMonth(next.getMonth() + delta);
|
||||||
|
view.value = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clicking a day: with scheduled content, open the posts list filtered to
|
||||||
|
// that day; otherwise show a transient popup on the dashboard.
|
||||||
|
const toast = ref('');
|
||||||
|
let toastTimer;
|
||||||
|
const onDayClick = (cell) => {
|
||||||
|
if (!cell) return;
|
||||||
|
if (cell.scheduled > 0) {
|
||||||
|
// Relative to /wp-admin/ (the dashboard is at index.php).
|
||||||
|
window.location.href = `edit.php?post_status=future&aurora_day=${cell.key}`;
|
||||||
|
} else {
|
||||||
|
toast.value = 'No scheduled content for this day.';
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => (toast.value = ''), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuroraCard title="Scheduled Content" :subtitle="monthLabel">
|
||||||
|
<template #head-right>
|
||||||
|
<div class="sc__nav">
|
||||||
|
<button type="button" @click="move(-1)" aria-label="Previous month">‹</button>
|
||||||
|
<button type="button" @click="move(1)" aria-label="Next month">›</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="sc__count">
|
||||||
|
<span class="sc__count-num">{{ data.count || 0 }}</span> Scheduled Content
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sc__weekdays">
|
||||||
|
<span v-for="(w, i) in weekdays" :key="i">{{ w }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sc__grid">
|
||||||
|
<div v-for="(cell, i) in grid" :key="i" class="sc__cell">
|
||||||
|
<button
|
||||||
|
v-if="cell"
|
||||||
|
type="button"
|
||||||
|
class="sc__day"
|
||||||
|
:class="{ 'sc__day--has': cell.scheduled > 0, 'sc__day--today': cell.isToday }"
|
||||||
|
:title="cell.scheduled > 0 ? `${cell.scheduled} scheduled` : 'No scheduled content'"
|
||||||
|
@click="onDayClick(cell)"
|
||||||
|
>
|
||||||
|
<span class="sc__date">{{ cell.d }}</span>
|
||||||
|
<span v-if="cell.scheduled > 0" class="sc__count">{{ cell.scheduled }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="sc-toast">
|
||||||
|
<div v-if="toast" class="sc__toast">{{ toast }}</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</AuroraCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sc__nav { display: flex; gap: 4px; }
|
||||||
|
.sc__nav button {
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
border: 1px solid var(--aurora-card-border);
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.sc__nav button:hover { color: var(--aurora-text); }
|
||||||
|
.sc__count { font-size: 0.875rem; color: var(--aurora-text-muted); margin-bottom: 16px; }
|
||||||
|
.sc__count-num { font-size: 1.5rem; font-weight: 700; color: var(--aurora-text); margin-right: 6px; }
|
||||||
|
.sc__weekdays {
|
||||||
|
display: grid; grid-template-columns: repeat(7, 1fr);
|
||||||
|
font-size: 0.6875rem; color: var(--aurora-text-muted); margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.sc__weekdays span { text-align: center; }
|
||||||
|
.sc__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; }
|
||||||
|
.sc__cell { aspect-ratio: 1; }
|
||||||
|
.sc__day {
|
||||||
|
position: relative;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--aurora-card-border);
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.12s ease, background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
.sc__day:hover { border-color: var(--aurora-accent); }
|
||||||
|
.sc__day--has:hover { filter: brightness(1.08); }
|
||||||
|
.sc__date {
|
||||||
|
position: absolute; top: 5px; left: 6px;
|
||||||
|
font-size: 0.7rem; line-height: 1; color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
.sc__count {
|
||||||
|
font-size: 1.05rem; font-weight: 700; color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
.sc__day--has { background: var(--aurora-accent); border-color: var(--aurora-accent); }
|
||||||
|
.sc__day--has .sc__date { color: rgba(255, 255, 255, 0.85); }
|
||||||
|
.sc__day--has .sc__count { color: #fff; }
|
||||||
|
.sc__day--today { border-color: var(--aurora-accent); }
|
||||||
|
.sc__day--today .sc__date { color: var(--aurora-accent); }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Toast is teleported to <body>, so these rules are global (not scoped).
|
||||||
|
They rely on the global --aurora-* theme variables. */
|
||||||
|
.sc__toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 100001;
|
||||||
|
background: var(--aurora-card-bg, #1f1f23);
|
||||||
|
color: var(--aurora-text, #f4f4f5);
|
||||||
|
border: 1px solid var(--aurora-border, #2e2e33);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.sc-toast-enter-active,
|
||||||
|
.sc-toast-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||||
|
.sc-toast-enter-from,
|
||||||
|
.sc-toast-leave-to { opacity: 0; transform: translate(-50%, 10px); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup>
|
||||||
|
import AuroraCard from '../components/AuroraCard.vue';
|
||||||
|
import StatRow from '../components/StatRow.vue';
|
||||||
|
import ProgressBar from '../components/ProgressBar.vue';
|
||||||
|
|
||||||
|
defineProps({ data: { type: Object, default: () => ({}) } });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuroraCard title="Server Health" subtitle="System performance and updates">
|
||||||
|
<template #head-right>
|
||||||
|
<span class="dot dot--green" /> {{ data.updates || 0 }} updates
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="sh__versions">
|
||||||
|
<div class="sh__ver">
|
||||||
|
<div class="sh__ver-label">‹› WordPress</div>
|
||||||
|
<div class="sh__ver-num">{{ data.wp || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sh__ver">
|
||||||
|
<div class="sh__ver-label">⚙ PHP</div>
|
||||||
|
<div class="sh__ver-num">{{ data.php || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sh__bars">
|
||||||
|
<ProgressBar
|
||||||
|
label="Memory Usage"
|
||||||
|
icon="◍"
|
||||||
|
:pct="data.memory ? data.memory.pct : 0"
|
||||||
|
:detail="data.memory ? `${data.memory.used} / ${data.memory.total}` : ''"
|
||||||
|
/>
|
||||||
|
<ProgressBar
|
||||||
|
label="Disk Space"
|
||||||
|
icon="▤"
|
||||||
|
gradient
|
||||||
|
:pct="data.disk ? data.disk.pct : 0"
|
||||||
|
:detail="data.disk ? `${data.disk.used} / ${data.disk.total}` : ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatRow
|
||||||
|
:stats="[
|
||||||
|
{ value: data.plugins || 0, label: 'Plugins' },
|
||||||
|
{ value: data.themes || 0, label: 'Themes' },
|
||||||
|
{ value: data.core || 0, label: 'Core' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="sh__foot">
|
||||||
|
<span>{{ data.ssl ? '🔒 SSL enabled' : '⚠ SSL disabled' }}</span>
|
||||||
|
<span>🕓 Timezone: {{ data.timezone || 'UTC' }}</span>
|
||||||
|
</div>
|
||||||
|
</AuroraCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sh__versions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
|
||||||
|
.sh__ver {
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.sh__ver-label { font-size: 0.6875rem; color: var(--aurora-text-muted); }
|
||||||
|
.sh__ver-num { font-size: 1rem; font-weight: 600; color: var(--aurora-text); margin-top: 2px; }
|
||||||
|
.sh__bars { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; }
|
||||||
|
.sh__foot {
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
margin-top: 16px; font-size: 0.6875rem; color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup>
|
||||||
|
import AuroraCard from '../components/AuroraCard.vue';
|
||||||
|
import BarList from '../components/BarList.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
title: { type: String, required: true },
|
||||||
|
subtitle: { type: String, default: '' },
|
||||||
|
items: { type: Array, default: () => [] },
|
||||||
|
emptyText: { type: String, default: 'No data in this range yet.' },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuroraCard :title="title" :subtitle="subtitle">
|
||||||
|
<BarList :items="items" :empty-text="emptyText" />
|
||||||
|
</AuroraCard>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<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="User Analytics" subtitle="User registration trends">
|
||||||
|
<StatRow
|
||||||
|
:boxed="false"
|
||||||
|
:stats="[
|
||||||
|
{ value: data.total || 0, label: 'Total Users' },
|
||||||
|
{ value: data.inRange || 0, label: 'In Range' },
|
||||||
|
{ value: data.recent7d || 0, label: 'Recent (7d)' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<div class="ua__chart">
|
||||||
|
<AreaChart :series="data.series || []" :height="150" />
|
||||||
|
</div>
|
||||||
|
</AuroraCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ua__chart { margin-top: 20px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
// series: [{ date, count }]
|
||||||
|
const props = defineProps({
|
||||||
|
series: { type: Array, default: () => [] },
|
||||||
|
height: { type: Number, default: 140 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const W = 600;
|
||||||
|
const PAD = 10;
|
||||||
|
|
||||||
|
const view = computed(() => {
|
||||||
|
const s = props.series;
|
||||||
|
if (!s.length) return null;
|
||||||
|
const max = Math.max(1, ...s.map((d) => d.count));
|
||||||
|
const H = props.height;
|
||||||
|
const innerW = W - PAD * 2;
|
||||||
|
const innerH = H - PAD * 2;
|
||||||
|
const stepX = s.length > 1 ? innerW / (s.length - 1) : 0;
|
||||||
|
const points = s.map((d, i) => {
|
||||||
|
const x = PAD + i * stepX;
|
||||||
|
const y = PAD + innerH - (d.count / max) * innerH;
|
||||||
|
return [x, y];
|
||||||
|
});
|
||||||
|
const line = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
|
||||||
|
const area = `${line} L${points[points.length - 1][0].toFixed(1)},${(H - PAD).toFixed(1)} L${points[0][0].toFixed(1)},${(H - PAD).toFixed(1)} Z`;
|
||||||
|
return { points, line, area, H };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="areachart" v-if="view">
|
||||||
|
<svg :viewBox="`0 0 ${W} ${view.H}`" preserveAspectRatio="none" class="areachart__svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="aurora-area-grad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="var(--aurora-accent)" stop-opacity="0.35" />
|
||||||
|
<stop offset="100%" stop-color="var(--aurora-accent)" stop-opacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path :d="view.area" fill="url(#aurora-area-grad)" />
|
||||||
|
<path :d="view.line" fill="none" stroke="var(--aurora-accent)" stroke-width="2"
|
||||||
|
vector-effect="non-scaling-stroke" />
|
||||||
|
<circle v-for="(p, i) in view.points" :key="i" :cx="p[0]" :cy="p[1]" r="3"
|
||||||
|
fill="var(--aurora-accent)" vector-effect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
<div class="areachart__labels">
|
||||||
|
<span v-for="(d, i) in series" :key="i">{{ d.date }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.areachart__svg {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.areachart__labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
.areachart__labels span {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.areachart__labels span:first-child { text-align: left; }
|
||||||
|
.areachart__labels span:last-child { text-align: right; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: { type: String, required: true },
|
||||||
|
subtitle: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="acard">
|
||||||
|
<header class="acard__head">
|
||||||
|
<div class="acard__titles">
|
||||||
|
<h2 class="acard__title">{{ title }}</h2>
|
||||||
|
<p v-if="subtitle" class="acard__subtitle">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="acard__head-right">
|
||||||
|
<slot name="head-right" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="acard__body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<footer v-if="$slots.footer" class="acard__foot">
|
||||||
|
<slot name="footer" />
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.acard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--aurora-card-bg);
|
||||||
|
border: 1px solid var(--aurora-card-border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.acard__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.acard__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
.acard__subtitle {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
.acard__head-right {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
.acard__body {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.acard__foot {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
// items: [{ label, count }]
|
||||||
|
const props = defineProps({
|
||||||
|
items: { type: Array, default: () => [] },
|
||||||
|
emptyText: { type: String, default: 'No data yet.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const max = computed(() => Math.max(1, ...props.items.map((i) => i.count)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="barlist">
|
||||||
|
<div v-for="(it, i) in items" :key="i" class="barlist__row">
|
||||||
|
<div class="barlist__head">
|
||||||
|
<span class="barlist__label" :title="it.label">{{ it.label }}</span>
|
||||||
|
<span class="barlist__count">{{ it.count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="barlist__track">
|
||||||
|
<div class="barlist__fill" :style="{ width: (it.count / max) * 100 + '%' }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!items.length" class="barlist__empty">{{ emptyText }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.barlist__row { margin-bottom: 12px; }
|
||||||
|
.barlist__head {
|
||||||
|
display: flex; justify-content: space-between; gap: 12px; margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.barlist__label {
|
||||||
|
font-size: 0.8125rem; color: var(--aurora-text);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.barlist__count { font-size: 0.8125rem; color: var(--aurora-text-muted); flex-shrink: 0; }
|
||||||
|
.barlist__track {
|
||||||
|
height: 6px; border-radius: 999px;
|
||||||
|
background: rgba(127,127,127,0.18); overflow: hidden;
|
||||||
|
}
|
||||||
|
.barlist__fill { height: 100%; border-radius: 999px; background: var(--aurora-accent); }
|
||||||
|
.barlist__empty { font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 8px 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
label: { type: String, required: true },
|
||||||
|
pct: { type: Number, default: 0 },
|
||||||
|
detail: { type: String, default: '' },
|
||||||
|
gradient: { type: Boolean, default: false },
|
||||||
|
icon: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pbar">
|
||||||
|
<div class="pbar__top">
|
||||||
|
<span class="pbar__label">
|
||||||
|
<span v-if="icon" class="pbar__icon">{{ icon }}</span>{{ label }}
|
||||||
|
</span>
|
||||||
|
<span class="pbar__pct">{{ pct }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="pbar__track">
|
||||||
|
<div
|
||||||
|
class="pbar__fill"
|
||||||
|
:class="{ 'pbar__fill--gradient': gradient }"
|
||||||
|
:style="{ width: Math.min(100, pct) + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="detail" class="pbar__detail">{{ detail }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pbar {
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.pbar__top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.pbar__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
.pbar__icon { color: var(--aurora-text-muted); }
|
||||||
|
.pbar__pct {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
.pbar__track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(127, 127, 127, 0.18);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pbar__fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--aurora-accent);
|
||||||
|
}
|
||||||
|
.pbar__fill--gradient {
|
||||||
|
background: linear-gradient(90deg, #4f7bf5 0%, #a855f7 55%, #ef4444 100%);
|
||||||
|
}
|
||||||
|
.pbar__detail {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup>
|
||||||
|
// stats: [{ value, label }]
|
||||||
|
defineProps({
|
||||||
|
stats: { type: Array, required: true },
|
||||||
|
boxed: { type: Boolean, default: true },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="statrow" :class="{ 'statrow--boxed': boxed }">
|
||||||
|
<div v-for="(s, i) in stats" :key="i" class="statrow__item">
|
||||||
|
<div class="statrow__value">{{ s.value }}</div>
|
||||||
|
<div class="statrow__label">{{ s.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.statrow {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 1fr;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.statrow--boxed {
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px 8px;
|
||||||
|
}
|
||||||
|
.statrow__value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--aurora-text);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.statrow__label {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
/** Fetches aggregated analytics from aurora-admin/v1/analytics for a range. */
|
||||||
|
export function useAnalyticsData(serverData) {
|
||||||
|
const data = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(false);
|
||||||
|
|
||||||
|
const fmt = (d) => {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async (from, to) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = false;
|
||||||
|
try {
|
||||||
|
const url = new URL(`${serverData.restUrl}aurora-admin/v1/analytics`);
|
||||||
|
url.searchParams.set('from', fmt(from));
|
||||||
|
url.searchParams.set('to', fmt(to));
|
||||||
|
const res = await fetch(url, { headers: { 'X-WP-Nonce': serverData.restNonce } });
|
||||||
|
if (!res.ok) throw new Error('Request failed');
|
||||||
|
data.value = await res.json();
|
||||||
|
} catch {
|
||||||
|
error.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { data, loading, error, load };
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all dashboard card data from aurora-admin/v1/dashboard for a
|
||||||
|
* given date range. Re-callable when the range changes.
|
||||||
|
*/
|
||||||
|
export function useDashboardData(serverData) {
|
||||||
|
const data = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(false);
|
||||||
|
|
||||||
|
const fmt = (d) => {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async (from, to) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = false;
|
||||||
|
try {
|
||||||
|
const url = new URL(`${serverData.restUrl}aurora-admin/v1/dashboard`);
|
||||||
|
url.searchParams.set('from', fmt(from));
|
||||||
|
url.searchParams.set('to', fmt(to));
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'X-WP-Nonce': serverData.restNonce },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Request failed');
|
||||||
|
data.value = await res.json();
|
||||||
|
} catch {
|
||||||
|
error.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { data, loading, error, load };
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
|
import AppListShell from '../_shared/AppListShell.vue';
|
||||||
|
import AppListItem from '../_shared/AppListItem.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
serverData: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const restBase = `${props.serverData.restUrl}aurora-admin/v1`;
|
||||||
|
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce };
|
||||||
|
const api = (path, opts = {}) =>
|
||||||
|
fetch(`${restBase}${path}`, { ...opts, headers }).then(async (r) => {
|
||||||
|
const body = await r.json();
|
||||||
|
if (!r.ok) throw new Error(body?.message || 'Request failed');
|
||||||
|
return body;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tables = ref([]);
|
||||||
|
const search = ref('');
|
||||||
|
const selectedTable = ref(null);
|
||||||
|
const tableData = ref(null);
|
||||||
|
const tablePage = ref(1);
|
||||||
|
const loadingTable = ref(false);
|
||||||
|
|
||||||
|
const sqlMode = ref(false);
|
||||||
|
const sqlQuery = ref('SELECT * FROM ' + (props.serverData.tablePrefix || 'wp_') + 'options LIMIT 10');
|
||||||
|
const sqlResult = ref(null);
|
||||||
|
const sqlError = ref('');
|
||||||
|
const sqlRunning = ref(false);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
tables.value = await api('/db-tables');
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = search.value.trim().toLowerCase();
|
||||||
|
if (!q) return tables.value;
|
||||||
|
return tables.value.filter((t) => t.name.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatSize = (bytes) => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTable = async (name, page = 1) => {
|
||||||
|
sqlMode.value = false;
|
||||||
|
selectedTable.value = name;
|
||||||
|
tablePage.value = page;
|
||||||
|
loadingTable.value = true;
|
||||||
|
try {
|
||||||
|
tableData.value = await api(`/db-tables/${encodeURIComponent(name)}?page=${page}`);
|
||||||
|
} finally {
|
||||||
|
loadingTable.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(tablePage, (p) => {
|
||||||
|
if (selectedTable.value) loadTable(selectedTable.value, p);
|
||||||
|
});
|
||||||
|
|
||||||
|
const openSqlEditor = () => {
|
||||||
|
sqlMode.value = true;
|
||||||
|
selectedTable.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runQuery = async () => {
|
||||||
|
sqlRunning.value = true;
|
||||||
|
sqlError.value = '';
|
||||||
|
sqlResult.value = null;
|
||||||
|
try {
|
||||||
|
sqlResult.value = await api('/db-query', { method: 'POST', body: JSON.stringify({ query: sqlQuery.value }) });
|
||||||
|
} catch (e) {
|
||||||
|
sqlError.value = e.message;
|
||||||
|
} finally {
|
||||||
|
sqlRunning.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSelection = computed(() => !!selectedTable.value || sqlMode.value);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppListShell
|
||||||
|
v-model:search="search"
|
||||||
|
title="Database Explorer"
|
||||||
|
search-placeholder="Search tables…"
|
||||||
|
:count-label="`${filtered.length} TABLES`"
|
||||||
|
detail-icon="dashicons-database"
|
||||||
|
detail-title="Table Details"
|
||||||
|
detail-text="Select a table from the list to view its structure and data."
|
||||||
|
:has-selection="hasSelection"
|
||||||
|
>
|
||||||
|
<template #header-actions>
|
||||||
|
<button type="button" @click="openSqlEditor">
|
||||||
|
<span class="dashicons dashicons-editor-code" /> SQL
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #detail-empty-actions>
|
||||||
|
<button type="button" class="is-primary" style="margin-top: 14px;" @click="openSqlEditor">
|
||||||
|
Open SQL Editor
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #list>
|
||||||
|
<AppListItem
|
||||||
|
v-for="t in filtered"
|
||||||
|
:key="t.name"
|
||||||
|
icon="dashicons-database"
|
||||||
|
:title="t.name"
|
||||||
|
:subtitle="`${t.rows} rows · ${formatSize(t.sizeBytes)}`"
|
||||||
|
:active="t.name === selectedTable"
|
||||||
|
@click="loadTable(t.name)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #detail>
|
||||||
|
<div v-if="sqlMode" class="db-sql">
|
||||||
|
<h2>SQL Editor</h2>
|
||||||
|
<p class="db-sql__hint">Only SELECT / SHOW / DESCRIBE / EXPLAIN queries are allowed.</p>
|
||||||
|
<textarea v-model="sqlQuery" rows="4" spellcheck="false" />
|
||||||
|
<div class="db-sql__actions">
|
||||||
|
<button type="button" class="is-primary" :disabled="sqlRunning" @click="runQuery">
|
||||||
|
{{ sqlRunning ? 'Running…' : 'Run query' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="sqlError" class="db-sql__error">{{ sqlError }}</p>
|
||||||
|
<div v-if="sqlResult" class="db-table-wrap">
|
||||||
|
<p class="db-table__count">{{ sqlResult.count }} rows</p>
|
||||||
|
<table v-if="sqlResult.rows.length" class="db-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th v-for="k in Object.keys(sqlResult.rows[0])" :key="k">{{ k }}</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, i) in sqlResult.rows" :key="i">
|
||||||
|
<td v-for="(v, k) in row" :key="k">{{ v }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="selectedTable" class="db-table-detail">
|
||||||
|
<h2>{{ selectedTable }}</h2>
|
||||||
|
<div v-if="loadingTable">Loading…</div>
|
||||||
|
<template v-else-if="tableData">
|
||||||
|
<div class="db-table-wrap">
|
||||||
|
<table class="db-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th v-for="c in tableData.columns" :key="c.name">{{ c.name }}</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, i) in tableData.rows" :key="i">
|
||||||
|
<td v-for="c in tableData.columns" :key="c.name">{{ row[c.name] }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="db-table__pager">
|
||||||
|
<span>Page {{ tableData.page }} of {{ tableData.totalPages || 1 }} · {{ tableData.total }} rows</span>
|
||||||
|
<button type="button" :disabled="tablePage <= 1" @click="tablePage--">‹</button>
|
||||||
|
<button type="button" :disabled="tablePage >= tableData.totalPages" @click="tablePage++">›</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</AppListShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.db-sql h2, .db-table-detail h2 { margin: 0 0 10px; font-size: 1.1rem; color: var(--aurora-text); }
|
||||||
|
.db-sql__hint { font-size: 0.75rem; color: var(--aurora-text-muted); margin: 0 0 10px; }
|
||||||
|
.db-sql textarea {
|
||||||
|
width: 100%; box-sizing: border-box; font-family: monospace; font-size: 0.8125rem;
|
||||||
|
padding: 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||||
|
background: var(--aurora-bg-subtle); color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
.db-sql__actions { display: flex; justify-content: flex-end; margin: 10px 0; }
|
||||||
|
.db-sql__actions button, .db-table__pager button {
|
||||||
|
border: none; border-radius: 8px; padding: 8px 16px; font-size: 0.8125rem; cursor: pointer;
|
||||||
|
background: var(--aurora-accent); color: #fff;
|
||||||
|
}
|
||||||
|
.db-sql__actions button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.db-sql__error { color: #ef4444; font-size: 0.8125rem; }
|
||||||
|
|
||||||
|
.db-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; border: 1px solid var(--aurora-frame-border); border-radius: 8px; }
|
||||||
|
.db-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; }
|
||||||
|
.db-table th, .db-table td {
|
||||||
|
padding: 6px 10px; text-align: left; white-space: nowrap;
|
||||||
|
border-bottom: 1px solid var(--aurora-frame-border); color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
.db-table th { background: var(--aurora-bg-subtle); position: sticky; top: 0; }
|
||||||
|
.db-table__count { font-size: 0.75rem; color: var(--aurora-text-muted); margin: 8px 0; }
|
||||||
|
.db-table__pager {
|
||||||
|
display: flex; align-items: center; gap: 8px; margin-top: 12px;
|
||||||
|
font-size: 0.75rem; color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
.db-table__pager button { background: var(--aurora-bg-subtle); color: var(--aurora-text); padding: 4px 10px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<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 menus = ref([]);
|
||||||
|
const nativeItems = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const search = ref('');
|
||||||
|
const statusFilter = ref('all');
|
||||||
|
const selectedId = ref(null);
|
||||||
|
const roleOptions = ref([]);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
menus.value = await api('/menus');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
load();
|
||||||
|
api('/menus/native-items').then((items) => (nativeItems.value = items));
|
||||||
|
roleOptions.value = props.serverData.roles || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredMenus = computed(() => {
|
||||||
|
let list = menus.value;
|
||||||
|
if (statusFilter.value !== 'all') {
|
||||||
|
list = list.filter((m) => m.status === statusFilter.value);
|
||||||
|
}
|
||||||
|
const q = search.value.trim().toLowerCase();
|
||||||
|
if (q) list = list.filter((m) => m.name.toLowerCase().includes(q));
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = computed(() => menus.value.find((m) => m.id === selectedId.value) || null);
|
||||||
|
|
||||||
|
const draft = reactive({ name: '', status: 'draft', roles: [], items: [] });
|
||||||
|
|
||||||
|
const selectMenu = (menu) => {
|
||||||
|
selectedId.value = menu.id;
|
||||||
|
Object.assign(draft, {
|
||||||
|
name: menu.name,
|
||||||
|
status: menu.status,
|
||||||
|
roles: [...menu.roles],
|
||||||
|
items: menu.items.map((i) => ({ ...i })),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCreating = ref(false);
|
||||||
|
const newMenu = () => {
|
||||||
|
selectedId.value = null;
|
||||||
|
isCreating.value = true;
|
||||||
|
Object.assign(draft, { name: 'New menu', status: 'draft', roles: [], items: [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemState = (nativeId) => draft.items.find((i) => i.native_id === nativeId);
|
||||||
|
const toggleHidden = (nativeId) => {
|
||||||
|
let item = itemState(nativeId);
|
||||||
|
if (!item) {
|
||||||
|
item = { native_id: nativeId, label: '', hidden: false, order: 0 };
|
||||||
|
draft.items.push(item);
|
||||||
|
}
|
||||||
|
item.hidden = !item.hidden;
|
||||||
|
};
|
||||||
|
const setLabel = (nativeId, label) => {
|
||||||
|
let item = itemState(nativeId);
|
||||||
|
if (!item) {
|
||||||
|
item = { native_id: nativeId, label: '', hidden: false, order: 0 };
|
||||||
|
draft.items.push(item);
|
||||||
|
}
|
||||||
|
item.label = label;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saving = ref(false);
|
||||||
|
const save = async () => {
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = { name: draft.name, status: draft.status, roles: draft.roles, items: draft.items };
|
||||||
|
if (isCreating.value) {
|
||||||
|
const created = await api('/menus', { method: 'POST', body: JSON.stringify(payload) });
|
||||||
|
await load();
|
||||||
|
selectMenu(created);
|
||||||
|
isCreating.value = false;
|
||||||
|
} else if (selectedId.value) {
|
||||||
|
await api(`/menus/${selectedId.value}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!selectedId.value) return;
|
||||||
|
await api(`/menus/${selectedId.value}`, { method: 'DELETE' });
|
||||||
|
selectedId.value = null;
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRole = (slug) => {
|
||||||
|
const idx = draft.roles.indexOf(slug);
|
||||||
|
if (idx === -1) draft.roles.push(slug);
|
||||||
|
else draft.roles.splice(idx, 1);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppListShell
|
||||||
|
v-model:search="search"
|
||||||
|
title="Menu Creator"
|
||||||
|
search-placeholder="Search menus…"
|
||||||
|
:count-label="`${filteredMenus.length} MENUS`"
|
||||||
|
detail-icon="dashicons-menu-alt"
|
||||||
|
detail-title="Menu Editor"
|
||||||
|
detail-text="Select a menu from the list to edit its structure and settings."
|
||||||
|
:has-selection="!!selected || isCreating"
|
||||||
|
>
|
||||||
|
<template #header-actions>
|
||||||
|
<button type="button" @click="newMenu">+</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #filters>
|
||||||
|
<button
|
||||||
|
v-for="f in ['all', 'published', 'draft']"
|
||||||
|
:key="f"
|
||||||
|
type="button"
|
||||||
|
:class="{ 'is-active': statusFilter === f }"
|
||||||
|
@click="statusFilter = f"
|
||||||
|
>
|
||||||
|
{{ f === 'all' ? 'All' : f[0].toUpperCase() + f.slice(1) }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #list>
|
||||||
|
<p v-if="!loading && !filteredMenus.length" style="font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 20px 4px; text-align: center;">
|
||||||
|
No menus yet
|
||||||
|
</p>
|
||||||
|
<AppListItem
|
||||||
|
v-for="m in filteredMenus"
|
||||||
|
:key="m.id"
|
||||||
|
icon="dashicons-menu-alt"
|
||||||
|
:title="m.name"
|
||||||
|
:subtitle="m.status"
|
||||||
|
:active="m.id === selectedId"
|
||||||
|
@click="selectMenu(m)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #detail>
|
||||||
|
<div class="menu-editor">
|
||||||
|
<div class="menu-editor__row">
|
||||||
|
<input v-model="draft.name" type="text" class="menu-editor__name" placeholder="Menu name" />
|
||||||
|
<select v-model="draft.status" class="menu-editor__status">
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="published">Published</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-editor__section">
|
||||||
|
<h3>Target roles</h3>
|
||||||
|
<div class="menu-editor__roles">
|
||||||
|
<label v-for="r in roleOptions" :key="r.value" class="menu-editor__role">
|
||||||
|
<input type="checkbox" :checked="draft.roles.includes(r.value)" @change="toggleRole(r.value)" />
|
||||||
|
{{ r.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-editor__section">
|
||||||
|
<h3>Menu items</h3>
|
||||||
|
<div class="menu-editor__items">
|
||||||
|
<div v-for="ni in nativeItems" :key="ni.native_id" class="menu-editor__item">
|
||||||
|
<span class="dashicons" :class="ni.icon?.value || 'dashicons-admin-generic'" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="itemState(ni.native_id)?.label || ni.title"
|
||||||
|
@input="setLabel(ni.native_id, $event.target.value)"
|
||||||
|
/>
|
||||||
|
<label class="menu-editor__hide">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="!!itemState(ni.native_id)?.hidden"
|
||||||
|
@change="toggleHidden(ni.native_id)"
|
||||||
|
/>
|
||||||
|
Hide
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-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>
|
||||||
|
.menu-editor { display: flex; flex-direction: column; gap: 22px; }
|
||||||
|
.menu-editor__row { display: flex; gap: 10px; }
|
||||||
|
.menu-editor__name {
|
||||||
|
flex: 1; font-size: 1.1rem; font-weight: 700; padding: 8px 10px;
|
||||||
|
border: 1px solid var(--aurora-border); border-radius: 8px;
|
||||||
|
background: var(--aurora-bg-subtle); color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
.menu-editor__status {
|
||||||
|
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||||
|
background: var(--aurora-bg-subtle); color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
.menu-editor__section h3 {
|
||||||
|
font-size: 0.8125rem; text-transform: uppercase; letter-spacing: 0.03em;
|
||||||
|
color: var(--aurora-text-muted); margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.menu-editor__roles { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.menu-editor__role { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; color: var(--aurora-text); }
|
||||||
|
.menu-editor__items { display: flex; flex-direction: column; gap: 6px; max-height: 360px; overflow-y: auto; }
|
||||||
|
.menu-editor__item {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 6px 4px;
|
||||||
|
border-bottom: 1px solid var(--aurora-frame-border);
|
||||||
|
}
|
||||||
|
.menu-editor__item input[type='text'] {
|
||||||
|
flex: 1; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--aurora-border);
|
||||||
|
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.menu-editor__hide { display: flex; align-items: center; gap: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); }
|
||||||
|
.menu-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
.menu-editor__actions button {
|
||||||
|
border: none; border-radius: 8px; padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
|
||||||
|
background: var(--aurora-bg-subtle); color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
.menu-editor__actions .is-primary { background: var(--aurora-accent); color: #fff; }
|
||||||
|
.menu-editor__actions .is-danger { color: #ef4444; }
|
||||||
|
.menu-editor__actions button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<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 roles = ref([]);
|
||||||
|
const allCapabilities = ref([]);
|
||||||
|
const search = ref('');
|
||||||
|
const selectedSlug = ref(null);
|
||||||
|
const isCreating = ref(false);
|
||||||
|
const errorMsg = ref('');
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const data = await api('/roles');
|
||||||
|
roles.value = data.roles;
|
||||||
|
allCapabilities.value = data.allCapabilities;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = search.value.trim().toLowerCase();
|
||||||
|
if (!q) return roles.value;
|
||||||
|
return roles.value.filter((r) => r.name.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = computed(() => roles.value.find((r) => r.slug === selectedSlug.value) || null);
|
||||||
|
|
||||||
|
const draft = reactive({ slug: '', name: '', capabilities: [] });
|
||||||
|
|
||||||
|
const selectRole = (r) => {
|
||||||
|
selectedSlug.value = r.slug;
|
||||||
|
isCreating.value = false;
|
||||||
|
errorMsg.value = '';
|
||||||
|
Object.assign(draft, { slug: r.slug, name: r.name, capabilities: [...r.capabilities] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const newRole = () => {
|
||||||
|
selectedSlug.value = null;
|
||||||
|
isCreating.value = true;
|
||||||
|
errorMsg.value = '';
|
||||||
|
Object.assign(draft, { slug: '', name: '', capabilities: ['read'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCap = (cap) => {
|
||||||
|
const idx = draft.capabilities.indexOf(cap);
|
||||||
|
if (idx === -1) draft.capabilities.push(cap);
|
||||||
|
else draft.capabilities.splice(idx, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saving = ref(false);
|
||||||
|
const save = async () => {
|
||||||
|
saving.value = true;
|
||||||
|
errorMsg.value = '';
|
||||||
|
try {
|
||||||
|
if (isCreating.value) {
|
||||||
|
await api('/roles', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ slug: draft.slug, displayName: draft.name, capabilities: draft.capabilities }),
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
isCreating.value = false;
|
||||||
|
selectRole(roles.value.find((r) => r.slug === draft.slug));
|
||||||
|
} else if (selectedSlug.value) {
|
||||||
|
await api(`/roles/${selectedSlug.value}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ displayName: draft.name, capabilities: draft.capabilities }),
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = 'Could not save — check the role slug is unique.';
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!selectedSlug.value || selected.value?.isDefault) return;
|
||||||
|
await api(`/roles/${selectedSlug.value}`, { method: 'DELETE' });
|
||||||
|
selectedSlug.value = null;
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppListShell
|
||||||
|
v-model:search="search"
|
||||||
|
title="Role Editor"
|
||||||
|
search-placeholder="Search roles…"
|
||||||
|
:count-label="`${filtered.length} ROLES`"
|
||||||
|
detail-icon="dashicons-groups"
|
||||||
|
detail-title="Role Details"
|
||||||
|
detail-text="Select a role from the list to view and edit its capabilities."
|
||||||
|
:has-selection="!!selected || isCreating"
|
||||||
|
>
|
||||||
|
<template #header-actions>
|
||||||
|
<button type="button" class="is-primary" @click="newRole">+ New Role</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #list>
|
||||||
|
<AppListItem
|
||||||
|
v-for="r in filtered"
|
||||||
|
:key="r.slug"
|
||||||
|
icon="dashicons-groups"
|
||||||
|
:title="r.name"
|
||||||
|
:subtitle="`${r.userCount} user${r.userCount === 1 ? '' : 's'}`"
|
||||||
|
:active="r.slug === selectedSlug"
|
||||||
|
@click="selectRole(r)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #detail>
|
||||||
|
<div class="role-editor">
|
||||||
|
<div class="role-editor__row">
|
||||||
|
<input
|
||||||
|
v-if="isCreating"
|
||||||
|
v-model="draft.slug"
|
||||||
|
type="text"
|
||||||
|
placeholder="role_slug"
|
||||||
|
class="role-editor__slug"
|
||||||
|
/>
|
||||||
|
<input v-model="draft.name" type="text" placeholder="Display name" class="role-editor__name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="errorMsg" class="role-editor__error">{{ errorMsg }}</p>
|
||||||
|
|
||||||
|
<div class="role-editor__section">
|
||||||
|
<h3>Capabilities</h3>
|
||||||
|
<div class="role-editor__caps">
|
||||||
|
<label v-for="cap in allCapabilities" :key="cap" class="role-editor__cap">
|
||||||
|
<input type="checkbox" :checked="draft.capabilities.includes(cap)" @change="toggleCap(cap)" />
|
||||||
|
{{ cap }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="role-editor__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="is-danger"
|
||||||
|
:disabled="!selectedSlug || selected?.isDefault"
|
||||||
|
:title="selected?.isDefault ? 'Default WordPress roles cannot be deleted' : ''"
|
||||||
|
@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>
|
||||||
|
.role-editor { display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
.role-editor__row { display: flex; gap: 10px; }
|
||||||
|
.role-editor__slug, .role-editor__name {
|
||||||
|
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--aurora-border);
|
||||||
|
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.role-editor__name { flex: 1; font-weight: 700; }
|
||||||
|
.role-editor__slug { width: 160px; }
|
||||||
|
.role-editor__error { color: #ef4444; font-size: 0.8125rem; margin: 0; }
|
||||||
|
.role-editor__section h3 {
|
||||||
|
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em;
|
||||||
|
color: var(--aurora-text-muted); margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.role-editor__caps {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 8px 16px; max-height: 380px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.role-editor__cap { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; color: var(--aurora-text); }
|
||||||
|
.role-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
.role-editor__actions button {
|
||||||
|
border: none; border-radius: 8px; padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
|
||||||
|
background: var(--aurora-bg-subtle); color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
.role-editor__actions .is-primary { background: var(--aurora-accent); color: #fff; }
|
||||||
|
.role-editor__actions .is-danger { color: #ef4444; }
|
||||||
|
.role-editor__actions button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, watch } from 'vue';
|
||||||
|
import { settingsCategories } from './settingsConfig.js';
|
||||||
|
import SettingsField from './components/SettingsField.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
serverData: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Working copy of the settings, seeded from the server + field defaults.
|
||||||
|
const settings = reactive({ ...(props.serverData.settings || {}) });
|
||||||
|
settingsCategories.forEach((cat) =>
|
||||||
|
cat.fields.forEach((f) => {
|
||||||
|
if (settings[f.key] === undefined && f.default !== undefined) {
|
||||||
|
settings[f.key] = f.default;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Active category, synced with the URL ?category= param.
|
||||||
|
const initialCat = new URLSearchParams(window.location.search).get('category');
|
||||||
|
const activeId = ref(
|
||||||
|
settingsCategories.some((c) => c.id === initialCat) ? initialCat : settingsCategories[0].id
|
||||||
|
);
|
||||||
|
const activeCategory = computed(
|
||||||
|
() => settingsCategories.find((c) => c.id === activeId.value) || settingsCategories[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectCategory = (id) => {
|
||||||
|
activeId.value = id;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('category', id);
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search filter over the nav.
|
||||||
|
const query = ref('');
|
||||||
|
const filteredCategories = computed(() => {
|
||||||
|
const q = query.value.trim().toLowerCase();
|
||||||
|
if (!q) return settingsCategories;
|
||||||
|
return settingsCategories.filter(
|
||||||
|
(c) =>
|
||||||
|
c.title.toLowerCase().includes(q) || c.subtitle.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Options for multiselect fields, resolved from server-provided lists.
|
||||||
|
const optionsFor = (field) => {
|
||||||
|
if (field.optionsSource === 'roles') return props.serverData.roles || [];
|
||||||
|
if (field.optionsSource === 'postTypes') return props.serverData.postTypes || [];
|
||||||
|
return field.options || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Live dark-mode preview.
|
||||||
|
watch(
|
||||||
|
() => settings.dark_mode,
|
||||||
|
(v) => document.documentElement.classList.toggle('aurora-dark', !!v)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save -------------------------------------------------------------------
|
||||||
|
const saving = ref(false);
|
||||||
|
const savedMsg = ref('');
|
||||||
|
const save = async () => {
|
||||||
|
saving.value = true;
|
||||||
|
savedMsg.value = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${props.serverData.restUrl}wp/v2/settings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': props.serverData.restNonce },
|
||||||
|
body: JSON.stringify({ aurora_admin_settings: { ...settings } }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
savedMsg.value = 'Saved';
|
||||||
|
setTimeout(() => (savedMsg.value = ''), 2500);
|
||||||
|
} catch {
|
||||||
|
savedMsg.value = 'Could not save';
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Import / export --------------------------------------------------------
|
||||||
|
const exportSettings = () => {
|
||||||
|
const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = 'aurora-admin-settings.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
};
|
||||||
|
const importInput = ref(null);
|
||||||
|
const importSettings = (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(reader.result);
|
||||||
|
Object.assign(settings, obj);
|
||||||
|
} catch {
|
||||||
|
savedMsg.value = 'Invalid file';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="settings">
|
||||||
|
<!-- Left nav panel -->
|
||||||
|
<aside class="settings__nav">
|
||||||
|
<div class="settings__nav-head">
|
||||||
|
<h1 class="settings__title">Settings</h1>
|
||||||
|
<div class="settings__actions">
|
||||||
|
<button type="button" title="Export settings" @click="exportSettings">
|
||||||
|
<span class="dashicons dashicons-download" />
|
||||||
|
</button>
|
||||||
|
<button type="button" title="Import settings" @click="importInput.click()">
|
||||||
|
<span class="dashicons dashicons-upload" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="importInput"
|
||||||
|
type="file"
|
||||||
|
accept="application/json"
|
||||||
|
class="settings__file"
|
||||||
|
@change="importSettings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings__search">
|
||||||
|
<span class="dashicons dashicons-search" />
|
||||||
|
<input v-model="query" type="text" placeholder="Search settings…" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="settings__list">
|
||||||
|
<li v-for="cat in filteredCategories" :key="cat.id">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="settings__cat"
|
||||||
|
:class="{ 'settings__cat--active': cat.id === activeId }"
|
||||||
|
@click="selectCategory(cat.id)"
|
||||||
|
>
|
||||||
|
<span class="settings__cat-icon"><span class="dashicons" :class="cat.icon" /></span>
|
||||||
|
<span class="settings__cat-text">
|
||||||
|
<span class="settings__cat-title">{{ cat.title }}</span>
|
||||||
|
<span class="settings__cat-sub">{{ cat.subtitle }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Right content panel -->
|
||||||
|
<section class="settings__content">
|
||||||
|
<header class="settings__content-head">
|
||||||
|
<div>
|
||||||
|
<h2 class="settings__content-title">{{ activeCategory.title }}</h2>
|
||||||
|
<p class="settings__content-sub">{{ activeCategory.subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings__save-wrap">
|
||||||
|
<span v-if="savedMsg" class="settings__saved">{{ savedMsg }}</span>
|
||||||
|
<button type="button" class="settings__save" :disabled="saving" @click="save">
|
||||||
|
{{ saving ? 'Saving…' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="settings__fields">
|
||||||
|
<SettingsField
|
||||||
|
v-for="f in activeCategory.fields"
|
||||||
|
:key="f.key"
|
||||||
|
:field="f"
|
||||||
|
:options="optionsFor(f)"
|
||||||
|
v-model="settings[f.key]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
min-height: calc(100vh - var(--aurora-toolbar-h) - 60px);
|
||||||
|
margin: -32px; /* cancel the frame padding */
|
||||||
|
padding: 22px; /* inset the two boxes; the frame's right gutter adds the rest */
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Darker backdrop so the two boxes read as distinct panels. */
|
||||||
|
background: var(--aurora-app-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left nav — its own box */
|
||||||
|
.settings__nav {
|
||||||
|
width: 340px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--aurora-frame-bg);
|
||||||
|
border: 1px solid var(--aurora-frame-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.settings__nav-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.settings__title { font-size: 1.35rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
|
||||||
|
.settings__actions { display: flex; gap: 4px; }
|
||||||
|
.settings__actions button {
|
||||||
|
width: 30px; height: 30px; border: none; background: none;
|
||||||
|
color: var(--aurora-text-muted); cursor: pointer; border-radius: 7px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.settings__actions button:hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
|
||||||
|
.settings__file { display: none; }
|
||||||
|
|
||||||
|
.settings__search {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
border: 1px solid var(--aurora-border);
|
||||||
|
border-radius: 10px; padding: 8px 12px; margin-bottom: 16px;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
.settings__search input {
|
||||||
|
border: none; background: none; outline: none; color: var(--aurora-text);
|
||||||
|
font-size: 0.8125rem; width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.settings__cat {
|
||||||
|
display: flex; align-items: center; gap: 12px; width: 100%;
|
||||||
|
padding: 10px; border: none; background: none; cursor: pointer;
|
||||||
|
border-radius: 12px; text-align: left; margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.settings__cat:hover { background: var(--aurora-bg-subtle); }
|
||||||
|
.settings__cat--active { background: var(--aurora-accent-soft); }
|
||||||
|
.settings__cat-icon {
|
||||||
|
width: 34px; height: 34px; flex-shrink: 0;
|
||||||
|
border-radius: 9px; background: var(--aurora-bg-subtle);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
.settings__cat--active .settings__cat-icon { color: var(--aurora-accent); }
|
||||||
|
.settings__cat-text { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.settings__cat-title { font-size: 0.875rem; font-weight: 600; color: var(--aurora-text); }
|
||||||
|
.settings__cat--active .settings__cat-title { color: var(--aurora-accent); }
|
||||||
|
.settings__cat-sub {
|
||||||
|
font-size: 0.75rem; color: var(--aurora-text-muted);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right content — its own box */
|
||||||
|
.settings__content {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
background: var(--aurora-frame-bg);
|
||||||
|
border: 1px solid var(--aurora-frame-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 28px 32px;
|
||||||
|
}
|
||||||
|
.settings__content-head {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding-bottom: 22px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-bottom: 1px solid var(--aurora-frame-border);
|
||||||
|
}
|
||||||
|
.settings__content-title { font-size: 1.6rem; font-weight: 700; margin: 0; color: var(--aurora-text); }
|
||||||
|
.settings__content-sub { margin: 6px 0 0; font-size: 0.875rem; color: var(--aurora-text-muted); }
|
||||||
|
.settings__save-wrap { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
||||||
|
.settings__saved { font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||||
|
.settings__save {
|
||||||
|
background: var(--aurora-accent); color: #fff; border: none;
|
||||||
|
border-radius: 9px; padding: 9px 20px; font-size: 0.875rem; font-weight: 500; cursor: pointer;
|
||||||
|
}
|
||||||
|
.settings__save:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
|
||||||
|
.settings__fields > :last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.settings { flex-direction: column; }
|
||||||
|
.settings__nav { width: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, reactive } from 'vue';
|
||||||
|
import Toggle from '@/components/Toggle.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
field: { type: Object, required: true },
|
||||||
|
modelValue: { default: undefined },
|
||||||
|
// Options for multiselect fields (resolved by the page from optionsSource).
|
||||||
|
options: { type: Array, default: () => [] },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
|
const val = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Repeater helpers ---------------------------------------------------------
|
||||||
|
const rows = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []));
|
||||||
|
const addRow = () => emit('update:modelValue', [...rows.value, { find: '', replace: '' }]);
|
||||||
|
const updateRow = (i, key, v) => {
|
||||||
|
const next = rows.value.map((r, idx) => (idx === i ? { ...r, [key]: v } : r));
|
||||||
|
emit('update:modelValue', next);
|
||||||
|
};
|
||||||
|
const removeRow = (i) => emit('update:modelValue', rows.value.filter((_, idx) => idx !== i));
|
||||||
|
|
||||||
|
// Multiselect helpers -------------------------------------------------------
|
||||||
|
const selectedValues = computed(() =>
|
||||||
|
Array.isArray(props.modelValue) ? props.modelValue : []
|
||||||
|
);
|
||||||
|
const toggleValue = (value) => {
|
||||||
|
const next = selectedValues.value.includes(value)
|
||||||
|
? selectedValues.value.filter((v) => v !== value)
|
||||||
|
: [...selectedValues.value, value];
|
||||||
|
emit('update:modelValue', next);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remote sites helpers -------------------------------------------------------
|
||||||
|
const sites = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []));
|
||||||
|
const newSite = reactive({ url: '', username: '', app_password: '' });
|
||||||
|
const addSite = () => {
|
||||||
|
if (!newSite.url.trim()) return;
|
||||||
|
emit('update:modelValue', [
|
||||||
|
...sites.value,
|
||||||
|
{ url: newSite.url.trim(), username: newSite.username.trim(), app_password: newSite.app_password },
|
||||||
|
]);
|
||||||
|
newSite.url = '';
|
||||||
|
newSite.username = '';
|
||||||
|
newSite.app_password = '';
|
||||||
|
};
|
||||||
|
const removeSite = (i) => emit('update:modelValue', sites.value.filter((_, idx) => idx !== i));
|
||||||
|
|
||||||
|
// Media picker (image fields) ---------------------------------------------
|
||||||
|
const pickImage = () => {
|
||||||
|
const wp = window.wp;
|
||||||
|
if (!wp || !wp.media) {
|
||||||
|
// Fallback: prompt for a URL if the media library isn't available.
|
||||||
|
const url = window.prompt('Image URL');
|
||||||
|
if (url) emit('update:modelValue', url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const frame = wp.media({ title: props.field.label, multiple: false });
|
||||||
|
frame.on('select', () => {
|
||||||
|
const att = frame.state().get('selection').first().toJSON();
|
||||||
|
emit('update:modelValue', att.url);
|
||||||
|
});
|
||||||
|
frame.open();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="field">
|
||||||
|
<div class="field__info">
|
||||||
|
<div class="field__label">{{ field.label }}</div>
|
||||||
|
<p v-if="field.description" class="field__desc">{{ field.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field__control">
|
||||||
|
<!-- Text -->
|
||||||
|
<input
|
||||||
|
v-if="field.type === 'text'"
|
||||||
|
type="text"
|
||||||
|
class="field__input"
|
||||||
|
:placeholder="field.placeholder || ''"
|
||||||
|
:value="val || ''"
|
||||||
|
@input="val = $event.target.value"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Toggle -->
|
||||||
|
<Toggle
|
||||||
|
v-else-if="field.type === 'toggle'"
|
||||||
|
:model-value="!!val"
|
||||||
|
@update:model-value="val = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Color -->
|
||||||
|
<div v-else-if="field.type === 'color'" class="field__color">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
:value="val || field.default || '#4772fa'"
|
||||||
|
@input="val = $event.target.value"
|
||||||
|
/>
|
||||||
|
<span class="field__color-hex">{{ val || field.default || '#4772fa' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image / favicon -->
|
||||||
|
<button
|
||||||
|
v-else-if="field.type === 'image'"
|
||||||
|
type="button"
|
||||||
|
class="field__image"
|
||||||
|
@click="pickImage"
|
||||||
|
>
|
||||||
|
<img v-if="val" :src="val" alt="" />
|
||||||
|
<span v-else class="dashicons dashicons-format-image field__image-ph" />
|
||||||
|
<span class="field__image-label">{{ val ? 'Change' : (field.label + ' url') }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Multiselect (checkbox list) -->
|
||||||
|
<div v-else-if="field.type === 'multiselect'" class="field__multi">
|
||||||
|
<label v-for="opt in options" :key="opt.value" class="field__multi-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="selectedValues.includes(opt.value)"
|
||||||
|
@change="toggleValue(opt.value)"
|
||||||
|
/>
|
||||||
|
<span>{{ opt.label }}</span>
|
||||||
|
</label>
|
||||||
|
<p v-if="!options.length" class="field__multi-empty">No options available.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Segmented No/Yes -->
|
||||||
|
<div v-else-if="field.type === 'segmented'" class="field__seg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ 'field__seg--active': !val }"
|
||||||
|
@click="val = false"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ 'field__seg--active': !!val }"
|
||||||
|
@click="val = true"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Remote sites -->
|
||||||
|
<div v-else-if="field.type === 'remote-sites'" class="field__sites">
|
||||||
|
<div class="field__sites-add">
|
||||||
|
<input v-model="newSite.url" type="text" class="field__input" placeholder="Site URL (e.g., https://example.com)" />
|
||||||
|
<div class="field__sites-row">
|
||||||
|
<input v-model="newSite.username" type="text" class="field__input" placeholder="Username" />
|
||||||
|
<input v-model="newSite.app_password" type="password" class="field__input" placeholder="Application Password" />
|
||||||
|
<button type="button" class="field__newitem" @click="addSite">Add Site</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field__sites-list">
|
||||||
|
<div v-if="!sites.length" class="field__sites-empty">No remote sites configured yet</div>
|
||||||
|
<div v-for="(site, i) in sites" :key="i" class="field__sites-item">
|
||||||
|
<span class="field__sites-url">{{ site.url }}</span>
|
||||||
|
<span class="field__sites-user">{{ site.username }}</span>
|
||||||
|
<button type="button" class="field__repeater-remove" @click="removeSite(i)">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="field__sites-help">
|
||||||
|
Application passwords can be created in WordPress under Users > Profile > Application Passwords
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Repeater -->
|
||||||
|
<div v-else-if="field.type === 'repeater'" class="field__repeater">
|
||||||
|
<div v-for="(row, i) in rows" :key="i" class="field__repeater-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="field__input"
|
||||||
|
:placeholder="field.columns ? field.columns[0] : 'Find'"
|
||||||
|
:value="row.find"
|
||||||
|
@input="updateRow(i, 'find', $event.target.value)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="field__input"
|
||||||
|
:placeholder="field.columns ? field.columns[1] : 'Replace'"
|
||||||
|
:value="row.replace"
|
||||||
|
@input="updateRow(i, 'replace', $event.target.value)"
|
||||||
|
/>
|
||||||
|
<button type="button" class="field__repeater-remove" @click="removeRow(i)">×</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="field__newitem" @click="addRow">New item</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 32px;
|
||||||
|
padding: 28px 0;
|
||||||
|
border-bottom: 1px solid var(--aurora-border);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.field__label { font-size: 0.95rem; font-weight: 600; color: var(--aurora-text); }
|
||||||
|
.field__desc { margin: 6px 0 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.5; }
|
||||||
|
.field__control { display: flex; justify-content: flex-start; }
|
||||||
|
|
||||||
|
.field__input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
border: 1px solid var(--aurora-border);
|
||||||
|
color: var(--aurora-text);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.field__input:focus { outline: none; border-color: var(--aurora-accent); }
|
||||||
|
|
||||||
|
.field__color { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.field__color input[type='color'] {
|
||||||
|
width: 40px; height: 32px; border: 1px solid var(--aurora-border);
|
||||||
|
border-radius: 8px; background: none; cursor: pointer; padding: 2px;
|
||||||
|
}
|
||||||
|
.field__color-hex { font-size: 0.8125rem; color: var(--aurora-text-muted); }
|
||||||
|
|
||||||
|
.field__image {
|
||||||
|
width: 160px; height: 120px;
|
||||||
|
border: 1px dashed var(--aurora-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 8px; cursor: pointer; color: var(--aurora-text-muted);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.field__image img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.field__image-ph { font-size: 28px; width: 28px; height: 28px; }
|
||||||
|
.field__image-label {
|
||||||
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
|
background: var(--aurora-card-bg); padding: 6px; font-size: 0.75rem;
|
||||||
|
border-top: 1px solid var(--aurora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__repeater { width: 100%; }
|
||||||
|
.field__repeater-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.field__repeater-remove {
|
||||||
|
flex-shrink: 0; width: 34px; border: 1px solid var(--aurora-border);
|
||||||
|
background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
|
||||||
|
border-radius: 8px; cursor: pointer; font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.field__newitem {
|
||||||
|
border: 1px solid var(--aurora-border);
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
color: var(--aurora-text);
|
||||||
|
border-radius: 8px; padding: 8px 16px; font-size: 0.8125rem; cursor: pointer;
|
||||||
|
}
|
||||||
|
.field__newitem:hover { border-color: var(--aurora-accent); color: var(--aurora-accent); }
|
||||||
|
|
||||||
|
/* Multiselect */
|
||||||
|
.field__multi { display: flex; flex-direction: column; gap: 8px; width: 100%; }
|
||||||
|
.field__multi-item {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 0.8125rem; color: var(--aurora-text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.field__multi-item input { accent-color: var(--aurora-accent); }
|
||||||
|
.field__multi-empty { font-size: 0.8125rem; color: var(--aurora-text-muted); margin: 0; }
|
||||||
|
|
||||||
|
/* Segmented No/Yes */
|
||||||
|
.field__seg {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
border: 1px solid var(--aurora-border);
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 3px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.field__seg button {
|
||||||
|
border: none; background: none; cursor: pointer;
|
||||||
|
padding: 6px 26px; border-radius: 7px;
|
||||||
|
font-size: 0.8125rem; color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
.field__seg--active { background: var(--aurora-accent); color: #fff !important; }
|
||||||
|
|
||||||
|
/* Remote sites */
|
||||||
|
.field__sites { width: 100%; }
|
||||||
|
.field__sites-add .field__input { margin-bottom: 8px; }
|
||||||
|
.field__sites-row { display: flex; gap: 8px; }
|
||||||
|
.field__sites-row .field__input { margin-bottom: 0; }
|
||||||
|
.field__sites-row .field__newitem { flex-shrink: 0; white-space: nowrap; }
|
||||||
|
.field__sites-list {
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 1px solid var(--aurora-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.field__sites-empty {
|
||||||
|
padding: 18px; text-align: center;
|
||||||
|
font-size: 0.8125rem; color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
.field__sites-item {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--aurora-border);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.field__sites-item:last-child { border-bottom: none; }
|
||||||
|
.field__sites-url { color: var(--aurora-text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.field__sites-user { color: var(--aurora-text-muted); }
|
||||||
|
.field__sites-help { margin: 10px 0 0; font-size: 0.75rem; color: var(--aurora-text-muted); }
|
||||||
|
|
||||||
|
@media (max-width: 782px) {
|
||||||
|
.field { grid-template-columns: 1fr; gap: 12px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
/**
|
||||||
|
* Declarative settings schema for the Aurora Admin settings page.
|
||||||
|
* Each category renders as an item in the left nav; its fields render in
|
||||||
|
* the right panel. Field `key`s map directly into the aurora_admin_settings
|
||||||
|
* option, saved via the wp/v2/settings REST route.
|
||||||
|
*/
|
||||||
|
export const settingsCategories = [
|
||||||
|
{
|
||||||
|
id: 'general',
|
||||||
|
title: 'General',
|
||||||
|
subtitle: 'Core plugin settings and license management',
|
||||||
|
icon: 'dashicons-admin-settings',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'license_key',
|
||||||
|
type: 'text',
|
||||||
|
label: 'License key',
|
||||||
|
description: 'Enter your license key to enable automatic updates.',
|
||||||
|
placeholder: 'AA-XXXX-XXXX-XXXX',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logo',
|
||||||
|
type: 'image',
|
||||||
|
label: 'Logo',
|
||||||
|
description: 'Optional logo to be displayed in the menu.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dark_logo',
|
||||||
|
type: 'image',
|
||||||
|
label: 'Dark mode logo',
|
||||||
|
description:
|
||||||
|
"Set an alternative logo to use in dark mode. If one isn't set it will fall back to the standard logo.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'remote_sites',
|
||||||
|
type: 'remote-sites',
|
||||||
|
label: 'Remote Sites',
|
||||||
|
description: 'Configure remote WordPress sites for cross-site operations.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'remote_site_switcher_capability',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Remote Site Switcher Capability',
|
||||||
|
description:
|
||||||
|
'WordPress capability required to see the remote site switcher in the toolbar. Defaults to "manage_options" (admin users). Users with this capability or below will see the switcher.',
|
||||||
|
placeholder: 'manage_options',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'disable_theme',
|
||||||
|
type: 'multiselect',
|
||||||
|
label: 'Disable theme',
|
||||||
|
description: 'Choose roles to disable the theme for.',
|
||||||
|
optionsSource: 'roles',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'search_post_types',
|
||||||
|
type: 'multiselect',
|
||||||
|
label: 'Search post types',
|
||||||
|
description: 'Choose which post types are available in the global search.',
|
||||||
|
optionsSource: 'postTypes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'disable_global_search',
|
||||||
|
type: 'segmented',
|
||||||
|
label: 'Disable global search',
|
||||||
|
description: 'Disable global search for all users.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'login',
|
||||||
|
title: 'Login',
|
||||||
|
subtitle: 'Customize login page appearance and security',
|
||||||
|
icon: 'dashicons-lock',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'style_login',
|
||||||
|
type: 'toggle',
|
||||||
|
label: 'Style login page',
|
||||||
|
description: 'Apply the Aurora theme to the WordPress login screen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'login_logo',
|
||||||
|
type: 'image',
|
||||||
|
label: 'Login logo',
|
||||||
|
description: 'Replace the WordPress logo on the login screen.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme',
|
||||||
|
title: 'Theme',
|
||||||
|
subtitle: 'Colors, styling, and visual customization',
|
||||||
|
icon: 'dashicons-admin-appearance',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'dark_mode',
|
||||||
|
type: 'toggle',
|
||||||
|
label: 'Dark mode',
|
||||||
|
description: 'Use the dark interface theme across the admin.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'accent_color',
|
||||||
|
type: 'color',
|
||||||
|
label: 'Accent color',
|
||||||
|
description: 'Primary accent color used throughout the interface.',
|
||||||
|
default: '#4772fa',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'whitelabel',
|
||||||
|
title: 'White Label',
|
||||||
|
subtitle: 'Rebrand and customize interface text and icons',
|
||||||
|
icon: 'dashicons-tag',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'admin_favicon',
|
||||||
|
type: 'image',
|
||||||
|
label: 'Admin favicon',
|
||||||
|
description: 'Optional favicon to replace the WordPress favicon in the admin.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'plugin_name',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Rename Aurora Admin',
|
||||||
|
description:
|
||||||
|
"This will rename Aurora Admin in the plugins list as well as renaming its settings link.",
|
||||||
|
placeholder: 'Aurora Admin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'text_replacements',
|
||||||
|
type: 'repeater',
|
||||||
|
label: 'Text Replacement',
|
||||||
|
description:
|
||||||
|
'Customize the text displayed in the WordPress admin area. Enter original words or phrases and their replacements to white-label the interface.',
|
||||||
|
columns: ['Find', 'Replace with'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menu',
|
||||||
|
title: 'Menu',
|
||||||
|
subtitle: 'Admin menu behavior and interaction settings',
|
||||||
|
icon: 'dashicons-menu',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'menu_search',
|
||||||
|
type: 'toggle',
|
||||||
|
label: 'Menu search',
|
||||||
|
description: 'Show a search box at the top of the admin menu.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'posts',
|
||||||
|
title: 'Posts',
|
||||||
|
subtitle: 'Post management and display preferences',
|
||||||
|
icon: 'dashicons-admin-post',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'modern_posts',
|
||||||
|
type: 'toggle',
|
||||||
|
label: 'Modern posts table',
|
||||||
|
description: 'Use the Aurora posts management interface.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plugins',
|
||||||
|
title: 'Plugins',
|
||||||
|
subtitle: 'Plugin management interface options',
|
||||||
|
icon: 'dashicons-admin-plugins',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'modern_plugins',
|
||||||
|
type: 'toggle',
|
||||||
|
label: 'Modern plugins page',
|
||||||
|
description: 'Use the Aurora plugins management interface.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
title: 'Dashboard',
|
||||||
|
subtitle: 'Custom dashboard and admin interface settings',
|
||||||
|
icon: 'dashicons-dashboard',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'use_custom_dashboard',
|
||||||
|
type: 'toggle',
|
||||||
|
label: 'Custom dashboard',
|
||||||
|
description: "Replace the WordPress dashboard with Aurora's dashboard.",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'analytics',
|
||||||
|
title: 'Analytics',
|
||||||
|
subtitle: 'Built-in analytics and tracking settings',
|
||||||
|
icon: 'dashicons-chart-bar',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'enable_analytics',
|
||||||
|
type: 'toggle',
|
||||||
|
label: 'Enable analytics',
|
||||||
|
description: 'Track first-party visitor analytics for the dashboard.',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'track_admins',
|
||||||
|
type: 'toggle',
|
||||||
|
label: 'Track logged-in admins',
|
||||||
|
description: 'Include administrator visits in analytics (off by default).',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'media',
|
||||||
|
title: 'Media',
|
||||||
|
subtitle: 'Media library and upload management settings',
|
||||||
|
icon: 'dashicons-admin-media',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'enable_svg',
|
||||||
|
type: 'toggle',
|
||||||
|
label: 'Allow SVG uploads',
|
||||||
|
description: 'Permit SVG files to be uploaded to the media library.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'users',
|
||||||
|
title: 'Users',
|
||||||
|
subtitle: 'User management and administration settings',
|
||||||
|
icon: 'dashicons-admin-users',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'modern_users',
|
||||||
|
type: 'toggle',
|
||||||
|
label: 'Modern users page',
|
||||||
|
description: 'Use the Aurora users management interface.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comments',
|
||||||
|
title: 'Comments',
|
||||||
|
subtitle: 'Comment management and administration',
|
||||||
|
icon: 'dashicons-admin-comments',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'modern_comments',
|
||||||
|
type: 'toggle',
|
||||||
|
label: 'Modern comments page',
|
||||||
|
description: 'Use the Aurora comments management interface.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import Sidebar from './components/Sidebar.vue';
|
||||||
|
import Toolbar from './components/Toolbar.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
serverData: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDark = ref(props.serverData?.settings?.dark_mode === 'on');
|
||||||
|
|
||||||
|
const applyTheme = () => {
|
||||||
|
document.documentElement.classList.toggle('aurora-dark', isDark.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistDark = async () => {
|
||||||
|
try {
|
||||||
|
await fetch(`${props.serverData.restUrl}wp/v2/settings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': props.serverData.restNonce,
|
||||||
|
},
|
||||||
|
// Partial payload: the option's sanitize callback merges this into
|
||||||
|
// the stored settings, so we never write back the (secret-stripped)
|
||||||
|
// settings copy this page received.
|
||||||
|
body: JSON.stringify({
|
||||||
|
aurora_admin_settings: {
|
||||||
|
dark_mode: isDark.value ? 'on' : 'off',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Non-critical: theme still applied for this session.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDark = () => {
|
||||||
|
isDark.value = !isDark.value;
|
||||||
|
applyTheme();
|
||||||
|
persistDark();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minified (icon-only) menu — a per-browser preference kept in
|
||||||
|
// localStorage; the html class drives all layout via --aurora-sidebar-w.
|
||||||
|
const collapsed = ref(localStorage.getItem('aurora-menu-collapsed') === '1');
|
||||||
|
const applyCollapsed = () => {
|
||||||
|
document.documentElement.classList.toggle('aurora-collapsed', collapsed.value);
|
||||||
|
};
|
||||||
|
const toggleMenu = () => {
|
||||||
|
collapsed.value = !collapsed.value;
|
||||||
|
applyCollapsed();
|
||||||
|
localStorage.setItem('aurora-menu-collapsed', collapsed.value ? '1' : '0');
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
applyTheme();
|
||||||
|
applyCollapsed();
|
||||||
|
// Signal the shell is live: reveals the framed content and commits to
|
||||||
|
// keeping native chrome hidden (the PHP failsafe stops watching once
|
||||||
|
// aurora-ready is present).
|
||||||
|
document.documentElement.classList.add('aurora-ready');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="site-shell">
|
||||||
|
<Sidebar
|
||||||
|
:menu="serverData.menu"
|
||||||
|
:aurora-nav="serverData.auroraNav"
|
||||||
|
:current-url="serverData.currentUrl"
|
||||||
|
:logo="serverData.settings?.logo || ''"
|
||||||
|
:dark-logo="serverData.settings?.dark_logo || ''"
|
||||||
|
:is-dark="isDark"
|
||||||
|
:collapsed="collapsed"
|
||||||
|
/>
|
||||||
|
<Toolbar
|
||||||
|
:server-data="serverData"
|
||||||
|
:is-dark="isDark"
|
||||||
|
@toggle-dark="toggleDark"
|
||||||
|
@toggle-menu="toggleMenu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Sidebar and Toolbar are both position:fixed, so this wrapper has no
|
||||||
|
layout role — it just groups them under one mounted root. */
|
||||||
|
.site-shell {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
serverData: { type: Object, required: true },
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const input = ref(null);
|
||||||
|
const query = ref('');
|
||||||
|
const results = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const highlighted = ref(0);
|
||||||
|
|
||||||
|
let debounceTimer;
|
||||||
|
let requestSeq = 0;
|
||||||
|
|
||||||
|
const search = async () => {
|
||||||
|
const q = query.value.trim();
|
||||||
|
if (q.length < 2) {
|
||||||
|
results.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = ++requestSeq;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const url = new URL(`${props.serverData.restUrl}wp/v2/search`);
|
||||||
|
url.searchParams.set('search', q);
|
||||||
|
url.searchParams.set('per_page', '10');
|
||||||
|
// Settings > General > "Search post types" limits which post types the
|
||||||
|
// global search covers; empty means all searchable types.
|
||||||
|
const types = props.serverData.settings?.search_post_types;
|
||||||
|
if (Array.isArray(types) && types.length) {
|
||||||
|
url.searchParams.set('subtype', types.join(','));
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'X-WP-Nonce': props.serverData.restNonce },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const items = await res.json();
|
||||||
|
if (seq === requestSeq) {
|
||||||
|
results.value = items;
|
||||||
|
highlighted.value = 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (seq === requestSeq) results.value = [];
|
||||||
|
} finally {
|
||||||
|
if (seq === requestSeq) loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(query, () => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(search, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
query.value = '';
|
||||||
|
results.value = [];
|
||||||
|
nextTick(() => input.value?.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const openResult = (item) => {
|
||||||
|
if (!item) return;
|
||||||
|
window.location.href = `${props.serverData.adminUrl}post.php?post=${item.id}&action=edit`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeydown = (e) => {
|
||||||
|
if (!props.open) return;
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
emit('close');
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlighted.value = Math.min(highlighted.value + 1, results.value.length - 1);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlighted.value = Math.max(highlighted.value - 1, 0);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
openResult(results.value[highlighted.value]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('keydown', onKeydown));
|
||||||
|
onUnmounted(() => document.removeEventListener('keydown', onKeydown));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="smodal__overlay" @click.self="emit('close')">
|
||||||
|
<div class="smodal">
|
||||||
|
<div class="smodal__inputwrap">
|
||||||
|
<span class="dashicons dashicons-search smodal__icon" />
|
||||||
|
<input
|
||||||
|
ref="input"
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
class="smodal__input"
|
||||||
|
placeholder="Search posts, pages…"
|
||||||
|
/>
|
||||||
|
<span class="smodal__esc">Esc</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="smodal__state">Searching…</div>
|
||||||
|
<div v-else-if="query.trim().length >= 2 && !results.length" class="smodal__state">
|
||||||
|
No results for “{{ query.trim() }}”
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="results.length" class="smodal__results">
|
||||||
|
<li v-for="(item, i) in results" :key="item.id">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="smodal__result"
|
||||||
|
:class="{ 'smodal__result--active': i === highlighted }"
|
||||||
|
@mouseenter="highlighted = i"
|
||||||
|
@click="openResult(item)"
|
||||||
|
>
|
||||||
|
<span class="smodal__result-title" v-html="item.title" />
|
||||||
|
<span class="smodal__result-type">{{ item.subtype }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.smodal__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
z-index: 100000;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 12vh;
|
||||||
|
}
|
||||||
|
.smodal {
|
||||||
|
width: min(620px, calc(100vw - 40px));
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--aurora-frame-bg, #1c1c20);
|
||||||
|
border: 1px solid var(--aurora-frame-border, #34343b);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
.smodal__inputwrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--aurora-border, #27272a);
|
||||||
|
}
|
||||||
|
.smodal__icon { color: var(--aurora-text-muted); }
|
||||||
|
.smodal__input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--aurora-text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.smodal__esc {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
border: 1px solid var(--aurora-border, #27272a);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
.smodal__state {
|
||||||
|
padding: 18px 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
.smodal__results {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.smodal__result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
.smodal__result--active { background: var(--aurora-accent-soft); }
|
||||||
|
.smodal__result-title {
|
||||||
|
color: var(--aurora-text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.smodal__result-type {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
border: 1px solid var(--aurora-border, #27272a);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
menu: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
currentUrl: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
darkLogo: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
isDark: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
collapsed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
auroraNav: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom logo from Settings > General. Dark mode prefers the dark variant
|
||||||
|
// and falls back to the standard logo; no logo set = "Aurora" wordmark.
|
||||||
|
const activeLogo = computed(() =>
|
||||||
|
props.isDark ? props.darkLogo || props.logo : props.logo
|
||||||
|
);
|
||||||
|
|
||||||
|
// Normalize any admin URL down to the part after /wp-admin/ so we can
|
||||||
|
// compare the current page against menu targets regardless of host/scheme.
|
||||||
|
const normalize = (url) => {
|
||||||
|
if (!url) return '';
|
||||||
|
const idx = url.indexOf('/wp-admin/');
|
||||||
|
const path = idx === -1 ? url : url.slice(idx + '/wp-admin/'.length);
|
||||||
|
// The bare admin root ("/wp-admin/" with nothing after it) IS the
|
||||||
|
// dashboard, same as an explicit index.php — normalize them to one form
|
||||||
|
// so the Dashboard section correctly reads as active/expanded there.
|
||||||
|
return path === '' || path.startsWith('?') ? 'index.php' + path : path;
|
||||||
|
};
|
||||||
|
|
||||||
|
const current = computed(() => normalize(props.currentUrl));
|
||||||
|
|
||||||
|
const isItemActive = (item) => {
|
||||||
|
if (!item.url) return false;
|
||||||
|
return normalize(item.url) === current.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSectionActive = (item) => {
|
||||||
|
if (isItemActive(item)) return true;
|
||||||
|
return (item.submenu || []).some((sub) => normalize(sub.url) === current.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Which sections are manually expanded. Active section starts expanded.
|
||||||
|
const expanded = ref(new Set());
|
||||||
|
|
||||||
|
const isExpanded = (item) => {
|
||||||
|
return expanded.value.has(item.id) || isSectionActive(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = (item) => {
|
||||||
|
const next = new Set(expanded.value);
|
||||||
|
if (next.has(item.id)) {
|
||||||
|
next.delete(item.id);
|
||||||
|
} else {
|
||||||
|
next.add(item.id);
|
||||||
|
}
|
||||||
|
expanded.value = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aurora's own branded nav section (Settings, Menu Creator, Admin Notices,
|
||||||
|
// Role Editor, Activity Log, Database Explorer) — separate from the
|
||||||
|
// WP-native menu list above it. Expanded by default; per-browser preference
|
||||||
|
// kept in localStorage, same pattern as the main collapsed-menu state.
|
||||||
|
const auroraExpanded = ref(localStorage.getItem('aurora-nav-expanded') !== '0');
|
||||||
|
const toggleAuroraNav = () => {
|
||||||
|
auroraExpanded.value = !auroraExpanded.value;
|
||||||
|
localStorage.setItem('aurora-nav-expanded', auroraExpanded.value ? '1' : '0');
|
||||||
|
};
|
||||||
|
const isAuroraActive = (item) => normalize(item.url) === current.value;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="sidebar" :class="{ 'sidebar--collapsed': collapsed }">
|
||||||
|
<div class="sidebar__brand">
|
||||||
|
<img v-if="activeLogo" :src="activeLogo" alt="" class="sidebar__brand-img" />
|
||||||
|
<template v-else>{{ collapsed ? 'A' : 'Aurora' }}</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="sidebar__list">
|
||||||
|
<template v-for="(item, index) in menu" :key="item.id || index">
|
||||||
|
<li v-if="item.separator" class="sidebar__separator" />
|
||||||
|
|
||||||
|
<li v-else class="sidebar__item">
|
||||||
|
<div
|
||||||
|
class="sidebar__row"
|
||||||
|
:class="{ 'sidebar__row--active': isSectionActive(item) }"
|
||||||
|
>
|
||||||
|
<a :href="item.url" class="sidebar__link" :title="collapsed ? item.title : null">
|
||||||
|
<span class="sidebar__icon">
|
||||||
|
<span
|
||||||
|
v-if="item.icon && item.icon.type === 'dashicon'"
|
||||||
|
class="dashicons"
|
||||||
|
:class="item.icon.value"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else-if="item.icon && item.icon.type === 'image'"
|
||||||
|
:src="item.icon.value"
|
||||||
|
alt=""
|
||||||
|
class="sidebar__icon-img"
|
||||||
|
/>
|
||||||
|
<span v-else class="sidebar__icon-dot" />
|
||||||
|
</span>
|
||||||
|
<span class="sidebar__title">{{ item.title }}</span>
|
||||||
|
<span v-if="item.count" class="sidebar__count">{{ item.count }}</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
v-if="item.submenu && item.submenu.length"
|
||||||
|
type="button"
|
||||||
|
class="sidebar__chevron"
|
||||||
|
:class="{ 'sidebar__chevron--open': isExpanded(item) }"
|
||||||
|
:aria-label="`Toggle ${item.title} submenu`"
|
||||||
|
@click="toggle(item)"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="item.submenu && item.submenu.length && isExpanded(item)"
|
||||||
|
class="sidebar__sublist"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(sub, subIndex) in item.submenu"
|
||||||
|
:key="sub.id || subIndex"
|
||||||
|
class="sidebar__subitem"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="sub.url"
|
||||||
|
class="sidebar__sublink"
|
||||||
|
:class="{ 'sidebar__sublink--active': normalize(sub.url) === current }"
|
||||||
|
>
|
||||||
|
{{ sub.title }}
|
||||||
|
<span v-if="sub.count" class="sidebar__count">{{ sub.count }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div v-if="auroraNav.length" class="sidebar__separator" />
|
||||||
|
|
||||||
|
<div v-if="auroraNav.length" class="sidebar__aurora">
|
||||||
|
<div class="sidebar__row sidebar__aurora-brand" @click="toggleAuroraNav">
|
||||||
|
<a href="#" class="sidebar__link" @click.prevent :title="collapsed ? 'Aurora' : null">
|
||||||
|
<span class="sidebar__icon">
|
||||||
|
<span class="dashicons dashicons-superhero-alt" />
|
||||||
|
</span>
|
||||||
|
<span class="sidebar__title">Aurora</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sidebar__chevron"
|
||||||
|
:class="{ 'sidebar__chevron--open': auroraExpanded }"
|
||||||
|
aria-label="Toggle Aurora menu"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="auroraExpanded" class="sidebar__list sidebar__aurora-list">
|
||||||
|
<li v-for="item in auroraNav" :key="item.id" class="sidebar__item">
|
||||||
|
<div class="sidebar__row" :class="{ 'sidebar__row--active': isAuroraActive(item) }">
|
||||||
|
<a :href="item.url" class="sidebar__link" :title="collapsed ? item.title : null">
|
||||||
|
<span class="sidebar__icon">
|
||||||
|
<span class="dashicons" :class="item.icon?.value" />
|
||||||
|
</span>
|
||||||
|
<span class="sidebar__title">{{ item.title }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: var(--aurora-sidebar-w);
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--aurora-app-bg);
|
||||||
|
overflow-y: auto;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
z-index: 99999;
|
||||||
|
padding: 12px 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__brand {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--aurora-text);
|
||||||
|
padding: 8px 20px 16px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__brand-img {
|
||||||
|
display: block;
|
||||||
|
max-height: 30px;
|
||||||
|
max-width: 190px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__list,
|
||||||
|
.sidebar__sublist {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__separator {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--aurora-border);
|
||||||
|
margin: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 1px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__row--active {
|
||||||
|
background: var(--aurora-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--aurora-text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__row--active .sidebar__link {
|
||||||
|
color: var(--aurora-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
transition: background 0.12s ease, color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__row--active .sidebar__icon {
|
||||||
|
background: var(--aurora-accent-soft);
|
||||||
|
color: var(--aurora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__icon .dashicons {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__icon-img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__icon-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__title {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__count {
|
||||||
|
background: var(--aurora-accent);
|
||||||
|
color: var(--aurora-accent-text);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__chevron {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 12px;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__chevron--open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__sublist {
|
||||||
|
padding: 2px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__sublink {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px 6px 42px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 1px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__sublink:hover {
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__sublink--active {
|
||||||
|
color: var(--aurora-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Aurora branded nav (right under the native menu's own divider) ---- */
|
||||||
|
.sidebar__aurora {
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
.sidebar__aurora-brand {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.sidebar__aurora-brand .sidebar__title {
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.sidebar__aurora-brand .sidebar__icon {
|
||||||
|
background: var(--aurora-accent-soft);
|
||||||
|
color: var(--aurora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Collapsed (icon-only) rail --------------------------------------- */
|
||||||
|
.sidebar--collapsed {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
.sidebar--collapsed .sidebar__brand {
|
||||||
|
padding: 8px 0 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.sidebar--collapsed .sidebar__brand-img {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 40px;
|
||||||
|
}
|
||||||
|
.sidebar--collapsed .sidebar__title,
|
||||||
|
.sidebar--collapsed .sidebar__count,
|
||||||
|
.sidebar--collapsed .sidebar__chevron,
|
||||||
|
.sidebar--collapsed .sidebar__sublist {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sidebar--collapsed .sidebar__row {
|
||||||
|
margin: 1px 10px;
|
||||||
|
}
|
||||||
|
.sidebar--collapsed .sidebar__link {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
|
import SearchModal from './SearchModal.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
serverData: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isDark: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['toggle-dark', 'toggle-menu']);
|
||||||
|
|
||||||
|
const menuOpen = ref(false);
|
||||||
|
const searchOpen = ref(false);
|
||||||
|
const sitesOpen = ref(false);
|
||||||
|
|
||||||
|
// Settings > General > "Disable global search" hides the search entirely.
|
||||||
|
const searchDisabled = computed(
|
||||||
|
() => !!props.serverData.settings?.disable_global_search
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remote sites (secrets already stripped server-side). The switcher only
|
||||||
|
// shows for users holding the configured capability (canSwitchSites).
|
||||||
|
const remoteSites = computed(() => {
|
||||||
|
const sites = props.serverData.settings?.remote_sites;
|
||||||
|
return Array.isArray(sites) ? sites.filter((s) => s && s.url) : [];
|
||||||
|
});
|
||||||
|
const showSwitcher = computed(
|
||||||
|
() => !!props.serverData.canSwitchSites && remoteSites.value.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const siteAdminUrl = (site) => {
|
||||||
|
let url = (site.url || '').trim();
|
||||||
|
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
|
||||||
|
return url.replace(/\/+$/, '') + '/wp-admin/';
|
||||||
|
};
|
||||||
|
const siteHost = (site) => {
|
||||||
|
try {
|
||||||
|
return new URL(siteAdminUrl(site)).host;
|
||||||
|
} catch {
|
||||||
|
return site.url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeydown = (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
if (searchDisabled.value) return;
|
||||||
|
e.preventDefault();
|
||||||
|
searchOpen.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
onMounted(() => document.addEventListener('keydown', onKeydown));
|
||||||
|
onUnmounted(() => document.removeEventListener('keydown', onKeydown));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="toolbar">
|
||||||
|
<div class="toolbar__left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toolbar__icon-btn"
|
||||||
|
title="Toggle menu"
|
||||||
|
@click="emit('toggle-menu')"
|
||||||
|
>
|
||||||
|
<span class="dashicons dashicons-menu" />
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
:href="serverData.siteUrl || '/'"
|
||||||
|
class="toolbar__home"
|
||||||
|
title="View website"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<span class="dashicons dashicons-admin-home" />
|
||||||
|
</a>
|
||||||
|
<a :href="serverData.adminUrl" class="toolbar__home" title="Dashboard">
|
||||||
|
<span class="dashicons dashicons-dashboard" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
v-if="!searchDisabled"
|
||||||
|
type="button"
|
||||||
|
class="toolbar__search"
|
||||||
|
@click="searchOpen = true"
|
||||||
|
>
|
||||||
|
<span class="dashicons dashicons-search toolbar__search-icon" />
|
||||||
|
<span class="toolbar__search-text">Search…</span>
|
||||||
|
<span class="toolbar__search-kbd">Ctrl K</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar__right">
|
||||||
|
<div v-if="showSwitcher" class="toolbar__sites">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toolbar__icon-btn"
|
||||||
|
title="Switch site"
|
||||||
|
@click="sitesOpen = !sitesOpen"
|
||||||
|
>
|
||||||
|
<span class="dashicons dashicons-admin-multisite" />
|
||||||
|
</button>
|
||||||
|
<div v-if="sitesOpen" class="toolbar__dropdown">
|
||||||
|
<div class="toolbar__dropdown-header">
|
||||||
|
<div class="toolbar__dropdown-name">Remote sites</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
v-for="(site, i) in remoteSites"
|
||||||
|
:key="i"
|
||||||
|
:href="siteAdminUrl(site)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="toolbar__dropdown-item"
|
||||||
|
>
|
||||||
|
{{ siteHost(site) }}
|
||||||
|
<span v-if="site.username" class="toolbar__site-user">{{ site.username }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toolbar__icon-btn toolbar__theme-toggle"
|
||||||
|
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||||
|
@click="emit('toggle-dark')"
|
||||||
|
>
|
||||||
|
{{ isDark ? '☀' : '☾' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="toolbar__user">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toolbar__user-btn"
|
||||||
|
@click="menuOpen = !menuOpen"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="serverData.avatar"
|
||||||
|
:src="serverData.avatar"
|
||||||
|
alt=""
|
||||||
|
class="toolbar__avatar"
|
||||||
|
/>
|
||||||
|
<span class="toolbar__user-name">{{ serverData.userName }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="menuOpen" class="toolbar__dropdown">
|
||||||
|
<div class="toolbar__dropdown-header">
|
||||||
|
<div class="toolbar__dropdown-name">{{ serverData.userName }}</div>
|
||||||
|
<div class="toolbar__dropdown-email">{{ serverData.userEmail }}</div>
|
||||||
|
</div>
|
||||||
|
<a :href="serverData.logoutUrl" class="toolbar__dropdown-item">
|
||||||
|
Log out
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearchModal
|
||||||
|
:server-data="serverData"
|
||||||
|
:open="searchOpen"
|
||||||
|
@close="searchOpen = false"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: var(--aurora-sidebar-w);
|
||||||
|
right: 0;
|
||||||
|
height: var(--aurora-toolbar-h);
|
||||||
|
background: var(--aurora-app-bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
z-index: 99998;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__left,
|
||||||
|
.toolbar__right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__home {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__home:hover {
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
min-width: 240px;
|
||||||
|
border: 1px solid var(--aurora-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__search-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__search-text {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__search-kbd {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
border: 1px solid var(--aurora-border);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: var(--aurora-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__icon-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__icon-btn:hover {
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__theme-toggle {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__user,
|
||||||
|
.toolbar__sites {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__site-user {
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__user-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__user-btn:hover {
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__user-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 200px;
|
||||||
|
background: var(--aurora-bg);
|
||||||
|
border: 1px solid var(--aurora-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 100000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__dropdown-header {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--aurora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__dropdown-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--aurora-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__dropdown-email {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--aurora-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__dropdown-item {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--aurora-text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__dropdown-item:hover {
|
||||||
|
background: var(--aurora-bg-subtle);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* The ONLY global stylesheet in the plugin. Every rule here either defines
|
||||||
|
* an --aurora-* theme variable or targets a specific WordPress structural ID
|
||||||
|
* to frame the native content. No element/tag resets, no utility classes —
|
||||||
|
* so it cannot leak onto or restyle WordPress's own page content the way the
|
||||||
|
* old plugin's Tailwind preflight did.
|
||||||
|
*
|
||||||
|
* Theme colors live here as plain CSS variables we fully control. Dark mode
|
||||||
|
* is a single class on <html> (.aurora-dark) that swaps the variable values.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Palette carried over from the original plugin's design tokens: a zinc
|
||||||
|
neutral scale with a blue/indigo accent, large rounded surfaces. */
|
||||||
|
:root {
|
||||||
|
--aurora-sidebar-w: 264px;
|
||||||
|
--aurora-toolbar-h: 56px;
|
||||||
|
/* Breathing room between page content and the right browser edge. */
|
||||||
|
--aurora-gutter-r: 44px;
|
||||||
|
|
||||||
|
--aurora-radius: 14px;
|
||||||
|
--aurora-radius-lg: 24px;
|
||||||
|
|
||||||
|
/* Light theme */
|
||||||
|
--aurora-app-bg: #fafafa; /* zinc-50 */
|
||||||
|
--aurora-bg: #ffffff; /* frame */
|
||||||
|
--aurora-card-bg: #fafafa; /* zinc-50 */
|
||||||
|
--aurora-bg-subtle: #f4f4f5; /* zinc-100 */
|
||||||
|
--aurora-text: #18181b; /* zinc-900 */
|
||||||
|
--aurora-text-muted: #71717a; /* zinc-500 */
|
||||||
|
--aurora-border: #e4e4e7; /* zinc-200 */
|
||||||
|
--aurora-card-border: #ececef;
|
||||||
|
--aurora-frame-bg: #ffffff; /* native-page content panel */
|
||||||
|
--aurora-frame-border: #e0e0e3;
|
||||||
|
--aurora-accent: rgb(71 114 250);
|
||||||
|
--aurora-accent-soft: rgb(71 114 250 / 0.1);
|
||||||
|
--aurora-accent-text: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minified menu: the toolbar's collapse button toggles this class; every
|
||||||
|
layout offset derives from --aurora-sidebar-w, so this one override
|
||||||
|
reflows the toolbar and content automatically. */
|
||||||
|
html.aurora-collapsed {
|
||||||
|
--aurora-sidebar-w: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.aurora-dark {
|
||||||
|
/* Dark theme — the immersive near-black look of the original */
|
||||||
|
--aurora-app-bg: #09090b; /* zinc-950 */
|
||||||
|
--aurora-bg: #18181b; /* zinc-900 frame */
|
||||||
|
--aurora-card-bg: #1f1f23; /* slightly elevated */
|
||||||
|
--aurora-bg-subtle: #27272a; /* zinc-800 */
|
||||||
|
--aurora-text: #f4f4f5; /* zinc-100 */
|
||||||
|
--aurora-text-muted: #a1a1aa; /* zinc-400 */
|
||||||
|
--aurora-border: #27272a; /* zinc-800 */
|
||||||
|
--aurora-card-border: #2e2e33;
|
||||||
|
--aurora-frame-bg: #1c1c20; /* slightly elevated panel */
|
||||||
|
--aurora-frame-border: #34343b;
|
||||||
|
--aurora-accent: rgb(125 158 255);
|
||||||
|
--aurora-accent-soft: rgb(125 158 255 / 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Framing: only applies once the Vue shell has signalled ready --- */
|
||||||
|
|
||||||
|
html.aurora-ready,
|
||||||
|
html.aurora-ready body {
|
||||||
|
background: var(--aurora-app-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* !important here defeats WordPress's own responsive #wpcontent margin
|
||||||
|
rules (.auto-fold / folded menu states live in media queries at equal
|
||||||
|
specificity). This is positioning the frame, not restyling content.
|
||||||
|
The toolbar clearance lives here (on #wpcontent, which holds everything)
|
||||||
|
rather than on #wpbody, so content rendered above #wpbody — like the
|
||||||
|
takeover dashboard — also clears the fixed toolbar. */
|
||||||
|
html.aurora-ready #wpcontent {
|
||||||
|
margin-left: var(--aurora-sidebar-w) !important;
|
||||||
|
padding-left: 0 !important;
|
||||||
|
padding-top: var(--aurora-toolbar-h);
|
||||||
|
transition: margin-left 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.aurora-ready #wpbody-content {
|
||||||
|
background: var(--aurora-frame-bg);
|
||||||
|
color: var(--aurora-text);
|
||||||
|
border: 1px solid var(--aurora-frame-border);
|
||||||
|
border-radius: var(--aurora-radius-lg);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
margin: 16px var(--aurora-gutter-r) 24px 24px;
|
||||||
|
padding: 32px;
|
||||||
|
min-height: calc(100vh - var(--aurora-toolbar-h) - 40px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The settings page renders its own two boxed panels, so drop the outer
|
||||||
|
content frame there — leaving just those panels on the dark backdrop. */
|
||||||
|
html.aurora-ready body.toplevel_page_aurora-admin #wpbody-content {
|
||||||
|
background: var(--aurora-app-bg);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Native WordPress pages are fully dark-themed by a dedicated stylesheet
|
||||||
|
* (admin/assets/native-dark.css), enqueued only on native pages and gated
|
||||||
|
* on dark mode. It restyles WP's postboxes, forms, tables, buttons, etc.
|
||||||
|
* Keeping that large surface in one isolated, native-only file means it
|
||||||
|
* can never leak onto Aurora's own pages or affect light mode.
|
||||||
|
*/
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
|
||||||
|
// Deliberately simple: one entry in this first slice (dashboard). Add more
|
||||||
|
// entries here as additional pages get rebuilt — same flat pattern each
|
||||||
|
// time, no shared scoping/theme-remap layer to keep in sync across files.
|
||||||
|
export default defineConfig({
|
||||||
|
root: fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: fileURLToPath(new URL('./dist', import.meta.url)),
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: true,
|
||||||
|
manifest: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
shell: fileURLToPath(new URL('./src/entries/shell.js', import.meta.url)),
|
||||||
|
dashboard: fileURLToPath(new URL('./src/entries/dashboard.js', import.meta.url)),
|
||||||
|
settings: fileURLToPath(new URL('./src/entries/settings.js', import.meta.url)),
|
||||||
|
'menu-creator': fileURLToPath(new URL('./src/entries/menu-creator.js', import.meta.url)),
|
||||||
|
'admin-notices': fileURLToPath(new URL('./src/entries/admin-notices.js', import.meta.url)),
|
||||||
|
'role-editor': fileURLToPath(new URL('./src/entries/role-editor.js', import.meta.url)),
|
||||||
|
'activity-log': fileURLToPath(new URL('./src/entries/activity-log.js', import.meta.url)),
|
||||||
|
'database-explorer': fileURLToPath(new URL('./src/entries/database-explorer.js', import.meta.url)),
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
entryFileNames: '[name].[hash].js',
|
||||||
|
chunkFileNames: 'assets/[name].[hash].js',
|
||||||
|
assetFileNames: 'assets/[name].[hash][extname]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Plugin Name: Aurora Admin
|
||||||
|
* Plugin URI: https://aurora.co
|
||||||
|
* Description: A modern, fast WordPress admin interface.
|
||||||
|
* Version: 2.0.0
|
||||||
|
* Author: Aurora
|
||||||
|
* Text Domain: aurora-admin
|
||||||
|
* Domain Path: /languages/
|
||||||
|
* Requires PHP: 7.4
|
||||||
|
* Requires at least: 5.5
|
||||||
|
* License: GPLv2 or later
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined("ABSPATH") || exit();
|
||||||
|
|
||||||
|
define("AURORA_ADMIN_VERSION", "2.0.0");
|
||||||
|
define("AURORA_ADMIN_PATH", plugin_dir_path(__FILE__));
|
||||||
|
define("AURORA_ADMIN_URL", plugins_url("aurora-admin/"));
|
||||||
|
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/App/AuroraAdmin.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/Options/Settings.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/Utility/Assets.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/Shell/MenuSerializer.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/Shell/Shell.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/Pages/DashboardTakeover.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/Pages/SettingsPage.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/Rest/DashboardData.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/Analytics/Analytics.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/Rest/AnalyticsData.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/Posts/ScheduledDayFilter.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/WhiteLabel/WhiteLabel.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/Pages/SubApps.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/MenuCreator/MenuCreator.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/MenuCreator/MenuCreatorData.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/Notices/Notices.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/Notices/NoticesData.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/Roles/RolesData.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/ActivityLog/ActivityLog.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/ActivityLog/ActivityLogData.php";
|
||||||
|
require AURORA_ADMIN_PATH . "admin/src/DatabaseExplorer/DatabaseExplorerData.php";
|
||||||
|
|
||||||
|
new AuroraAdmin\App\AuroraAdmin();
|
||||||
Reference in New Issue
Block a user