PluginProbe
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) / trunk
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) vtrunk
2.1.2 2.1.1 2.1.0 2.0.4 2.0.3 2.0.2 2.0.1 2.0.0 1.5.5 1.5.4 1.5.3 1.5.2 1.5.1 1.5.0 trunk 1.0.1 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 All 56 releases
darkify / src / Admin / Menu.php

Menu.php in Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) trunk, at src/Admin/Menu.php

271 lines 11.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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