*/
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 '';
}
}