commit 03cabe2fe4a147d6550780865d005a51b91b1773 Author: Kris Parish Date: Fri Jul 3 02:53:29 2026 -0500 Initial commit: Aurora Admin plugin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2975e2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +app/node_modules/ +app/dist/ +*.log +.DS_Store +Thumbs.db diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md new file mode 100644 index 0000000..680380a --- /dev/null +++ b/PROJECT_STATUS.md @@ -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 `
` + 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 + `
` — 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). diff --git a/admin/assets/native-dark.css b/admin/assets/native-dark.css new file mode 100644 index 0000000..4606f52 --- /dev/null +++ b/admin/assets/native-dark.css @@ -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); +} diff --git a/admin/assets/native-enhance.js b/admin/assets/native-enhance.js new file mode 100644 index 0000000..c451cbe --- /dev/null +++ b/admin/assets/native-enhance.js @@ -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(); + } +})(); diff --git a/admin/src/ActivityLog/ActivityLog.php b/admin/src/ActivityLog/ActivityLog.php new file mode 100644 index 0000000..0376ffe --- /dev/null +++ b/admin/src/ActivityLog/ActivityLog.php @@ -0,0 +1,144 @@ +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"); + } +} diff --git a/admin/src/ActivityLog/ActivityLogData.php b/admin/src/ActivityLog/ActivityLogData.php new file mode 100644 index 0000000..6c10ff6 --- /dev/null +++ b/admin/src/ActivityLog/ActivityLogData.php @@ -0,0 +1,91 @@ + "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); + } +} diff --git a/admin/src/Analytics/Analytics.php b/admin/src/Analytics/Analytics.php new file mode 100644 index 0000000..b0fe807 --- /dev/null +++ b/admin/src/Analytics/Analytics.php @@ -0,0 +1,191 @@ +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 + ); + } +} diff --git a/admin/src/App/AuroraAdmin.php b/admin/src/App/AuroraAdmin.php new file mode 100644 index 0000000..8c396f3 --- /dev/null +++ b/admin/src/App/AuroraAdmin.php @@ -0,0 +1,54 @@ +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[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) . "`"; + } +} diff --git a/admin/src/MenuCreator/MenuCreator.php b/admin/src/MenuCreator/MenuCreator.php new file mode 100644 index 0000000..89d0b3d --- /dev/null +++ b/admin/src/MenuCreator/MenuCreator.php @@ -0,0 +1,119 @@ +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"]); + } + } + } + } + } +} diff --git a/admin/src/MenuCreator/MenuCreatorData.php b/admin/src/MenuCreator/MenuCreatorData.php new file mode 100644 index 0000000..fa3d2bf --- /dev/null +++ b/admin/src/MenuCreator/MenuCreatorData.php @@ -0,0 +1,225 @@ + "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\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; + } +} diff --git a/admin/src/Notices/Notices.php b/admin/src/Notices/Notices.php new file mode 100644 index 0000000..b683123 --- /dev/null +++ b/admin/src/Notices/Notices.php @@ -0,0 +1,98 @@ +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( + '

%2$s%3$s

', + esc_attr($classes), + esc_html($row->title), + $row->message ? ": " . esc_html($row->message) : "" + ); + } + } +} diff --git a/admin/src/Notices/NoticesData.php b/admin/src/Notices/NoticesData.php new file mode 100644 index 0000000..8c278b3 --- /dev/null +++ b/admin/src/Notices/NoticesData.php @@ -0,0 +1,150 @@ + "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\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)); + } +} diff --git a/admin/src/Options/Settings.php b/admin/src/Options/Settings.php new file mode 100644 index 0000000..ff66d28 --- /dev/null +++ b/admin/src/Options/Settings.php @@ -0,0 +1,96 @@ + "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; + } +} diff --git a/admin/src/Pages/Dashboard.php b/admin/src/Pages/Dashboard.php new file mode 100644 index 0000000..fc52666 --- /dev/null +++ b/admin/src/Pages/Dashboard.php @@ -0,0 +1,71 @@ + esc_url_raw(get_rest_url()), + "restNonce" => wp_create_nonce("wp_rest"), + "userName" => $current_user->display_name, + "settings" => Settings::get(), + ]; + ?> +
+ + + esc_url_raw(get_rest_url()), + "restNonce" => wp_create_nonce("wp_rest"), + "userName" => $current_user->display_name, + "settings" => Settings::get_safe(), + ]; + ?> +
+ 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, + ]; + ?> +
+ ["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"; + ?> +
+ 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"); + ?> +
+

+ " . esc_html($label) . "" + ); ?> +   +

+
+ "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"; + } +} diff --git a/admin/src/Rest/DashboardData.php b/admin/src/Rest/DashboardData.php new file mode 100644 index 0000000..6e5bb4d --- /dev/null +++ b/admin/src/Rest/DashboardData.php @@ -0,0 +1,292 @@ + "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; + } +} diff --git a/admin/src/Roles/RolesData.php b/admin/src/Roles/RolesData.php new file mode 100644 index 0000000..e10c426 --- /dev/null +++ b/admin/src/Roles/RolesData.php @@ -0,0 +1,166 @@ + "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[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; + } +} diff --git a/admin/src/Shell/MenuSerializer.php b/admin/src/Shell/MenuSerializer.php new file mode 100644 index 0000000..5d4afc9 --- /dev/null +++ b/admin/src/Shell/MenuSerializer.php @@ -0,0 +1,177 @@ + 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('/]*>(\d+)/', $raw, $matches)) { + $count = (int) $matches[1]; + } + + // WordPress menu titles are "Label count bubble" — 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; + } +} diff --git a/admin/src/Shell/Shell.php b/admin/src/Shell/Shell.php new file mode 100644 index 0000000..5016ee3 --- /dev/null +++ b/admin/src/Shell/Shell.php @@ -0,0 +1,251 @@ +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"; + ?> + + + + // 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(), + ]; + ?> +
+ "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; + } +} diff --git a/admin/src/Utility/Assets.php b/admin/src/Utility/Assets.php new file mode 100644 index 0000000..e5a213b --- /dev/null +++ b/admin/src/Utility/Assets.php @@ -0,0 +1,96 @@ +|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('' . "\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); + } +} diff --git a/app/package-lock.json b/app/package-lock.json new file mode 100644 index 0000000..420c276 --- /dev/null +++ b/app/package-lock.json @@ -0,0 +1,1363 @@ +{ + "name": "aurora-admin-app", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aurora-admin-app", + "version": "2.0.0", + "dependencies": { + "vue": "^3.5.26" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "vite": "^6.0.7" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..ffed15b --- /dev/null +++ b/app/package.json @@ -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" + } +} diff --git a/app/src/components/Toggle.vue b/app/src/components/Toggle.vue new file mode 100644 index 0000000..1018310 --- /dev/null +++ b/app/src/components/Toggle.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/app/src/entries/activity-log.js b/app/src/entries/activity-log.js new file mode 100644 index 0000000..4d2639f --- /dev/null +++ b/app/src/entries/activity-log.js @@ -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); +} diff --git a/app/src/entries/admin-notices.js b/app/src/entries/admin-notices.js new file mode 100644 index 0000000..f492747 --- /dev/null +++ b/app/src/entries/admin-notices.js @@ -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); +} diff --git a/app/src/entries/dashboard.js b/app/src/entries/dashboard.js new file mode 100644 index 0000000..3c631ca --- /dev/null +++ b/app/src/entries/dashboard.js @@ -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); +} diff --git a/app/src/entries/database-explorer.js b/app/src/entries/database-explorer.js new file mode 100644 index 0000000..dd67002 --- /dev/null +++ b/app/src/entries/database-explorer.js @@ -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); +} diff --git a/app/src/entries/menu-creator.js b/app/src/entries/menu-creator.js new file mode 100644 index 0000000..7e7d644 --- /dev/null +++ b/app/src/entries/menu-creator.js @@ -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); +} diff --git a/app/src/entries/role-editor.js b/app/src/entries/role-editor.js new file mode 100644 index 0000000..da56b68 --- /dev/null +++ b/app/src/entries/role-editor.js @@ -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); +} diff --git a/app/src/entries/settings.js b/app/src/entries/settings.js new file mode 100644 index 0000000..f6a5139 --- /dev/null +++ b/app/src/entries/settings.js @@ -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); +} diff --git a/app/src/entries/shell.js b/app/src/entries/shell.js new file mode 100644 index 0000000..cca3f92 --- /dev/null +++ b/app/src/entries/shell.js @@ -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); +} diff --git a/app/src/pages/_shared/AppListItem.vue b/app/src/pages/_shared/AppListItem.vue new file mode 100644 index 0000000..c6757e9 --- /dev/null +++ b/app/src/pages/_shared/AppListItem.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/app/src/pages/_shared/AppListShell.vue b/app/src/pages/_shared/AppListShell.vue new file mode 100644 index 0000000..1200a26 --- /dev/null +++ b/app/src/pages/_shared/AppListShell.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/app/src/pages/activity-log/ActivityLog.vue b/app/src/pages/activity-log/ActivityLog.vue new file mode 100644 index 0000000..8528461 --- /dev/null +++ b/app/src/pages/activity-log/ActivityLog.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/app/src/pages/admin-notices/AdminNotices.vue b/app/src/pages/admin-notices/AdminNotices.vue new file mode 100644 index 0000000..fe207c4 --- /dev/null +++ b/app/src/pages/admin-notices/AdminNotices.vue @@ -0,0 +1,217 @@ + + +