*/ namespace ThemeAtelier\Darkify\Admin; use ThemeAtelier\Darkify\Admin\Schema\SchemaRegistry; if (! defined('ABSPATH')) { die; } class Menu { /** * The option key whose page args drive the menu. */ const OPTION_KEY = 'darkify'; /** * The main SPA page slug (unchanged from the old options screen). */ const PAGE_SLUG = 'darkify'; /** * The Help page's own slug. It is a separate WordPress menu entry — not a tab, * modal or panel — so Help is reachable in one click from anywhere in wp-admin. */ const HELP_SLUG = 'darkify-help'; public function __construct() { \add_action('admin_menu', [$this, 'register']); // Old bookmarks / links to the retired "Get Help" screen are forwarded to // the SPA's own Help route (see redirect_legacy_help_page()). \add_action('admin_page_access_denied', [$this, 'redirect_legacy_help_page']); } /** * Register the top-level menu page, plus its submenu entries: an explicit * "Darkify" entry, "Get Help", and the free plugin's existing * "Upgrade To Pro! 👑" link. * * Every settings section is reached from the React admin's own header nav * instead of WP submenus, but Get Help gets its own submenu entry (matching * Pro's Menu.php) so it is reachable in one click from anywhere in wp-admin, * not only from inside the settings screen. The upgrade submenu is the same * external pricing link the free plugin has always registered — the * upgrade flow is deliberately unchanged by the React migration. */ public function register(): void { $args = SchemaRegistry::options(self::OPTION_KEY); $menu_title = $args['menu_title'] ?? \esc_html__('Darkify', 'darkify'); $capability = \apply_filters('darkify_ui_permission', $args['menu_capability'] ?? 'manage_options'); $position = $args['menu_position'] ?? null; $icon = $args['menu_icon'] ?? 'dashicons-lightbulb'; \add_menu_page( $menu_title, $menu_title, $capability, self::PAGE_SLUG, [$this, 'render'], $icon, $position ); // Registered explicitly with the parent's own slug so it reads "Darkify" // instead of WordPress auto-duplicating the top-level menu title as the // first submenu item — that auto-duplication only happens when nothing // else claims the parent slug as a submenu (see Pro's Menu.php, same fix). \add_submenu_page( self::PAGE_SLUG, $menu_title, \esc_html__('Darkify', 'darkify'), $capability, self::PAGE_SLUG, [$this, 'render'] ); // Get Help is no longer a WP submenu — it lives in the SPA rail's footer // (see AppSidebar). The retired `?page=darkify-help` still forwards to the // SPA's /help route (see below). \add_submenu_page( self::PAGE_SLUG, \__('Upgrade To Pro! 👑', 'darkify'), \sprintf('%s', \__('Upgrade To Pro! 👑', 'darkify')), 'manage_options', 'https://darkifywp.com/pricing/?utm_source=darkify_plugin&utm_medium=submenu_page&utm_campaign=regular' ); } /** * Forward the retired `?page=darkify-help` screen to the SPA's Help route. * * "Get Help" used to be its own menu page; it is now a route inside the one * SPA page. Without this, an existing bookmark (or any third-party link) to * the old slug would hit WordPress's "you are not allowed to access this * page" wall, since the slug is no longer a registered page. * * Hooked on `admin_page_access_denied` — which wp-admin/includes/menu.php * fires immediately before that `wp_die( …, 403 )` — because that check runs * while menu.php is being required, i.e. BEFORE `admin_init`; an admin_init * hook would never get the chance to redirect. The slug guard keeps this * scoped to our own retired page, so every other denied page still dies * normally. */ public function redirect_legacy_help_page(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen check, no state is changed. $page = isset($_GET['page']) ? \sanitize_key(\wp_unslash($_GET['page'])) : ''; if ($page !== self::HELP_SLUG) { return; } \wp_safe_redirect(\admin_url('admin.php?page=' . self::PAGE_SLUG . '#/help')); exit; } /** * The page body: just the node React mounts on. Every menu entry renders this * same root — the SPA reads the `?page=` slug to decide which route to open. * * The `darkify_ignore` class opts the whole React admin out of Darkify's own * dark-mode engine. When "Admin Panel Dark Mode" is on, that engine runs on * every admin screen and force-rewrites element backgrounds/colors with * `!important` (classifying nodes as `darkify_style_bg` / `darkify_style_button` * etc. — see src/assets/css/client_main.css). On our settings screen that * stomped the app's own theme — most visibly the colour-preset swatches, whose * inline background was overwritten so every preview looked empty. The React * admin already themes itself via the `.dark` class, so the engine must leave * its subtree alone; `.darkify_ignore` / `.darkify_ignore *` is that engine's * built-in exclusion hook. * * `translate="no"` / `.notranslate` opts the subtree out of browser page * translation (Chrome, Edge). Those translators replace each text node with * a `` wrapper; React later tries to remove the original node, hits * "Failed to execute 'removeChild' on 'Node'", and the whole app unmounts. * Because saving re-renders (spinner, then toast), the crash landed exactly * on Save and looked like settings failing to save. The panel ships real * translations instead — see languages/ — so nothing is lost by declining * the browser's. */ public function render(): void { echo '
'; } }