get_toplevel_menu_icon_url(); $page_title = $this->get_toplevel_menu_title(); add_menu_page( $page_title, $page_title, 'manage_options', ACADEMY_PLUGIN_SLUG, [ $this, 'load_main_template' ], $icon_url, 58 ); foreach ( Helper::get_admin_menu_list() as $item_key => $item ) { add_submenu_page( $item['parent_slug'], $item['title'], $this->menu_title( $item ), $item['capability'], $item_key, [ $this, 'load_main_template' ] ); } // "About" — registered as a real submenu so ?page=academy-about // resolves and passes the admin-page access check, but hidden from the // visible menu via CSS (add_admin_menu_css). It is shown once per update // instead of via a permanent menu item. $about = ACADEMY_PLUGIN_SLUG . '-about'; add_submenu_page( ACADEMY_PLUGIN_SLUG, __( 'About', 'academy' ), __( 'About', 'academy' ), 'manage_options', $about, [ $this, 'load_main_template' ] ); } /** * A menu item's name, with its badge ("New") when it has one. * * @param array $item Menu item. * @return string */ private function menu_title( array $item ) { // A group's link shows the group name (Helper::nest_admin_menu_pages()). $title = ! empty( $item['menu_title'] ) ? $item['menu_title'] : $item['title']; if ( empty( $item['badge'] ) ) { return $title; } return $title . ' '; } /** * Show the "About" page once after each plugin update. * * Driven entirely by the version bump: Migration::run_migration() sets the * `show_whats_new` flag (in `academy_migrations`) when ACADEMY_VERSION changes (updates only, * not fresh installs). This just consumes that flag once — redirecting to * the About page from ANY screen the first safe request after the update, * then clearing the flag so it never fires again until the next version bump. * Contexts where a redirect would break something (the update/install flow, * bulk/row actions, form POSTs) are skipped so it lands on the next safe page. * * @return void */ public function maybe_redirect_about() { if ( wp_doing_ajax() || wp_doing_cron() || is_network_admin() || ! current_user_can( 'manage_options' ) ) { return; } // Nothing to do unless the migrator flagged a version change. if ( ! \Academy\Options::get( \Academy\Options::MIGRATIONS, 'show_whats_new' ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; $about = ACADEMY_PLUGIN_SLUG . '-about'; // Already on the About page → clear the flag and stop redirecting. if ( $about === $page ) { \Academy\Options::delete( \Academy\Options::MIGRATIONS, 'show_whats_new' ); return; } // Don't hijack the update/install flow, bulk or row actions, or form // submissions — let those complete and fire on the next plain page view // (the flag survives, so the redirect still happens next safe request). global $pagenow; $blocked_pages = [ 'update.php', 'update-core.php', 'plugins.php', 'plugin-install.php', 'admin-post.php', 'options.php', 'async-upload.php', 'media-upload.php' ]; if ( in_array( $pagenow, $blocked_pages, true ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( 'GET' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) ) || isset( $_GET['action'] ) ) { return; } // Clear before redirecting so a failed redirect can't cause a loop. \Academy\Options::delete( \Academy\Options::MIGRATIONS, 'show_whats_new' ); wp_safe_redirect( admin_url( 'admin.php?page=' . $about ) ); exit; } // Menu visibility for `academy_manager` (academy/openspec/changes/academy-manager-role/) // is deliberately NOT handled here. That role carries no capabilities of // its own beyond a marker, so a holder is routed through the exact same // per-area menu filtering every other Manage Roles staff member already // gets from the pro `role-permission` addon's own // `AcademyProRolePermission\Menu::filter_menu_for_staff()` / // `relax_top_level()` — a blanket "show everything" version lived here // briefly and was reverted once it defeated per-area granularity. /** * Prints the root element the Academy admin app mounts into. */ public function load_main_template() { $preloader_html = apply_filters( 'academy/preloader', academy_get_preloader_html() ); echo '