[ 'edit_posts' ], 'fields' => [ 'display_name' ], ]; // Capability queries were only introduced in WP 5.9. if ( version_compare( $GLOBALS['wp_version'], '5.9', '<' ) ) { $args['who'] = 'authors'; unset( $args['capability'] ); } $authors = get_users( $args ); $sanitized_authors = []; foreach ( $authors as $author ) { if ( ! empty( $author->display_name ) && is_string( $author->display_name ) ) { $sanitized_authors[] = $author->display_name; } } $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"; // 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 $results = $wpdb->get_results( $key_match_statement ); $keys = []; foreach ( $results as $result ) { $keys[ $result->original_object_id ] = $result->id; } $current_user = wp_get_current_user(); $amazon_provision_lock = (bool) Amazon_Creators::get_transient_timeout( 'mv_create_amazon_provision' ); return [ '__VERSION__' => Plugin::VERSION, '__WP_VERSION__' => $GLOBALS['wp_version'], '__PHP_VERSION__' => PHP_VERSION, '__URL__' => esc_url_raw( rest_url() ), '__NONCE__' => wp_create_nonce( 'wp_rest' ), '__ADMIN_NONCE__' => wp_create_nonce( 'mv_create_admin' ), '__ADMIN_URL__' => esc_url_raw( admin_url() ), '__STATIC__' => MV_CREATE_URL . 'ui/static', '__SITE_URL__' => esc_url_raw( site_url() ), '__CREATE_STUDIO_URL__' => esc_url_raw( self::$create_studio_base_url ), '__CURRENT_USER_EMAIL__' => $current_user->user_email, '__SETTINGS__' => $settings, '__SERVICES_URL__' => esc_url_raw( self::$js_services_api_url ), '__SHAPES__' => $shapes, '__AUTHORS__' => $sanitized_authors, '__MCP__' => self::$mcp_data, '__KEY_LOOKUP__' => $keys, '__CUSTOM_FIELDS__' => self::custom_fields(), '__META_BLOCKS__' => Creations_Meta_Blocks::get_meta_block_slugs(), '__USER__' => [ 'current_user_email' => $current_user->user_email, 'studio_email' => User_Verification_Meta::get_email( $current_user->ID ), 'current_firstname' => $current_user->user_firstname, 'display_name' => $current_user->display_name, 'current_lastname' => $current_user->user_lastname, 'avatar_url' => get_avatar_url( $current_user->ID, [ 'size' => 96 ] ), 'site_url' => esc_url_raw( site_url() ), 'mediavine_publisher' => self::$mcp_enabled, 'current_user_authorized' => \Mediavine\Permissions::is_user_authorized(), ], '__FLAGS__' => [ 'NO_DOM_DOC' => class_exists( 'DOMDocument' ) === false, 'AMAZON_PROVISION_LOCK' => $amazon_provision_lock, 'DEV_MODE' => Plugin::is_dev_mode(), ], '__ALLOWED_TYPES__' => [ json_decode( \Mediavine\Settings::get_setting( 'mv_create_allowed_types' ) ), ], '__SERVING_ADJUSTMENT_LABEL__' => \Mediavine\Settings::get_setting( 'mv_create_servings_adjustment_label' ), ]; } /** * Gets fresh settings from the database and returns their translations */ private static function get_translated_settings() { // force the get_settings call to grab settings fresh from the database $saved_settings = (array) \Mediavine\Settings::get_settings(null, null, true); if ( 'en_US' === get_locale() ) { return $saved_settings; } $translated_settings = self::get_settings(); $saved_slug_keys = []; // let's set the array key for each slug of the settings from the DB // into a temporary array foreach ( $saved_settings as $key => $value ) { $saved_slug_keys[ $value->slug ] = $key; } // loop through all of the translated settings and if the slug exists // in our temporary array, use the key in the temp array // to update the string data/translations in the settings from the DB. foreach ( $translated_settings as $setting ) { if ( array_key_exists( $setting['slug'], $saved_slug_keys ) ) { $saved_settings[ $saved_slug_keys[ $setting['slug'] ] ]->data = $setting['data']; } } return $saved_settings; } private static function get_translated_shapes() { $shapes = \Mediavine\Create\Shapes::get_shapes(); if ( 'en_US' === get_locale() ) { return $shapes; } $translated_shapes = self::get_shapes_data(); $saved_slug_keys = []; // we currently only use the plural string from SHAPES in the UI // let's make sure it's translated // let's set the array key for each slug of the shapes from the DB // into a temporary array foreach ( $shapes as $key => $value ) { $saved_slug_keys[ $value->slug ] = $key; } // loop through all of the translated shapes and if the slug exists // in our temporary array, use the key in the temp array // to update the string plural in the shapes from the DB. foreach ( $translated_shapes as $shape ) { if ( array_key_exists( $shape['slug'], $saved_slug_keys ) ) { $shapes[ $saved_slug_keys[ $shape['slug'] ] ]->plural = $shape['plural']; } } return $shapes; } public static function get_current_url() { $current_url = null; if ( ! empty( $_SERVER['REQUEST_URI'] ) ) { $current_url = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); } return $current_url; } /** * Whether the current URL matches a Create-owned admin param string. * * Shared by is_create_admin_url() and is_create_spa_url() so the * mv_create_url_params filter + strpos loop lives in one place. * * @return bool */ private static function url_matches_create_params() { $current_url = static::get_current_url(); if ( ! is_string( $current_url ) || '' === $current_url ) { return false; } /** * Filters the Create admin URL strings checked against * * @param array $mv_create_url_params List of URL strings to check */ $mv_create_url_params = apply_filters( 'mv_create_url_params', self::$mv_create_url_params ); foreach ( $mv_create_url_params as $url_param_to_check ) { if ( strpos( $current_url, $url_param_to_check ) !== false ) { return true; } } return false; } /** * Check if we are on a Create admin URL * * @return boolean True if on a Create admin URL */ public static function is_create_admin_url() { if ( self::url_matches_create_params() ) { return true; } $current_url = static::get_current_url(); if ( ! is_string( $current_url ) || '' === $current_url ) { return false; } // For post.php and post-new.php, check if ANY editor is present // This covers all post types including Ultimate Recipe, Osetin, and custom post types if ( strpos( $current_url, 'post.php' ) !== false || strpos( $current_url, 'post-new.php' ) !== false ) { $screen = get_current_screen(); // Gutenberg/block editor if ( $screen && ! empty( $screen->is_block_editor ) ) { return true; } // Classic editor - check if post type supports editor if ( $screen && $screen->post_type && post_type_supports( $screen->post_type, 'editor' ) ) { return true; } // Fallback: if screen exists and has an edit base, load Create if ( $screen && $screen->base === 'post' ) { return true; } return false; } return false; } /** * Whether the current screen is a Create admin SPA page (not a content post editor). * * Matches Create-owned URL params (collections, settings, welcome, editor, import, etc.). * Post/page editors use the slim blocks bundle instead. * * @return bool */ public static function is_create_spa_url() { return self::url_matches_create_params(); } /** * Script handle for the Create admin SPA (app.build). * * @return string */ public static function app_script_handle() { return Plugin::PLUGIN_DOMAIN . '-script'; } /** * Script handle for Gutenberg/Classic block registration (blocks.build). * * @return string */ public static function blocks_script_handle() { return Plugin::PLUGIN_DOMAIN . '-blocks-script'; } /** * Build (or Vite-dev) URL for a versioned admin UI artifact. * * @param string $basename Filename stem, e.g. 'app.build' or 'blocks.build'. * @return string */ public static function admin_ui_script_url( $basename ) { $filename = $basename . '.' . self::VERSION . '.js'; $origin = Plugin::dev_asset_origin( 'admin' ); if ( '' !== $origin ) { return $origin . '/' . $filename; } return Plugin::assets_url() . 'admin/ui/build/' . $filename; } /** * Register, localize, and enqueue an admin UI script handle. * * Localization runs at most once per handle per request so Gutenberg screens * that hit both enqueue_block_editor_assets and admin_enqueue_scripts do not * double the cost of localization() (get_users, SQL, settings, MCP). * * @param string $handle Script handle. * @param string $basename Filename stem, e.g. 'app.build' or 'blocks.build'. * @param array $deps Script dependencies. * @param bool $in_footer Whether to load in footer. */ public static function enqueue_admin_ui_script( $handle, $basename, $deps, $in_footer = true ) { static $localized = []; if ( ! wp_script_is( $handle, 'registered' ) ) { wp_register_script( $handle, self::admin_ui_script_url( $basename ), $deps, self::VERSION, $in_footer ); } // register_create_script() already registers this handle on 'init' pinned to the // header, so the wp_register_script() above is skipped and its $in_footer ignored. // Set the group directly instead — wp_register_script() does the same thing, and // notably does it outside its own "already registered" check. Without this the // Classic Editor loads the script in , before the media_buttons hook has // emitted the [data-shortcode] node the TinyMCE block mounts into, so the Create // insert button silently never renders. wp_script_add_data( $handle, 'group', $in_footer ? 1 : 0 ); if ( empty( $localized[ $handle ] ) ) { wp_localize_script( $handle, 'MV_CREATE', self::localization() ); wp_set_script_translations( $handle, 'mediavine-create', plugin_dir_path( __DIR__ ) . 'languages/' ); $localized[ $handle ] = true; } wp_enqueue_script( $handle ); } /** * Outputs the Slate JS Chrome CSS fix if Chrome is detected as browser. * * While this is unreliable and can be spoofed, we are just using this to output CSS. * If we can't detect, then we will output the CSS anyway. Pure CSS solution was found at * https://github.com/ianstormtaylor/slate/issues/5119#issuecomment-1264590939. */ public function add_slate_chrome_fix() { // If no user agent, spoof it as chrome and add CSS anyway, because this info should always // be available. $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : 'Chrome'; // If no Chrome, abort. if ( ! preg_match( '/Chrome/i', $user_agent ) ) { return; } // Make sure Edge isn't mimicking Chrome. if ( preg_match( '/Edge/i', $user_agent ) ) { return; } // Begin Chrome version check. preg_match_all( '/Chrome\/(\d+)/i', $user_agent, $versions ); // If no verisons found, then we have something funny or spoofed, so add CSS fix anyway. if ( empty( $versions[1] ) ) { echo ''; return; } // Only add CSS fix if Chrome version is 105 or greater. foreach ( $versions[1] as $version ) { if ( version_compare( (int) $version, 105, '>=' ) ) { echo ''; } } } /** * Enqueues the admin scripts on the page. * * Create SPA pages get the full app.build bundle. Gutenberg/Classic content * post editors get the slim blocks.build bundle (block registration only). */ function admin_enqueue_scripts() { if ( ! $this::is_create_admin_url() ) { return; } wp_enqueue_media(); // Self-hosted fonts (both SIL OFL 1.1), served from the plugin over the // site's own scheme. Loaded wherever Create UI may render (SPA or block modal). // Nunito is the primary UI typeface; Fraunces is the display face used // for the dashboard headings. wp_enqueue_style( 'mv-font/nunito', Plugin::assets_url() . 'assets/fonts/nunito/nunito.css', [], self::VERSION ); wp_enqueue_style( 'mv-font/fraunces', Plugin::assets_url() . 'assets/fonts/fraunces/fraunces.css', [], self::VERSION ); if ( self::is_create_spa_url() ) { $deps = [ 'lodash', 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-data' ]; self::enqueue_admin_ui_script( self::app_script_handle(), 'app.build', $deps, true ); } else { // Content post editor (Gutenberg or Classic) — slim blocks entry. $deps = [ 'lodash', 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-data' ]; if ( function_exists( 'is_gutenberg_page' ) && is_gutenberg_page() ) { $deps = array_merge( $deps, [ 'wp-plugins', 'wp-editor' ] ); } $screen = get_current_screen(); $in_footer = true; if ( $screen && ! empty( $screen->is_block_editor ) ) { $in_footer = false; $deps[] = 'wp-edit-post'; } self::enqueue_admin_ui_script( self::blocks_script_handle(), 'blocks.build', $deps, $in_footer ); } // Add CSS to fix Chrome editor if needed $this->add_slate_chrome_fix(); } function admin_head() { if ( ! current_user_can( 'edit_posts' ) && ! current_user_can( 'edit_pages' ) ) { return; } // check if WYSIWYG is enabled if ( 'true' === get_user_option( 'rich_editing' ) ) { add_filter( 'tiny_mce_before_init', [ $this, 'tiny_mce_before_init' ] ); } echo ''; } function admin_footer() { echo '
'; } function admin_menu() { $shapes = \Mediavine\Create\Shapes::get_shapes(); $allowed_shapes = \Mediavine\Settings::get_setting( 'mv_create_allowed_types' ); $allowed_shapes = json_decode( $allowed_shapes ); // Router page — hidden submenu entry that redirects to the user's // preferred default page via maybe_redirect_default_admin_page(). add_submenu_page( 'edit.php?post_type=mv_create', __( 'Create', 'mediavine-create' ), __( 'Create', 'mediavine-create' ), 'edit_posts', 'create_home', '__return_null' ); // Dashboard page add_submenu_page( 'edit.php?post_type=mv_create', __( 'Dashboard', 'mediavine-create' ), __( 'Dashboard', 'mediavine-create' ), 'manage_options', 'create_dashboard', [ $this, 'dashboard_page' ] ); $menu_keys = [ 'recipe' => __( 'Recipes', 'mediavine-create' ), 'diy' => __( 'How-Tos', 'mediavine-create' ), 'list' => __( 'Lists', 'mediavine-create' ), ]; // normalize shapes list for backwards compatibility. foreach ( $shapes as $card ) { if ( ! array_key_exists( $card->slug, $menu_keys ) || ( ! empty( $allowed_shapes ) && ! in_array( $card->slug, $allowed_shapes, true ) ) ) { continue; } add_submenu_page( 'edit.php?post_type=mv_create', $menu_keys[ $card->slug ], $menu_keys[ $card->slug ], 'manage_options', $card->slug, [ $this, 'card_page' ] ); } $static_pages = []; $static_pages[ __( 'Recommended Products', 'mediavine-create' ) ] = 'products'; $static_pages[ __( 'User Reviews', 'mediavine-create' ) ] = 'reviews'; foreach ( $static_pages as $label => $value ) { add_submenu_page( 'edit.php?post_type=mv_create', $label, $label, 'manage_options', $value, [ $this, 'card_page' ] ); } add_submenu_page( 'edit.php?post_type=mv_create', __( 'Create Plugin Settings', 'mediavine-create' ), __( 'Settings', 'mediavine-create' ), 'manage_options', 'settings', [ $this, 'menu_page' ] ); add_options_page( __( 'Create Plugin Settings', 'mediavine-create' ), __( 'Create', 'mediavine-create' ), 'manage_options', 'mv_settings', [ $this, 'menu_page' ] ); // Editor page under Create menu. Registered under the real parent so // WordPress keeps the submenu open and handles capabilities correctly. // The menu item is hidden via CSS in editor_hide_menu_item(). add_submenu_page( 'edit.php?post_type=mv_create', __( 'Edit Create Card', 'mediavine-create' ), __( 'Edit Card', 'mediavine-create' ), 'edit_posts', 'create_editor', [ $this, 'editor_page' ] ); // Hidden welcome page (no menu item) add_submenu_page( '', __( 'Welcome to Create 2.0', 'mediavine-create' ), __( 'Welcome', 'mediavine-create' ), 'manage_options', 'mv_create_welcome', [ $this, 'welcome_page' ] ); // Theme Elements page (dev mode only) if ( Plugin::is_dev_mode() ) { add_submenu_page( 'edit.php?post_type=mv_create', __( 'Theme Elements', 'mediavine-create' ), __( 'Theme Elements', 'mediavine-create' ), 'manage_options', 'theme_elements', [ $this, 'theme_elements_page' ] ); } // Reorder submenu and set the parent menu href to the router page. // // WordPress uses $submenu[$parent][0][2] as the href for the top-level // menu item. For CPT menus, position 0 is auto-generated as "All {type}" // with the slug set to the full parent URL (e.g. edit.php?post_type=mv_create). // We change position 0's slug to the router URL so the parent menu link // goes through the router, then add "All Create Cards" as its own entry. global $submenu; $parent = 'edit.php?post_type=mv_create'; if ( isset( $submenu[ $parent ] ) ) { $dashboard = null; foreach ( $submenu[ $parent ] as $key => $item ) { if ( 'create_dashboard' === $item[2] ) { $dashboard = $item; unset( $submenu[ $parent ][ $key ] ); } elseif ( 'create_home' === $item[2] ) { unset( $submenu[ $parent ][ $key ] ); } } $submenu[ $parent ] = array_values( $submenu[ $parent ] ); // Position 0 is the auto-generated "All Create Cards" entry. // Change its slug to the router URL so clicking the top-level // "Create" menu item goes through the router. Keep the label. if ( isset( $submenu[ $parent ][0] ) ) { $all_cards_label = $submenu[ $parent ][0][0]; $all_cards_cap = $submenu[ $parent ][0][1]; // Point position 0 to the router page (full URL so WP uses it directly). $submenu[ $parent ][0][2] = 'edit.php?post_type=mv_create&page=create_home'; // Re-add "All Create Cards" as a real submenu entry at position 2. $all_cards_item = [ $all_cards_label, $all_cards_cap, $parent ]; array_splice( $submenu[ $parent ], 1, 0, [ $all_cards_item ] ); } // Insert Dashboard at position 1 (right after the router, before All Cards). if ( $dashboard ) { array_splice( $submenu[ $parent ], 1, 0, [ $dashboard ] ); } } } function card_page() { $screen_object = get_current_screen(); $exploded = explode( '_', $screen_object->base ); $position = count( $exploded ) - 1; $type = $exploded[ $position ]; ?>
'mediavine-create', 'title' => __( 'Create', 'mediavine-create' ), 'icon' => 'mediavine', ], ] ); return $merged; } } /** * Register the slim blocks script early so it can be referenced by block registration. * This runs on 'init' before register_gutenberg_blocks. */ function register_create_script() { wp_register_script( self::blocks_script_handle(), self::admin_ui_script_url( 'blocks.build' ), [ 'lodash', 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-components', 'wp-api-fetch', 'wp-data' ], self::VERSION, false // Load in header ); } /** * Register Gutenberg blocks server-side * * This server-side registration provides: * - Block type recognition so Gutenberg doesn't show "unsupported block" errors * - Attribute schema for proper serialization * - Render callback for frontend output * - Script dependency so WordPress loads our script when blocks are used */ function register_gutenberg_blocks() { // Get allowed types from settings $allowed_shapes = \Mediavine\Settings::get_setting( 'mv_create_allowed_types' ); $allowed_shapes = json_decode( $allowed_shapes ); // Default to all types if none specified if ( empty( $allowed_shapes ) ) { $allowed_shapes = ['recipe', 'list', 'diy']; } // Register blocks for each allowed type foreach ( $allowed_shapes as $type ) { $block_name = "mv/{$type}"; // Skip if block is already registered (prevents re-registration in test environments) if ( \WP_Block_Type_Registry::get_instance()->is_registered( $block_name ) ) { continue; } register_block_type( $block_name, [ 'api_version' => 3, 'editor_script' => self::blocks_script_handle(), 'render_callback' => [ $this, 'render_block' ], 'attributes' => [ 'id' => [ 'type' => 'number', ], 'title' => [ 'type' => 'string', 'default' => '', ], 'thumbnail_uri' => [ 'type' => 'string', 'default' => '', ], 'type' => [ 'type' => 'string', 'default' => $type, ], 'layout' => [ 'type' => 'string', ], ], ] ); } } /** * Render callback for Gutenberg blocks */ function render_block( $attributes ) { $id = isset( $attributes['id'] ) ? $attributes['id'] : ''; $title = isset( $attributes['title'] ) ? $attributes['title'] : ''; $thumbnail = isset( $attributes['thumbnail_uri'] ) ? $attributes['thumbnail_uri'] : ''; $type = isset( $attributes['type'] ) ? $attributes['type'] : 'recipe'; $layout = isset( $attributes['layout'] ) ? $attributes['layout'] : ''; // Build shortcode $shortcode = "[mv_create key=\"{$id}\" type=\"{$type}\" title=\"{$title}\" thumbnail=\"{$thumbnail}\""; if ( !empty( $layout ) ) { $shortcode .= " layout=\"{$layout}\""; } $shortcode .= "]"; return $shortcode; } /** * Enqueue scripts specifically for the block editor. * This runs at the right time for Gutenberg integration. */ function enqueue_block_editor_assets() { $deps = [ 'lodash', 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-plugins', 'wp-edit-post', 'wp-api-fetch', 'wp-data' ]; self::enqueue_admin_ui_script( self::blocks_script_handle(), 'blocks.build', $deps, false ); } /** * Highlight the correct submenu item (Recipes, How-Tos, Lists) when on the editor page. * * @param string $submenu_file The submenu file slug. * @return string */ function editor_submenu_file( $submenu_file ) { global $plugin_page; if ( 'create_editor' === $plugin_page ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $type = isset( $_GET['type'] ) ? sanitize_text_field( wp_unslash( $_GET['type'] ) ) : ''; if ( $type ) { return $type; } } return $submenu_file; } /** * Hide the "Edit Card" link from the Create submenu sidebar via CSS. * * The editor page is registered under the real Create parent so WordPress * can properly highlight the menu. But we don't want the "Edit Card" link * visible in the sidebar — it's accessed via collection/card links. */ function editor_hide_menu_item() { ?> mv_creations->find_one( $creation_id ); if ( ! $creation || ! isset( $creation->object_id ) ) { return; } // Check if object_id points to a valid mv_create post $post = get_post( $creation->object_id ); if ( $post && 'mv_create' === $post->post_type ) { return; // Already correct } // object_id is missing or points to wrong post type — create a new mv_create post $mv_create_post_id = wp_insert_post( [ 'post_title' => $creation->title ?? '', 'post_type' => 'mv_create', 'post_status' => 'publish', ], true ); if ( is_wp_error( $mv_create_post_id ) ) { return; } // Update the creation's object_id self::$models_v2->mv_creations->update_without_modified_date( [ 'id' => $creation_id, 'object_id' => $mv_create_post_id, ] ); } /** * Redirect the create_home router page to the user's preferred admin page. * * Runs on admin_init (before output) so we can safely redirect. */ function maybe_redirect_default_admin_page() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( empty( $_GET['page'] ) || 'create_home' !== $_GET['page'] ) { return; } $default_page = \Mediavine\Settings::get_setting( 'mv_create_default_admin_page' ); $slug_map = [ 'dashboard' => 'create_dashboard', 'all_cards' => null, // special case — go to the CPT list 'recipe' => 'recipe', 'diy' => 'diy', 'list' => 'list', ]; // Default to dashboard if the setting is empty or unrecognised. if ( empty( $default_page ) || ! array_key_exists( $default_page, $slug_map ) ) { $default_page = 'dashboard'; } if ( null === $slug_map[ $default_page ] ) { // "All Cards" — the CPT list table with no page param. $url = admin_url( 'edit.php?post_type=mv_create' ); } else { $url = admin_url( 'edit.php?post_type=mv_create&page=' . $slug_map[ $default_page ] ); } wp_safe_redirect( $url ); exit; } function init() { global $wp_version; // version-check for filter compatibility $block_categories_filter = 'block_categories'; if ( version_compare( $wp_version, '5.8', '>=' ) ) { $block_categories_filter = 'block_categories_all'; } add_action( 'admin_init', [ $this, 'maybe_redirect_default_admin_page' ] ); add_action( 'admin_init', [ $this, 'maybe_repair_creation_object_id' ] ); add_filter( 'submenu_file', [ $this, 'editor_submenu_file' ] ); add_action( 'admin_head', [ $this, 'editor_hide_menu_item' ] ); add_action( 'admin_head', [ $this, 'admin_head' ] ); add_action( 'admin_footer', [ $this, 'admin_footer' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ], 11 ); add_action( 'admin_menu', [ $this, 'admin_menu' ] ); add_action( 'media_buttons', [ $this, 'media_buttons' ] ); // Register script first (priority 5), then blocks (priority 10) add_action( 'init', [ $this, 'register_create_script' ], 5 ); add_action( 'init', [ $this, 'register_gutenberg_blocks' ], 10 ); add_filter( $block_categories_filter, [ $this, 'block_categories' ], 10, 1 ); // Also enqueue for block editor specifically with early priority add_action( 'enqueue_block_editor_assets', [ $this, 'enqueue_block_editor_assets' ], 1 ); } }