| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* The Darkify admin menu. |
| 5 |
* |
| 6 |
* Registers the top-level menu page that hosts the React SPA, plus a "Get Help" |
| 7 |
* submenu entry of its own. Both render the same SPA root node; which page the |
| 8 |
* app opens on is derived from the `?page=` slug (see resolveInitialRoute in |
| 9 |
* darkify-react/src/main.jsx), so "Get Help" lands directly on the dedicated Help |
| 10 |
* page while the main entry lands on the settings sections. |
| 11 |
* |
| 12 |
* The menu title, slug, icon and position all come from the args the config class |
| 13 |
* already declared via `DarkifyOptions::options()` |
| 14 |
* (src/Admin/Views/DarkifyOptions.php), so the menu keeps its existing slug |
| 15 |
* (`darkify`), icon and place in the sidebar — an existing bookmark to |
| 16 |
* `?page=darkify` still lands on the settings screen, it is simply a React one now. |
| 17 |
* |
| 18 |
* @package darkify |
| 19 |
* @subpackage darkify/src/Admin |
| 20 |
* @author ThemeAtelier<themeatelierbd@gmail.com> |
| 21 |
*/ |
| 22 |
|
| 23 |
namespace ThemeAtelier\Darkify\Admin; |
| 24 |
|
| 25 |
use ThemeAtelier\Darkify\Admin\Schema\SchemaRegistry; |
| 26 |
|
| 27 |
if (! defined('ABSPATH')) { |
| 28 |
die; |
| 29 |
} |
| 30 |
|
| 31 |
class Menu |
| 32 |
{ |
| 33 |
/** |
| 34 |
* The option key whose page args drive the menu. |
| 35 |
*/ |
| 36 |
const OPTION_KEY = 'darkify'; |
| 37 |
|
| 38 |
/** |
| 39 |
* The main SPA page slug (unchanged from the old options screen). |
| 40 |
*/ |
| 41 |
const PAGE_SLUG = 'darkify'; |
| 42 |
|
| 43 |
/** |
| 44 |
* The Help page's own slug. It is a separate WordPress menu entry — not a tab, |
| 45 |
* modal or panel — so Help is reachable in one click from anywhere in wp-admin. |
| 46 |
*/ |
| 47 |
const HELP_SLUG = 'darkify-help'; |
| 48 |
|
| 49 |
public function __construct() |
| 50 |
{ |
| 51 |
\add_action('admin_menu', [$this, 'register']); |
| 52 |
\add_action('admin_head', [$this, 'menu_icon_style']); |
| 53 |
\add_action('admin_footer', [$this, 'upgrade_link_new_tab']); |
| 54 |
// Old bookmarks / links to the retired "Get Help" screen are forwarded to |
| 55 |
// the SPA's own Help route (see redirect_legacy_help_page()). |
| 56 |
\add_action('admin_page_access_denied', [$this, 'redirect_legacy_help_page']); |
| 57 |
} |
| 58 |
|
| 59 |
/** |
| 60 |
* Register the top-level menu page, plus its submenu entries: an explicit |
| 61 |
* "Darkify" entry, "Get Help", and the free plugin's existing |
| 62 |
* "Upgrade To Pro! 👑" link. |
| 63 |
* |
| 64 |
* Every settings section is reached from the React admin's own header nav |
| 65 |
* instead of WP submenus, but Get Help gets its own submenu entry (matching |
| 66 |
* Pro's Menu.php) so it is reachable in one click from anywhere in wp-admin, |
| 67 |
* not only from inside the settings screen. The upgrade submenu is the same |
| 68 |
* external pricing link the free plugin has always registered — the |
| 69 |
* upgrade flow is deliberately unchanged by the React migration. |
| 70 |
*/ |
| 71 |
public function register(): void |
| 72 |
{ |
| 73 |
$args = SchemaRegistry::options(self::OPTION_KEY); |
| 74 |
|
| 75 |
$menu_title = $args['menu_title'] ?? \esc_html__('Darkify', 'darkify'); |
| 76 |
$capability = \apply_filters('darkify_ui_permission', $args['menu_capability'] ?? 'manage_options'); |
| 77 |
$position = $args['menu_position'] ?? null; |
| 78 |
$icon = $args['menu_icon'] ?? 'dashicons-lightbulb'; |
| 79 |
|
| 80 |
\add_menu_page( |
| 81 |
$menu_title, |
| 82 |
$menu_title, |
| 83 |
$capability, |
| 84 |
self::PAGE_SLUG, |
| 85 |
[$this, 'render'], |
| 86 |
$icon, |
| 87 |
$position |
| 88 |
); |
| 89 |
|
| 90 |
// Registered explicitly with the parent's own slug so it reads "Darkify" |
| 91 |
// instead of WordPress auto-duplicating the top-level menu title as the |
| 92 |
// first submenu item — that auto-duplication only happens when nothing |
| 93 |
// else claims the parent slug as a submenu (see Pro's Menu.php, same fix). |
| 94 |
\add_submenu_page( |
| 95 |
self::PAGE_SLUG, |
| 96 |
$menu_title, |
| 97 |
\esc_html__('Darkify', 'darkify'), |
| 98 |
$capability, |
| 99 |
self::PAGE_SLUG, |
| 100 |
[$this, 'render'] |
| 101 |
); |
| 102 |
|
| 103 |
// Get Help is no longer a WP submenu — it lives in the SPA rail's footer |
| 104 |
// (see AppSidebar). The retired `?page=darkify-help` still forwards to the |
| 105 |
// SPA's /help route (see below). |
| 106 |
|
| 107 |
\add_submenu_page( |
| 108 |
self::PAGE_SLUG, |
| 109 |
\__('Upgrade To Pro! 👑', 'darkify'), |
| 110 |
\sprintf('<span style="color: #35b747;font-weight:600;" class="darkify-get-pro-text">%s</span>', \__('Upgrade To Pro! 👑', 'darkify')), |
| 111 |
'manage_options', |
| 112 |
'https://darkifywp.com/pricing/?utm_source=darkify_plugin&utm_medium=submenu_page&utm_campaign=regular' |
| 113 |
); |
| 114 |
} |
| 115 |
|
| 116 |
/** |
| 117 |
* Open the "Upgrade To Pro!" submenu link in a new tab. |
| 118 |
* |
| 119 |
* That submenu is registered with an external URL as its slug, so WordPress |
| 120 |
* renders it as an ordinary anchor into wp-admin's menu. Leaving wp-admin |
| 121 |
* entirely is not what a menu click should do — someone half-way through |
| 122 |
* configuring the plugin loses the screen they were on, and the back button |
| 123 |
* is their only way back. |
| 124 |
* |
| 125 |
* There is no filter for the attributes of that anchor: wp-admin builds the |
| 126 |
* markup itself in wp-admin/menu-header.php, and the only thing under our |
| 127 |
* control is the title string, which is escaped into the link text. So the |
| 128 |
* attributes are set client-side instead, keyed off the class the title |
| 129 |
* already carries. |
| 130 |
* |
| 131 |
* `rel` is not optional here. A `target="_blank"` link hands the opened page |
| 132 |
* a `window.opener` reference to this one; `noopener` severs it, and |
| 133 |
* `noreferrer` keeps the admin URL (which can carry screen and query |
| 134 |
* context) out of the referrer header sent to the marketing site. |
| 135 |
*/ |
| 136 |
public function upgrade_link_new_tab(): void |
| 137 |
{ |
| 138 |
?> |
| 139 |
<script> |
| 140 |
(function () { |
| 141 |
var label = document.querySelector('#adminmenu .darkify-get-pro-text'); |
| 142 |
var link = label && label.closest('a'); |
| 143 |
if (!link) { |
| 144 |
return; |
| 145 |
} |
| 146 |
link.target = '_blank'; |
| 147 |
link.rel = 'noopener noreferrer'; |
| 148 |
})(); |
| 149 |
</script> |
| 150 |
<?php |
| 151 |
} |
| 152 |
|
| 153 |
/** |
| 154 |
* Forward the retired `?page=darkify-help` screen to the SPA's Help route. |
| 155 |
* |
| 156 |
* "Get Help" used to be its own menu page; it is now a route inside the one |
| 157 |
* SPA page. Without this, an existing bookmark (or any third-party link) to |
| 158 |
* the old slug would hit WordPress's "you are not allowed to access this |
| 159 |
* page" wall, since the slug is no longer a registered page. |
| 160 |
* |
| 161 |
* Hooked on `admin_page_access_denied` — which wp-admin/includes/menu.php |
| 162 |
* fires immediately before that `wp_die( …, 403 )` — because that check runs |
| 163 |
* while menu.php is being required, i.e. BEFORE `admin_init`; an admin_init |
| 164 |
* hook would never get the chance to redirect. The slug guard keeps this |
| 165 |
* scoped to our own retired page, so every other denied page still dies |
| 166 |
* normally. |
| 167 |
*/ |
| 168 |
public function redirect_legacy_help_page(): void |
| 169 |
{ |
| 170 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen check, no state is changed. |
| 171 |
$page = isset($_GET['page']) ? \sanitize_key(\wp_unslash($_GET['page'])) : ''; |
| 172 |
if ($page !== self::HELP_SLUG) { |
| 173 |
return; |
| 174 |
} |
| 175 |
|
| 176 |
\wp_safe_redirect(\admin_url('admin.php?page=' . self::PAGE_SLUG . '#/help')); |
| 177 |
exit; |
| 178 |
} |
| 179 |
|
| 180 |
/** |
| 181 |
* Make the menu icon take its colour from the menu, like every other icon. |
| 182 |
* |
| 183 |
* WordPress renders a `data:` menu icon as a CSS `background-image` on |
| 184 |
* `div.wp-menu-image.svg`, and ours has `fill="white"` baked into the SVG. A |
| 185 |
* background-image cannot inherit `color`, so while WordPress's own icons are |
| 186 |
* dashicons — font glyphs that follow the menu's text colour and dim to 0.6 |
| 187 |
* opacity — ours painted flat white and read as brighter than everything |
| 188 |
* around it. Dark mode made it obvious, but it was never matching; the dark |
| 189 |
* sidebar just hid it. |
| 190 |
* |
| 191 |
* Switching from background-image to a `mask` inverts the relationship: the |
| 192 |
* SVG becomes a stencil and `background-color: currentColor` supplies the |
| 193 |
* paint, so the icon follows the menu's colour exactly as a dashicon does — |
| 194 |
* under any admin colour scheme, under any Darkify palette, with no colour |
| 195 |
* hardcoded here. |
| 196 |
* |
| 197 |
* Printed from the same schema value `register()` passes to add_menu_page(), |
| 198 |
* so there is only ever one copy of the SVG to keep correct. |
| 199 |
*/ |
| 200 |
public function menu_icon_style(): void |
| 201 |
{ |
| 202 |
$args = SchemaRegistry::options(self::OPTION_KEY); |
| 203 |
$icon = $args['menu_icon'] ?? ''; |
| 204 |
|
| 205 |
/* |
| 206 |
* Dashicon slugs and image files are already coloured (or already correct) |
| 207 |
* by WordPress; only a base64 inline SVG needs this treatment. |
| 208 |
* |
| 209 |
* Matched against a strict pattern rather than escaped with esc_url(): |
| 210 |
* `data:` is not in wp_allowed_protocols(), so esc_url() strips the whole |
| 211 |
* value and the icon disappears. The pattern is what makes it safe to |
| 212 |
* print raw — base64 cannot contain the `<` that would be needed to close |
| 213 |
* the surrounding <style> element, and nothing outside the alphabet gets |
| 214 |
* through. |
| 215 |
*/ |
| 216 |
if (! \is_string($icon) || ! \preg_match('#^data:image/svg\+xml;base64,[A-Za-z0-9+/]+={0,2}$#', $icon)) { |
| 217 |
return; |
| 218 |
} |
| 219 |
|
| 220 |
$selector = '#adminmenu .toplevel_page_' . self::PAGE_SLUG . ' div.wp-menu-image.svg'; |
| 221 |
$url = "url('" . $icon . "')"; |
| 222 |
|
| 223 |
$css = $selector . '{' |
| 224 |
. 'background-image:none!important;' |
| 225 |
. '-webkit-mask:' . $url . ' no-repeat center;' |
| 226 |
. 'mask:' . $url . ' no-repeat center;' |
| 227 |
. '-webkit-mask-size:20px auto;' |
| 228 |
. 'mask-size:20px auto;' |
| 229 |
. 'background-color:currentColor!important;' |
| 230 |
// Match the 0.6 dashicons rest at, and the full opacity they take on |
| 231 |
// hover and while current. |
| 232 |
. 'opacity:.6;' |
| 233 |
. '}' |
| 234 |
. '#adminmenu .toplevel_page_' . self::PAGE_SLUG . ':hover div.wp-menu-image.svg,' |
| 235 |
. '#adminmenu .toplevel_page_' . self::PAGE_SLUG . '.current div.wp-menu-image.svg,' |
| 236 |
. '#adminmenu .toplevel_page_' . self::PAGE_SLUG . '.wp-has-current-submenu div.wp-menu-image.svg' |
| 237 |
. '{opacity:1;}'; |
| 238 |
|
| 239 |
echo '<style id="darkify-menu-icon">' . $css . '</style>'; |
| 240 |
} |
| 241 |
|
| 242 |
/** |
| 243 |
* The page body: just the node React mounts on. Every menu entry renders this |
| 244 |
* same root — the SPA reads the `?page=` slug to decide which route to open. |
| 245 |
* |
| 246 |
* The `darkify_ignore` class opts the whole React admin out of Darkify's own |
| 247 |
* dark-mode engine. When "Admin Panel Dark Mode" is on, that engine runs on |
| 248 |
* every admin screen and force-rewrites element backgrounds/colors with |
| 249 |
* `!important` (classifying nodes as `darkify_style_bg` / `darkify_style_button` |
| 250 |
* etc. — see src/assets/css/client_main.css). On our settings screen that |
| 251 |
* stomped the app's own theme — most visibly the colour-preset swatches, whose |
| 252 |
* inline background was overwritten so every preview looked empty. The React |
| 253 |
* admin already themes itself via the `.dark` class, so the engine must leave |
| 254 |
* its subtree alone; `.darkify_ignore` / `.darkify_ignore *` is that engine's |
| 255 |
* built-in exclusion hook. |
| 256 |
* |
| 257 |
* `translate="no"` / `.notranslate` opts the subtree out of browser page |
| 258 |
* translation (Chrome, Edge). Those translators replace each text node with |
| 259 |
* a `<font>` wrapper; React later tries to remove the original node, hits |
| 260 |
* "Failed to execute 'removeChild' on 'Node'", and the whole app unmounts. |
| 261 |
* Because saving re-renders (spinner, then toast), the crash landed exactly |
| 262 |
* on Save and looked like settings failing to save. The panel ships real |
| 263 |
* translations instead — see languages/ — so nothing is lost by declining |
| 264 |
* the browser's. |
| 265 |
*/ |
| 266 |
public function render(): void |
| 267 |
{ |
| 268 |
echo '<div id="darkify_react" class="darkify_ignore notranslate" translate="no"></div>'; |
| 269 |
} |
| 270 |
} |
| 271 |
|