*/ 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']); \add_action('admin_head', [$this, 'menu_icon_style']); \add_action('admin_footer', [$this, 'upgrade_link_new_tab']); // 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' ); } /** * Open the "Upgrade To Pro!" submenu link in a new tab. * * That submenu is registered with an external URL as its slug, so WordPress * renders it as an ordinary anchor into wp-admin's menu. Leaving wp-admin * entirely is not what a menu click should do — someone half-way through * configuring the plugin loses the screen they were on, and the back button * is their only way back. * * There is no filter for the attributes of that anchor: wp-admin builds the * markup itself in wp-admin/menu-header.php, and the only thing under our * control is the title string, which is escaped into the link text. So the * attributes are set client-side instead, keyed off the class the title * already carries. * * `rel` is not optional here. A `target="_blank"` link hands the opened page * a `window.opener` reference to this one; `noopener` severs it, and * `noreferrer` keeps the admin URL (which can carry screen and query * context) out of the referrer header sent to the marketing site. */ public function upgrade_link_new_tab(): void { ?> element, and nothing outside the alphabet gets * through. */ if (! \is_string($icon) || ! \preg_match('#^data:image/svg\+xml;base64,[A-Za-z0-9+/]+={0,2}$#', $icon)) { return; } $selector = '#adminmenu .toplevel_page_' . self::PAGE_SLUG . ' div.wp-menu-image.svg'; $url = "url('" . $icon . "')"; $css = $selector . '{' . 'background-image:none!important;' . '-webkit-mask:' . $url . ' no-repeat center;' . 'mask:' . $url . ' no-repeat center;' . '-webkit-mask-size:20px auto;' . 'mask-size:20px auto;' . 'background-color:currentColor!important;' // Match the 0.6 dashicons rest at, and the full opacity they take on // hover and while current. . 'opacity:.6;' . '}' . '#adminmenu .toplevel_page_' . self::PAGE_SLUG . ':hover div.wp-menu-image.svg,' . '#adminmenu .toplevel_page_' . self::PAGE_SLUG . '.current div.wp-menu-image.svg,' . '#adminmenu .toplevel_page_' . self::PAGE_SLUG . '.wp-has-current-submenu div.wp-menu-image.svg' . '{opacity:1;}'; echo ''; } /** * 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 '
'; } }