PluginProbe
Create / trunk
Create vtrunk
2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.4 2.5.5 2.5.3 2.5.2 2.5.1 2.5.0 2.4.7 2.4.6 2.4.5 2.4.4 2.4.3 2.4.2 2.4.1 2.1.0 2.1.1 2.1.2 2.2.0 2.3.0 2.4.0 trunk All 138 releases
mediavine-create / admin / class-admin-init.php

class-admin-init.php in Create trunk, at admin/class-admin-init.php

992 lines 31.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace Mediavine\Create;
3
4 /**
5 * Class for initializing our admin scripts
6 */
7 class Admin_Init extends Plugin {
8
9 public static $mcp_data = null;
10
11 public static $mv_create_url_params = [
12 'post_type=mv_create',
13 'page=mv_settings',
14 'page=mv_create_welcome',
15 'page=create_home',
16 'page=create_editor',
17 'page=create_dashboard',
18 'page=import',
19 'page=theme_elements',
20 ];
21
22 /**
23 * Manages default custom field registration.
24 */
25 public static function custom_fields() {
26 $fields = [];
27 $fields = apply_filters( 'mv_create_fields', $fields );
28 return $fields;
29 }
30
31 /**
32 * Gets localization data needed for both Gutenberg and general scripts
33 */
34 public static function localization() {
35 global $wpdb;
36 $settings = apply_filters( 'mv_create_localized_admin_settings', self::get_translated_settings() );
37
38 // Never ship credential-class values to the browser. The site JWT is
39 // reduced to a presence flag for everyone; other credentials (e.g. the
40 // Amazon Creators secret) are only kept for users who can manage them.
41 $settings = Sensitive_Settings::redact( $settings, current_user_can( 'manage_options' ) );
42
43 $shapes = self::get_translated_shapes();
44
45 self::$mcp_data = self::get_mcp_data();
46
47 $args = [
48 'capability' => [ 'edit_posts' ],
49 'fields' => [ 'display_name' ],
50 ];
51
52 // Capability queries were only introduced in WP 5.9.
53 if ( version_compare( $GLOBALS['wp_version'], '5.9', '<' ) ) {
54 $args['who'] = 'authors';
55 unset( $args['capability'] );
56 }
57
58 $authors = get_users( $args );
59
60 $sanitized_authors = [];
61 foreach ( $authors as $author ) {
62 if ( ! empty( $author->display_name ) && is_string( $author->display_name ) ) {
63 $sanitized_authors[] = $author->display_name;
64 }
65 }
66
67 $key_match_statement = "SELECT id, original_object_id from {$wpdb->prefix}mv_creations WHERE original_object_id IS NOT NULL AND original_object_id != 0";
68 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- direct $wpdb access on custom/plugin tables; values bound via prepare() where applicable
69 $results = $wpdb->get_results( $key_match_statement );
70 $keys = [];
71 foreach ( $results as $result ) {
72 $keys[ $result->original_object_id ] = $result->id;
73 }
74
75 $current_user = wp_get_current_user();
76
77 $amazon_provision_lock = (bool) Amazon_Creators::get_transient_timeout( 'mv_create_amazon_provision' );
78
79 return [
80 '__VERSION__' => Plugin::VERSION,
81 '__WP_VERSION__' => $GLOBALS['wp_version'],
82 '__PHP_VERSION__' => PHP_VERSION,
83 '__URL__' => esc_url_raw( rest_url() ),
84 '__NONCE__' => wp_create_nonce( 'wp_rest' ),
85 '__ADMIN_NONCE__' => wp_create_nonce( 'mv_create_admin' ),
86 '__ADMIN_URL__' => esc_url_raw( admin_url() ),
87 '__STATIC__' => MV_CREATE_URL . 'ui/static',
88 '__SITE_URL__' => esc_url_raw( site_url() ),
89 '__CREATE_STUDIO_URL__' => esc_url_raw( self::$create_studio_base_url ),
90 '__CURRENT_USER_EMAIL__' => $current_user->user_email,
91 '__SETTINGS__' => $settings,
92 '__SERVICES_URL__' => esc_url_raw( self::$js_services_api_url ),
93 '__SHAPES__' => $shapes,
94 '__AUTHORS__' => $sanitized_authors,
95 '__MCP__' => self::$mcp_data,
96 '__KEY_LOOKUP__' => $keys,
97 '__CUSTOM_FIELDS__' => self::custom_fields(),
98 '__META_BLOCKS__' => Creations_Meta_Blocks::get_meta_block_slugs(),
99 '__USER__' => [
100 'current_user_email' => $current_user->user_email,
101 'studio_email' => User_Verification_Meta::get_email( $current_user->ID ),
102 'current_firstname' => $current_user->user_firstname,
103 'display_name' => $current_user->display_name,
104 'current_lastname' => $current_user->user_lastname,
105 'avatar_url' => get_avatar_url( $current_user->ID, [ 'size' => 96 ] ),
106 'site_url' => esc_url_raw( site_url() ),
107 'mediavine_publisher' => self::$mcp_enabled,
108 'current_user_authorized' => \Mediavine\Permissions::is_user_authorized(),
109 ],
110 '__FLAGS__' => [
111 'NO_DOM_DOC' => class_exists( 'DOMDocument' ) === false,
112 'AMAZON_PROVISION_LOCK' => $amazon_provision_lock,
113 'DEV_MODE' => Plugin::is_dev_mode(),
114 ],
115 '__ALLOWED_TYPES__' => [
116 json_decode( \Mediavine\Settings::get_setting( 'mv_create_allowed_types' ) ),
117 ],
118 '__SERVING_ADJUSTMENT_LABEL__' => \Mediavine\Settings::get_setting( 'mv_create_servings_adjustment_label' ),
119 ];
120 }
121
122 /**
123 * Gets fresh settings from the database and returns their translations
124 */
125 private static function get_translated_settings() {
126 // force the get_settings call to grab settings fresh from the database
127 $saved_settings = (array) \Mediavine\Settings::get_settings(null, null, true);
128 if ( 'en_US' === get_locale() ) {
129 return $saved_settings;
130 }
131 $translated_settings = self::get_settings();
132 $saved_slug_keys = [];
133 // let's set the array key for each slug of the settings from the DB
134 // into a temporary array
135 foreach ( $saved_settings as $key => $value ) {
136 $saved_slug_keys[ $value->slug ] = $key;
137 }
138 // loop through all of the translated settings and if the slug exists
139 // in our temporary array, use the key in the temp array
140 // to update the string data/translations in the settings from the DB.
141 foreach ( $translated_settings as $setting ) {
142 if ( array_key_exists( $setting['slug'], $saved_slug_keys ) ) {
143 $saved_settings[ $saved_slug_keys[ $setting['slug'] ] ]->data = $setting['data'];
144 }
145 }
146 return $saved_settings;
147 }
148 private static function get_translated_shapes() {
149 $shapes = \Mediavine\Create\Shapes::get_shapes();
150 if ( 'en_US' === get_locale() ) {
151 return $shapes;
152 }
153 $translated_shapes = self::get_shapes_data();
154 $saved_slug_keys = [];
155
156 // we currently only use the plural string from SHAPES in the UI
157 // let's make sure it's translated
158
159 // let's set the array key for each slug of the shapes from the DB
160 // into a temporary array
161 foreach ( $shapes as $key => $value ) {
162 $saved_slug_keys[ $value->slug ] = $key;
163 }
164 // loop through all of the translated shapes and if the slug exists
165 // in our temporary array, use the key in the temp array
166 // to update the string plural in the shapes from the DB.
167 foreach ( $translated_shapes as $shape ) {
168 if ( array_key_exists( $shape['slug'], $saved_slug_keys ) ) {
169 $shapes[ $saved_slug_keys[ $shape['slug'] ] ]->plural = $shape['plural'];
170 }
171 }
172 return $shapes;
173 }
174
175 public static function get_current_url() {
176 $current_url = null;
177 if ( ! empty( $_SERVER['REQUEST_URI'] ) ) {
178 $current_url = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
179 }
180 return $current_url;
181 }
182
183 /**
184 * Whether the current URL matches a Create-owned admin param string.
185 *
186 * Shared by is_create_admin_url() and is_create_spa_url() so the
187 * mv_create_url_params filter + strpos loop lives in one place.
188 *
189 * @return bool
190 */
191 private static function url_matches_create_params() {
192 $current_url = static::get_current_url();
193 if ( ! is_string( $current_url ) || '' === $current_url ) {
194 return false;
195 }
196
197 /**
198 * Filters the Create admin URL strings checked against
199 *
200 * @param array $mv_create_url_params List of URL strings to check
201 */
202 $mv_create_url_params = apply_filters( 'mv_create_url_params', self::$mv_create_url_params );
203
204 foreach ( $mv_create_url_params as $url_param_to_check ) {
205 if ( strpos( $current_url, $url_param_to_check ) !== false ) {
206 return true;
207 }
208 }
209
210 return false;
211 }
212
213 /**
214 * Check if we are on a Create admin URL
215 *
216 * @return boolean True if on a Create admin URL
217 */
218 public static function is_create_admin_url() {
219 if ( self::url_matches_create_params() ) {
220 return true;
221 }
222
223 $current_url = static::get_current_url();
224 if ( ! is_string( $current_url ) || '' === $current_url ) {
225 return false;
226 }
227
228 // For post.php and post-new.php, check if ANY editor is present
229 // This covers all post types including Ultimate Recipe, Osetin, and custom post types
230 if ( strpos( $current_url, 'post.php' ) !== false || strpos( $current_url, 'post-new.php' ) !== false ) {
231 $screen = get_current_screen();
232
233 // Gutenberg/block editor
234 if ( $screen && ! empty( $screen->is_block_editor ) ) {
235 return true;
236 }
237
238 // Classic editor - check if post type supports editor
239 if ( $screen && $screen->post_type && post_type_supports( $screen->post_type, 'editor' ) ) {
240 return true;
241 }
242
243 // Fallback: if screen exists and has an edit base, load Create
244 if ( $screen && $screen->base === 'post' ) {
245 return true;
246 }
247
248 return false;
249 }
250
251 return false;
252 }
253
254 /**
255 * Whether the current screen is a Create admin SPA page (not a content post editor).
256 *
257 * Matches Create-owned URL params (collections, settings, welcome, editor, import, etc.).
258 * Post/page editors use the slim blocks bundle instead.
259 *
260 * @return bool
261 */
262 public static function is_create_spa_url() {
263 return self::url_matches_create_params();
264 }
265
266 /**
267 * Script handle for the Create admin SPA (app.build).
268 *
269 * @return string
270 */
271 public static function app_script_handle() {
272 return Plugin::PLUGIN_DOMAIN . '-script';
273 }
274
275 /**
276 * Script handle for Gutenberg/Classic block registration (blocks.build).
277 *
278 * @return string
279 */
280 public static function blocks_script_handle() {
281 return Plugin::PLUGIN_DOMAIN . '-blocks-script';
282 }
283
284 /**
285 * Build (or Vite-dev) URL for a versioned admin UI artifact.
286 *
287 * @param string $basename Filename stem, e.g. 'app.build' or 'blocks.build'.
288 * @return string
289 */
290 public static function admin_ui_script_url( $basename ) {
291 $filename = $basename . '.' . self::VERSION . '.js';
292 $origin = Plugin::dev_asset_origin( 'admin' );
293
294 if ( '' !== $origin ) {
295 return $origin . '/' . $filename;
296 }
297
298 return Plugin::assets_url() . 'admin/ui/build/' . $filename;
299 }
300
301 /**
302 * Register, localize, and enqueue an admin UI script handle.
303 *
304 * Localization runs at most once per handle per request so Gutenberg screens
305 * that hit both enqueue_block_editor_assets and admin_enqueue_scripts do not
306 * double the cost of localization() (get_users, SQL, settings, MCP).
307 *
308 * @param string $handle Script handle.
309 * @param string $basename Filename stem, e.g. 'app.build' or 'blocks.build'.
310 * @param array $deps Script dependencies.
311 * @param bool $in_footer Whether to load in footer.
312 */
313 public static function enqueue_admin_ui_script( $handle, $basename, $deps, $in_footer = true ) {
314 static $localized = [];
315
316 if ( ! wp_script_is( $handle, 'registered' ) ) {
317 wp_register_script(
318 $handle,
319 self::admin_ui_script_url( $basename ),
320 $deps,
321 self::VERSION,
322 $in_footer
323 );
324 }
325
326 // register_create_script() already registers this handle on 'init' pinned to the
327 // header, so the wp_register_script() above is skipped and its $in_footer ignored.
328 // Set the group directly instead — wp_register_script() does the same thing, and
329 // notably does it outside its own "already registered" check. Without this the
330 // Classic Editor loads the script in <head>, before the media_buttons hook has
331 // emitted the [data-shortcode] node the TinyMCE block mounts into, so the Create
332 // insert button silently never renders.
333 wp_script_add_data( $handle, 'group', $in_footer ? 1 : 0 );
334
335 if ( empty( $localized[ $handle ] ) ) {
336 wp_localize_script( $handle, 'MV_CREATE', self::localization() );
337 wp_set_script_translations( $handle, 'mediavine-create', plugin_dir_path( __DIR__ ) . 'languages/' );
338 $localized[ $handle ] = true;
339 }
340
341 wp_enqueue_script( $handle );
342 }
343
344 /**
345 * Outputs the Slate JS Chrome CSS fix if Chrome is detected as browser.
346 *
347 * While this is unreliable and can be spoofed, we are just using this to output CSS.
348 * If we can't detect, then we will output the CSS anyway. Pure CSS solution was found at
349 * https://github.com/ianstormtaylor/slate/issues/5119#issuecomment-1264590939.
350 */
351 public function add_slate_chrome_fix() {
352 // If no user agent, spoof it as chrome and add CSS anyway, because this info should always
353 // be available.
354 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : 'Chrome';
355
356 // If no Chrome, abort.
357 if ( ! preg_match( '/Chrome/i', $user_agent ) ) {
358 return;
359 }
360
361 // Make sure Edge isn't mimicking Chrome.
362 if ( preg_match( '/Edge/i', $user_agent ) ) {
363 return;
364 }
365
366 // Begin Chrome version check.
367 preg_match_all( '/Chrome\/(\d+)/i', $user_agent, $versions );
368
369 // If no verisons found, then we have something funny or spoofed, so add CSS fix anyway.
370 if ( empty( $versions[1] ) ) {
371 echo '<style>div[data-slate-editor]{-webkit-user-modify: read-write !important;}</style>';
372
373 return;
374 }
375
376 // Only add CSS fix if Chrome version is 105 or greater.
377 foreach ( $versions[1] as $version ) {
378 if ( version_compare( (int) $version, 105, '>=' ) ) {
379 echo '<style>div[data-slate-editor]{-webkit-user-modify: read-write !important;}</style>';
380 }
381 }
382 }
383
384 /**
385 * Enqueues the admin scripts on the page.
386 *
387 * Create SPA pages get the full app.build bundle. Gutenberg/Classic content
388 * post editors get the slim blocks.build bundle (block registration only).
389 */
390 function admin_enqueue_scripts() {
391 if ( ! $this::is_create_admin_url() ) {
392 return;
393 }
394
395 wp_enqueue_media();
396
397 // Self-hosted fonts (both SIL OFL 1.1), served from the plugin over the
398 // site's own scheme. Loaded wherever Create UI may render (SPA or block modal).
399 // Nunito is the primary UI typeface; Fraunces is the display face used
400 // for the dashboard headings.
401 wp_enqueue_style(
402 'mv-font/nunito',
403 Plugin::assets_url() . 'assets/fonts/nunito/nunito.css',
404 [],
405 self::VERSION
406 );
407 wp_enqueue_style(
408 'mv-font/fraunces',
409 Plugin::assets_url() . 'assets/fonts/fraunces/fraunces.css',
410 [],
411 self::VERSION
412 );
413
414 if ( self::is_create_spa_url() ) {
415 $deps = [ 'lodash', 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-data' ];
416 self::enqueue_admin_ui_script( self::app_script_handle(), 'app.build', $deps, true );
417 } else {
418 // Content post editor (Gutenberg or Classic) — slim blocks entry.
419 $deps = [ 'lodash', 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-data' ];
420 if ( function_exists( 'is_gutenberg_page' ) && is_gutenberg_page() ) {
421 $deps = array_merge( $deps, [ 'wp-plugins', 'wp-editor' ] );
422 }
423 $screen = get_current_screen();
424 $in_footer = true;
425 if ( $screen && ! empty( $screen->is_block_editor ) ) {
426 $in_footer = false;
427 $deps[] = 'wp-edit-post';
428 }
429 self::enqueue_admin_ui_script( self::blocks_script_handle(), 'blocks.build', $deps, $in_footer );
430 }
431
432 // Add CSS to fix Chrome editor if needed
433 $this->add_slate_chrome_fix();
434 }
435
436 function admin_head() {
437 if ( ! current_user_can( 'edit_posts' ) && ! current_user_can( 'edit_pages' ) ) {
438 return;
439 }
440
441 // check if WYSIWYG is enabled
442 if ( 'true' === get_user_option( 'rich_editing' ) ) {
443 add_filter( 'tiny_mce_before_init', [ $this, 'tiny_mce_before_init' ] );
444 }
445
446 echo '<style>.post-type-mv_create #wpbody #wpbody-content { display: none };</style>';
447 }
448
449 function admin_footer() {
450 echo '<div id="mv-gb-modal"></div>';
451 }
452
453 function admin_menu() {
454 $shapes = \Mediavine\Create\Shapes::get_shapes();
455 $allowed_shapes = \Mediavine\Settings::get_setting( 'mv_create_allowed_types' );
456 $allowed_shapes = json_decode( $allowed_shapes );
457
458 // Router page — hidden submenu entry that redirects to the user's
459 // preferred default page via maybe_redirect_default_admin_page().
460 add_submenu_page(
461 'edit.php?post_type=mv_create',
462 __( 'Create', 'mediavine-create' ),
463 __( 'Create', 'mediavine-create' ),
464 'edit_posts',
465 'create_home',
466 '__return_null'
467 );
468
469 // Dashboard page
470 add_submenu_page(
471 'edit.php?post_type=mv_create',
472 __( 'Dashboard', 'mediavine-create' ),
473 __( 'Dashboard', 'mediavine-create' ),
474 'manage_options',
475 'create_dashboard',
476 [ $this, 'dashboard_page' ]
477 );
478
479 $menu_keys = [
480 'recipe' => __( 'Recipes', 'mediavine-create' ),
481 'diy' => __( 'How-Tos', 'mediavine-create' ),
482 'list' => __( 'Lists', 'mediavine-create' ),
483 ];
484
485 // normalize shapes list for backwards compatibility.
486 foreach ( $shapes as $card ) {
487 if (
488 ! array_key_exists( $card->slug, $menu_keys ) ||
489 (
490 ! empty( $allowed_shapes ) && ! in_array( $card->slug, $allowed_shapes, true )
491 )
492 ) {
493 continue;
494 }
495
496 add_submenu_page(
497 'edit.php?post_type=mv_create',
498 $menu_keys[ $card->slug ],
499 $menu_keys[ $card->slug ],
500 'manage_options',
501 $card->slug,
502 [ $this, 'card_page' ]
503 );
504 }
505
506 $static_pages = [];
507 $static_pages[ __( 'Recommended Products', 'mediavine-create' ) ] = 'products';
508 $static_pages[ __( 'User Reviews', 'mediavine-create' ) ] = 'reviews';
509
510 foreach ( $static_pages as $label => $value ) {
511 add_submenu_page(
512 'edit.php?post_type=mv_create',
513 $label,
514 $label,
515 'manage_options',
516 $value,
517 [ $this, 'card_page' ]
518 );
519 }
520
521 add_submenu_page(
522 'edit.php?post_type=mv_create',
523 __( 'Create Plugin Settings', 'mediavine-create' ),
524 __( 'Settings', 'mediavine-create' ),
525 'manage_options',
526 'settings',
527 [ $this, 'menu_page' ]
528 );
529
530 add_options_page(
531 __( 'Create Plugin Settings', 'mediavine-create' ),
532 __( 'Create', 'mediavine-create' ),
533 'manage_options',
534 'mv_settings',
535 [ $this, 'menu_page' ]
536 );
537
538 // Editor page under Create menu. Registered under the real parent so
539 // WordPress keeps the submenu open and handles capabilities correctly.
540 // The menu item is hidden via CSS in editor_hide_menu_item().
541 add_submenu_page(
542 'edit.php?post_type=mv_create',
543 __( 'Edit Create Card', 'mediavine-create' ),
544 __( 'Edit Card', 'mediavine-create' ),
545 'edit_posts',
546 'create_editor',
547 [ $this, 'editor_page' ]
548 );
549
550 // Hidden welcome page (no menu item)
551 add_submenu_page(
552 '',
553 __( 'Welcome to Create 2.0', 'mediavine-create' ),
554 __( 'Welcome', 'mediavine-create' ),
555 'manage_options',
556 'mv_create_welcome',
557 [ $this, 'welcome_page' ]
558 );
559
560 // Theme Elements page (dev mode only)
561 if ( Plugin::is_dev_mode() ) {
562 add_submenu_page(
563 'edit.php?post_type=mv_create',
564 __( 'Theme Elements', 'mediavine-create' ),
565 __( 'Theme Elements', 'mediavine-create' ),
566 'manage_options',
567 'theme_elements',
568 [ $this, 'theme_elements_page' ]
569 );
570 }
571
572 // Reorder submenu and set the parent menu href to the router page.
573 //
574 // WordPress uses $submenu[$parent][0][2] as the href for the top-level
575 // menu item. For CPT menus, position 0 is auto-generated as "All {type}"
576 // with the slug set to the full parent URL (e.g. edit.php?post_type=mv_create).
577 // We change position 0's slug to the router URL so the parent menu link
578 // goes through the router, then add "All Create Cards" as its own entry.
579 global $submenu;
580 $parent = 'edit.php?post_type=mv_create';
581 if ( isset( $submenu[ $parent ] ) ) {
582 $dashboard = null;
583
584 foreach ( $submenu[ $parent ] as $key => $item ) {
585 if ( 'create_dashboard' === $item[2] ) {
586 $dashboard = $item;
587 unset( $submenu[ $parent ][ $key ] );
588 } elseif ( 'create_home' === $item[2] ) {
589 unset( $submenu[ $parent ][ $key ] );
590 }
591 }
592
593 $submenu[ $parent ] = array_values( $submenu[ $parent ] );
594
595 // Position 0 is the auto-generated "All Create Cards" entry.
596 // Change its slug to the router URL so clicking the top-level
597 // "Create" menu item goes through the router. Keep the label.
598 if ( isset( $submenu[ $parent ][0] ) ) {
599 $all_cards_label = $submenu[ $parent ][0][0];
600 $all_cards_cap = $submenu[ $parent ][0][1];
601
602 // Point position 0 to the router page (full URL so WP uses it directly).
603 $submenu[ $parent ][0][2] = 'edit.php?post_type=mv_create&page=create_home';
604
605 // Re-add "All Create Cards" as a real submenu entry at position 2.
606 $all_cards_item = [ $all_cards_label, $all_cards_cap, $parent ];
607 array_splice( $submenu[ $parent ], 1, 0, [ $all_cards_item ] );
608 }
609
610 // Insert Dashboard at position 1 (right after the router, before All Cards).
611 if ( $dashboard ) {
612 array_splice( $submenu[ $parent ], 1, 0, [ $dashboard ] );
613 }
614 }
615 }
616
617 function card_page() {
618 $screen_object = get_current_screen();
619 $exploded = explode( '_', $screen_object->base );
620 $position = count( $exploded ) - 1;
621 $type = $exploded[ $position ];
622 ?>
623 <div id="MVRoot" data-type="<?php echo esc_html( $type ); ?>"></div>
624 <?php
625 }
626
627 // Blank function prevents PHP notice
628 function menu_page() {}
629
630 function dashboard_page() {
631 ?>
632 <div id="MVRoot" data-page="dashboard"></div>
633 <?php
634 }
635
636 function editor_page() {
637 ?>
638 <div id="MVRoot" data-page="editor"></div>
639 <?php
640 }
641
642 function welcome_page() {
643 ?>
644 <div id="MVRoot" data-page="welcome"></div>
645 <?php
646 }
647
648 function theme_elements_page() {
649 ?>
650 <div id="MVRoot" data-page="theme-elements"></div>
651 <?php
652 }
653
654 function media_buttons( $editor_id ) {
655 if ( 'content' !== $editor_id ) {
656 return;
657 }
658 ?>
659 <div data-shortcode="mv_create"></div>
660 <?php
661 }
662
663 /**
664 * Adds Create styles to TinyMCE (Classic Editor) load
665 *
666 * @param array $mceInit An array with TinyMCE config.
667 * @return array
668 */
669 function tiny_mce_before_init( $mceInit ) {
670 // Prevent PHP errors/notices as this can be filtered by other plugins
671 if ( ! is_array( $mceInit ) ) {
672 return $mceInit;
673 }
674 $content_css = Plugin::assets_url() . 'admin/ui/static/tinymce.css?' . self::VERSION;
675 if ( ! empty( $mceInit['content_css'] ) ) {
676 $mceInit['content_css'] .= ', ' . $content_css;
677 } else {
678 $mceInit['content_css'] = $content_css;
679 }
680
681 return $mceInit;
682 }
683
684 /**
685 * Register the block categories.
686 *
687 * @param array $categories An array of categories available to the editors
688 * @return array $categories
689 */
690 public function block_categories( $categories = [] ) {
691 // TODO: the following page check should no longer be needed once a fix for the following issue is released
692 // https://github.com/WordPress/gutenberg/issues/28517
693 global $pagenow;
694 if ( 'widgets.php' === $pagenow || 'customize.php' === $pagenow ) {
695 // This is a widgets block editor. We only want our blocks registered for post/page editors.
696 return $categories;
697 } else {
698 $merged = array_merge(
699 $categories,
700 [
701 [
702 'slug' => 'mediavine-create',
703 'title' => __( 'Create', 'mediavine-create' ),
704 'icon' => 'mediavine',
705 ],
706 ]
707 );
708 return $merged;
709 }
710 }
711
712 /**
713 * Register the slim blocks script early so it can be referenced by block registration.
714 * This runs on 'init' before register_gutenberg_blocks.
715 */
716 function register_create_script() {
717 wp_register_script(
718 self::blocks_script_handle(),
719 self::admin_ui_script_url( 'blocks.build' ),
720 [ 'lodash', 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-components', 'wp-api-fetch', 'wp-data' ],
721 self::VERSION,
722 false // Load in header
723 );
724 }
725
726 /**
727 * Register Gutenberg blocks server-side
728 *
729 * This server-side registration provides:
730 * - Block type recognition so Gutenberg doesn't show "unsupported block" errors
731 * - Attribute schema for proper serialization
732 * - Render callback for frontend output
733 * - Script dependency so WordPress loads our script when blocks are used
734 */
735 function register_gutenberg_blocks() {
736 // Get allowed types from settings
737 $allowed_shapes = \Mediavine\Settings::get_setting( 'mv_create_allowed_types' );
738 $allowed_shapes = json_decode( $allowed_shapes );
739 // Default to all types if none specified
740 if ( empty( $allowed_shapes ) ) {
741 $allowed_shapes = ['recipe', 'list', 'diy'];
742 }
743
744 // Register blocks for each allowed type
745 foreach ( $allowed_shapes as $type ) {
746 $block_name = "mv/{$type}";
747
748 // Skip if block is already registered (prevents re-registration in test environments)
749 if ( \WP_Block_Type_Registry::get_instance()->is_registered( $block_name ) ) {
750 continue;
751 }
752
753 register_block_type( $block_name, [
754 'api_version' => 3,
755 'editor_script' => self::blocks_script_handle(),
756 'render_callback' => [ $this, 'render_block' ],
757 'attributes' => [
758 'id' => [
759 'type' => 'number',
760 ],
761 'title' => [
762 'type' => 'string',
763 'default' => '',
764 ],
765 'thumbnail_uri' => [
766 'type' => 'string',
767 'default' => '',
768 ],
769 'type' => [
770 'type' => 'string',
771 'default' => $type,
772 ],
773 'layout' => [
774 'type' => 'string',
775 ],
776 ],
777 ] );
778 }
779 }
780
781 /**
782 * Render callback for Gutenberg blocks
783 */
784 function render_block( $attributes ) {
785 $id = isset( $attributes['id'] ) ? $attributes['id'] : '';
786 $title = isset( $attributes['title'] ) ? $attributes['title'] : '';
787 $thumbnail = isset( $attributes['thumbnail_uri'] ) ? $attributes['thumbnail_uri'] : '';
788 $type = isset( $attributes['type'] ) ? $attributes['type'] : 'recipe';
789 $layout = isset( $attributes['layout'] ) ? $attributes['layout'] : '';
790
791 // Build shortcode
792 $shortcode = "[mv_create key=\"{$id}\" type=\"{$type}\" title=\"{$title}\" thumbnail=\"{$thumbnail}\"";
793 if ( !empty( $layout ) ) {
794 $shortcode .= " layout=\"{$layout}\"";
795 }
796 $shortcode .= "]";
797
798 return $shortcode;
799 }
800
801 /**
802 * Enqueue scripts specifically for the block editor.
803 * This runs at the right time for Gutenberg integration.
804 */
805 function enqueue_block_editor_assets() {
806 $deps = [ 'lodash', 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-plugins', 'wp-edit-post', 'wp-api-fetch', 'wp-data' ];
807 self::enqueue_admin_ui_script( self::blocks_script_handle(), 'blocks.build', $deps, false );
808 }
809
810 /**
811 * Highlight the correct submenu item (Recipes, How-Tos, Lists) when on the editor page.
812 *
813 * @param string $submenu_file The submenu file slug.
814 * @return string
815 */
816 function editor_submenu_file( $submenu_file ) {
817 global $plugin_page;
818 if ( 'create_editor' === $plugin_page ) {
819 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
820 $type = isset( $_GET['type'] ) ? sanitize_text_field( wp_unslash( $_GET['type'] ) ) : '';
821 if ( $type ) {
822 return $type;
823 }
824 }
825 return $submenu_file;
826 }
827
828 /**
829 * Hide the "Edit Card" link from the Create submenu sidebar via CSS.
830 *
831 * The editor page is registered under the real Create parent so WordPress
832 * can properly highlight the menu. But we don't want the "Edit Card" link
833 * visible in the sidebar — it's accessed via collection/card links.
834 */
835 function editor_hide_menu_item() {
836 ?>
837 <style>
838 #adminmenu .wp-submenu a[href*="page=create_home"],
839 #adminmenu a[href*="page=create_editor"] { display: none !important; }
840 </style>
841 <?php
842 }
843
844 /**
845 * Redirect post.php Create editor URLs to admin.php?page=create_editor
846 * and repair bad object_id values on the editor page.
847 *
848 * WordPress core's post.php validates the post type of the loaded post before
849 * our React app can mount. If a creation's object_id points to a non-mv_create
850 * post (e.g., wprm_recipe from an old import), post.php dies with "Invalid post type."
851 *
852 * This hook:
853 * 1. Redirects post.php?post_type=mv_create URLs to admin.php?page=create_editor
854 * to avoid WordPress core's post type validation entirely.
855 * 2. On the admin.php editor page, repairs the creation's object_id if it doesn't
856 * point to a valid mv_create post.
857 */
858 function maybe_repair_creation_object_id() {
859 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only check
860 $current_url = static::get_current_url();
861 if ( ! $current_url ) {
862 return;
863 }
864
865 // Redirect post.php Create editor URLs to admin.php?page=create_editor
866 if (
867 strpos( $current_url, 'post.php' ) !== false &&
868 strpos( $current_url, 'post_type=mv_create' ) !== false &&
869 strpos( $current_url, 'action=edit' ) !== false
870 ) {
871 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
872 $id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : null;
873 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
874 $type = isset( $_GET['type'] ) ? sanitize_text_field( wp_unslash( $_GET['type'] ) ) : null;
875
876 if ( $id && $type ) {
877 $redirect_url = admin_url( 'admin.php?page=create_editor&id=' . $id . '&type=' . $type );
878 wp_safe_redirect( $redirect_url );
879 exit;
880 }
881 }
882
883 // On the admin.php editor page, repair bad object_id values
884 if ( strpos( $current_url, 'page=create_editor' ) === false ) {
885 return;
886 }
887
888 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
889 $creation_id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : null;
890 if ( ! $creation_id ) {
891 return;
892 }
893
894 // Look up the creation
895 $creation = self::$models_v2->mv_creations->find_one( $creation_id );
896 if ( ! $creation || ! isset( $creation->object_id ) ) {
897 return;
898 }
899
900 // Check if object_id points to a valid mv_create post
901 $post = get_post( $creation->object_id );
902 if ( $post && 'mv_create' === $post->post_type ) {
903 return; // Already correct
904 }
905
906 // object_id is missing or points to wrong post type — create a new mv_create post
907 $mv_create_post_id = wp_insert_post(
908 [
909 'post_title' => $creation->title ?? '',
910 'post_type' => 'mv_create',
911 'post_status' => 'publish',
912 ],
913 true
914 );
915
916 if ( is_wp_error( $mv_create_post_id ) ) {
917 return;
918 }
919
920 // Update the creation's object_id
921 self::$models_v2->mv_creations->update_without_modified_date(
922 [
923 'id' => $creation_id,
924 'object_id' => $mv_create_post_id,
925 ]
926 );
927 }
928
929 /**
930 * Redirect the create_home router page to the user's preferred admin page.
931 *
932 * Runs on admin_init (before output) so we can safely redirect.
933 */
934 function maybe_redirect_default_admin_page() {
935 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
936 if ( empty( $_GET['page'] ) || 'create_home' !== $_GET['page'] ) {
937 return;
938 }
939
940 $default_page = \Mediavine\Settings::get_setting( 'mv_create_default_admin_page' );
941
942 $slug_map = [
943 'dashboard' => 'create_dashboard',
944 'all_cards' => null, // special case — go to the CPT list
945 'recipe' => 'recipe',
946 'diy' => 'diy',
947 'list' => 'list',
948 ];
949
950 // Default to dashboard if the setting is empty or unrecognised.
951 if ( empty( $default_page ) || ! array_key_exists( $default_page, $slug_map ) ) {
952 $default_page = 'dashboard';
953 }
954
955 if ( null === $slug_map[ $default_page ] ) {
956 // "All Cards" — the CPT list table with no page param.
957 $url = admin_url( 'edit.php?post_type=mv_create' );
958 } else {
959 $url = admin_url( 'edit.php?post_type=mv_create&page=' . $slug_map[ $default_page ] );
960 }
961
962 wp_safe_redirect( $url );
963 exit;
964 }
965
966 function init() {
967 global $wp_version;
968 // version-check for filter compatibility
969 $block_categories_filter = 'block_categories';
970 if ( version_compare( $wp_version, '5.8', '>=' ) ) {
971 $block_categories_filter = 'block_categories_all';
972 }
973
974 add_action( 'admin_init', [ $this, 'maybe_redirect_default_admin_page' ] );
975 add_action( 'admin_init', [ $this, 'maybe_repair_creation_object_id' ] );
976 add_filter( 'submenu_file', [ $this, 'editor_submenu_file' ] );
977 add_action( 'admin_head', [ $this, 'editor_hide_menu_item' ] );
978 add_action( 'admin_head', [ $this, 'admin_head' ] );
979 add_action( 'admin_footer', [ $this, 'admin_footer' ] );
980 add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ], 11 );
981 add_action( 'admin_menu', [ $this, 'admin_menu' ] );
982 add_action( 'media_buttons', [ $this, 'media_buttons' ] );
983 // Register script first (priority 5), then blocks (priority 10)
984 add_action( 'init', [ $this, 'register_create_script' ], 5 );
985 add_action( 'init', [ $this, 'register_gutenberg_blocks' ], 10 );
986 add_filter( $block_categories_filter, [ $this, 'block_categories' ], 10, 1 );
987 // Also enqueue for block editor specifically with early priority
988 add_action( 'enqueue_block_editor_assets', [ $this, 'enqueue_block_editor_assets' ], 1 );
989 }
990
991 }
992