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 . ' ' . esc_html( $item['badge'] ) . ''; } /** * 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 '
' . wp_kses_post( $preloader_html ) . '
'; } public function get_toplevel_menu_title() { return apply_filters( 'academy/admin/toplevel_menu_title', __( 'Academy LMS', 'academy' ) ); } public function get_toplevel_menu_icon_url() { // phpcs:disable if ( $this->is_academy_admin_page() ) { $icon_url = 'data:image/svg+xml;base64, ' . base64_encode( file_get_contents( ACADEMY_ASSETS_DIR_PATH . 'images/logo-white.svg' ) ); return apply_filters( 'academy/admin/toplevel_active_menu_icon', $icon_url ); } $icon_url = 'data:image/svg+xml;base64, ' . base64_encode( file_get_contents( ACADEMY_ASSETS_DIR_PATH . 'images/admin-logo.svg' ) ); return apply_filters( 'academy/admin/toplevel_inactive_menu_icon', $icon_url ); } /** * Whether the current admin page lives under the Academy top-level menu * (Dashboard, Courses, Settings, …), i.e. when that menu shows as open and * needs its "active" icon — not only the bare `?page=academy` dashboard. * * @return bool */ private function is_academy_admin_page() { $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; if ( '' === $page ) { return false; } if ( ACADEMY_PLUGIN_SLUG . '-about' === $page ) { return true; } $menu = Helper::get_admin_menu_list(); return isset( $menu[ $page ] ) && ACADEMY_PLUGIN_SLUG === $menu[ $page ]['parent_slug']; } public function get_logo_url(){ return apply_filters( 'academy/admin/logo_url', ACADEMY_ASSETS_URI . 'images/logo.svg' ); } /** * Hide pages shown inside another item's submenu (Helper::nest_admin_menu_pages()) * from the flat WordPress menu. They stay registered so their URLs work. * * @return string */ private function nested_pages_css() { $selectors = []; foreach ( Helper::get_admin_menu_list() as $key => $item ) { if ( ! empty( $item['menu_parent'] ) ) { // `>` so only the page's own item hides, not a parent whose submenu // links to it; `$=` so academy-courses won't match academy-courses-x. $selectors[] = '#adminmenu li.toplevel_page_academy > ul.wp-submenu > li:has(> a[href$="page=' . esc_attr( $key ) . '"])'; } } return $selectors ? "\n\t\t\t" . implode( ",\n\t\t\t", $selectors ) . ' { display: none; }' : ''; } /** * Second-level hover menus for the collapsed Academy LMS flyout. * * WordPress menus are one level deep, so on non-Academy pages hovering * "Academy LMS" showed Courses, Quizzes, … with none of their children * (Category, Tags, Bundles, Certificates, …). This adds each item's * `sub_items` as a nested list that opens beside it on hover/focus. * Academy pages render their own React menu (which already shows the * children), so the script bails out there. * * @return void */ public function print_flyout_submenus() { $submenus = []; foreach ( Helper::get_admin_menu_list() as $key => $item ) { if ( empty( $item['sub_items'] ) || ! empty( $item['menu_parent'] ) || ! current_user_can( $item['capability'] ) ) { continue; } $links = []; foreach ( $item['sub_items'] as $sub ) { if ( ! empty( $sub['page'] ) ) { $url = admin_url( 'admin.php?page=' . $sub['page'] ); } else { $url = admin_url( 'admin.php?page=' . $key . ( ! empty( $sub['slug'] ) ? '&path=' . $sub['slug'] : '' ) ); } $links[] = [ 'url' => $url, 'title' => wp_strip_all_tags( $sub['title'] ), ]; } $submenus[ $key ] = $links; } if ( ! $submenus ) { return; } ?> $item ) { if ( ! empty( $item['separator_after'] ) ) { $selectors[] = '#adminmenu li.toplevel_page_academy > ul.wp-submenu > li:has(> a[href$="page=' . esc_attr( $key ) . '"])'; } } if ( ! $selectors ) { return ''; } return "\n\t\t\t" . implode( ",\n\t\t\t", $selectors ) . " {\n\t\t\t\tmargin-bottom: 7px;\n\t\t\t\tpadding-bottom: 7px;\n\t\t\t\tborder-bottom: 1px solid hsla(0,0%,100%,.2);\n\t\t\t}"; } function add_admin_menu_css() { echo ''; } }