array( 'capability' => 'edit_theme_options', ), 'leitvia-studio-tokens' => array( 'capability' => 'edit_theme_options', ), 'leitvia-studio-model' => array( 'capability' => 'manage_options', ), 'leitvia-studio-recipes' => array( 'capability' => 'edit_theme_options', ), // The Inbox is a tab of this screen, so editors who may decide on changes reach it; the other tabs need // manage_options (canManage in enqueue()). 'leitvia-studio-agents' => array( 'capability' => 'edit_theme_options', ), 'leitvia-studio-settings' => array( 'capability' => 'manage_options', ), ); /** * Earlier page slug of the Inbox, now the tab "inbox" of the Agents screen. It stays registered as a hidden page * that forwards there, so links of add-ons, agents and bookmarks keep working. */ public const INBOX_SLUG = 'leitvia-studio-inbox'; /** * User meta that marks the product notes as dismissed. */ public const NOTES_DISMISSED = 'leitvia_studio_notes_dismissed'; /** * WordPress screen IDs of the registered screens, mapped to their page slug. * * @var array */ private static array $hooks = array(); /** * Menu titles by page slug. * * @return array */ private static function titles(): array { return array( 'leitvia-studio' => __( 'Pattern Lab', 'leitvia-studio' ), 'leitvia-studio-tokens' => __( 'Tokens', 'leitvia-studio' ), 'leitvia-studio-model' => __( 'Content Model', 'leitvia-studio' ), 'leitvia-studio-recipes' => __( 'Recipes', 'leitvia-studio' ), 'leitvia-studio-agents' => __( 'Agents', 'leitvia-studio' ), 'leitvia-studio-settings' => __( 'Settings', 'leitvia-studio' ), ); } /** * Adds the menu after Appearance. Runs on admin_menu. */ public static function register_menu(): void { $titles = self::titles(); $brand = leitvia_studio_brand(); self::$hooks = array(); $hook = add_menu_page( $brand['name'], $brand['menu_title'], self::SCREENS['leitvia-studio']['capability'], 'leitvia-studio', array( self::class, 'render' ), // A logo of any size would break the menu; another brand gets a neutral dashicon instead of the mark. '' === $brand['logo_url'] ? self::menu_icon() : 'dashicons-art', 61 ); self::$hooks[ $hook ] = 'leitvia-studio'; foreach ( self::SCREENS as $slug => $screen ) { $menu = 'leitvia-studio-agents' === $slug ? $titles[ $slug ] . self::inbox_bubble() : $titles[ $slug ]; $hook = add_submenu_page( 'leitvia-studio', $titles[ $slug ], $menu, $screen['capability'], $slug, array( self::class, 'render' ) ); if ( $hook ) { self::$hooks[ $hook ] = $slug; } } // Hidden: no menu entry, the page only forwards before anything is printed. $hook = add_submenu_page( '', $titles['leitvia-studio-agents'], '', self::SCREENS['leitvia-studio-agents']['capability'], self::INBOX_SLUG, array( self::class, 'render' ) ); if ( $hook ) { add_action( 'load-' . $hook, array( self::class, 'redirect_inbox' ) ); } } /** * The count bubble of open changes in the Inbox next to "Agents", like the one of Comments; '' without any. */ private static function inbox_bubble(): string { $count = current_user_can( 'edit_theme_options' ) ? \LeitviaStudio\Gateway\Packages::count_open() : 0; if ( ! $count ) { return ''; } return sprintf( ' %3$s', $count, esc_html( number_format_i18n( $count ) ), esc_html( sprintf( /* translators: %s: number of changes. */ _n( '%s open change in the Inbox', '%s open changes in the Inbox', $count, 'leitvia-studio' ), number_format_i18n( $count ) ) ) ); } /** * URL of the Inbox tab, or of one change in it. * * @param int $package Change package ID, 0 for the list. */ public static function inbox_url( int $package = 0 ): string { return add_query_arg( array_filter( array( 'page' => 'leitvia-studio-agents', 'tab' => 'inbox', 'package' => $package, ) ), admin_url( 'admin.php' ) ); } /** * Forwards the earlier Inbox page to its tab, with ?package=. Runs on load-{hook} of INBOX_SLUG. */ public static function redirect_inbox(): void { wp_safe_redirect( self::inbox_url( absint( wp_unslash( $_GET['package'] ?? 0 ) ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only forwards to another screen. exit; } /** * Page slug of the current Studio screen, '' elsewhere. */ public static function current_screen(): string { $screen = get_current_screen(); return $screen ? ( self::$hooks[ $screen->id ] ?? '' ) : ''; } /** * Prints the frame of a screen; the app renders into the root element. */ public static function render(): void { $screen = get_current_screen(); ?>
id : '' ) ); ?>
'; $link = ''; if ( '' !== $brand['support_url'] ) { $link = ' ' . esc_html__( 'Support', 'leitvia-studio' ) . ''; } elseif ( '' === $brand['logo_url'] && __( 'Leitvia Studio', 'leitvia-studio' ) === $brand['name'] ) { $link = ' ' . esc_html__( 'by CMS ADMINS', 'leitvia-studio' ) . ''; } $svg = array( 'img' => array( 'src' => true, 'alt' => true, 'class' => true, ), 'svg' => array( 'xmlns' => true, 'viewbox' => true, 'width' => true, 'height' => true, 'class' => true, 'aria-hidden' => true, 'focusable' => true, ), 'path' => array( 'fill' => true, 'fill-rule' => true, 'd' => true, ), ); printf( '

%1$s%2$s%3$s

', wp_kses( $logo, $svg ), esc_html( $brand['name'] ), wp_kses( $link, array( 'a' => array( 'class' => true, 'href' => true, ), ) ) ); } /** * The note on Leitvia Pro in the product notes of the Settings screen only: what Pro adds and a link to its * plans. Runs on leitvia_studio_screen_notes; prints nothing while Leitvia Pro is active or another brand is set * (filter leitvia_studio_brand). * * @param string $slug Page slug of the screen. */ public static function pro_note( string $slug = '' ): void { if ( 'leitvia-studio-settings' !== $slug || defined( 'LEITVIA_PRO_VERSION' ) || __( 'Leitvia Studio', 'leitvia-studio' ) !== leitvia_studio_brand()['name'] ) { return; } echo wp_kses_post( self::pro_note_markup() ); } /** * Markup of the note on Leitvia Pro. */ public static function pro_note_markup(): string { return sprintf( '

%1$s %2$s %4$s

', esc_html__( 'Leitvia Pro', 'leitvia-studio' ), esc_html__( 'adds the blocks Slider, Scroll story, Before and after and Entry list, and eight industry kits with their own brand, pages and patterns. Everything in Leitvia Studio stays free.', 'leitvia-studio' ), esc_url( 'https://leitvia.com/pricing/' ), esc_html__( 'Plans of Leitvia Pro', 'leitvia-studio' ) ); } /** * Product notes above the app of a Studio screen: the note on Leitvia Pro on Settings (pro_note()) and what * add-ons print into leitvia_studio_screen_notes. Nothing is printed outside Studio screens, with the setting hide_product_notes * or after the user dismissed the notes. * * @param string $screen_id WordPress screen ID, for example toplevel_page_leitvia-studio. * @return string The notes wrapped in an aside with a dismiss button, or ''. */ public static function render_slot( string $screen_id ): string { $slug = self::$hooks[ $screen_id ] ?? ''; if ( '' === $slug || Settings::get( 'hide_product_notes' ) || get_user_meta( get_current_user_id(), self::NOTES_DISMISSED, true ) ) { return ''; } ob_start(); /** * Fires above the app of every Studio screen, for notes on the screen. Output appears in an aside the user can * dismiss. * * @param string $slug Page slug of the screen, for example leitvia-studio. */ do_action( 'leitvia_studio_screen_notes', $slug ); $notes = trim( (string) ob_get_clean() ); if ( '' === $notes ) { return ''; } return sprintf( '', esc_attr__( 'Product notes', 'leitvia-studio' ), $notes, esc_attr__( 'Dismiss product notes', 'leitvia-studio' ), esc_html__( 'Dismiss', 'leitvia-studio' ) ); } /** * Registers /leitvia-studio/v1/notes/dismiss: POST dismisses the notes for the current user, DELETE shows them again. * Runs on rest_api_init. */ public static function register_routes(): void { register_rest_route( 'leitvia-studio/v1', '/notes/dismiss', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => static function (): \WP_REST_Response { update_user_meta( get_current_user_id(), self::NOTES_DISMISSED, 1 ); return new \WP_REST_Response( array( 'dismissed' => true ) ); }, 'permission_callback' => static fn(): bool => current_user_can( 'read' ), ), array( 'methods' => \WP_REST_Server::DELETABLE, 'callback' => static function (): \WP_REST_Response { delete_user_meta( get_current_user_id(), self::NOTES_DISMISSED ); return new \WP_REST_Response( array( 'dismissed' => false ) ); }, 'permission_callback' => static fn(): bool => current_user_can( 'read' ), ), 'schema' => static fn(): array => array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'leitvia-studio-notes', 'type' => 'object', 'properties' => array( 'dismissed' => array( 'description' => __( 'Whether the product notes stay hidden for the current user.', 'leitvia-studio' ), 'type' => 'boolean', 'readonly' => true, ), ), ), ) ); } /** * Page slugs of add-on screens that look like Studio screens: the Studio stylesheet (accent, headings, status * rows, details) loads there, and the screen prints brand() first inside
. * * @return list */ public static function framed_pages(): array { /** * Filters the add-on screens that use the frame of Studio screens. * * @param list $pages Page slugs, for example my-addon-settings. */ return array_values( array_filter( (array) apply_filters( 'leitvia_studio_framed_pages', array() ), 'is_string' ) ); } /** * The product line of Studio screens, for the add-on screens of framed_pages(). */ public static function brand(): void { self::render_brand(); } /** * Loads the app on Studio screens, and the stylesheet alone on the add-on screens of framed_pages(). Runs on * admin_enqueue_scripts. */ public static function enqueue(): void { $screen_id = self::current_screen(); $framed = '' === $screen_id && in_array( (string) ( $GLOBALS['plugin_page'] ?? '' ), self::framed_pages(), true ); $asset_file = dirname( LEITVIA_STUDIO_FILE ) . '/build/admin.asset.php'; if ( ( '' === $screen_id && ! $framed ) || ! is_readable( $asset_file ) ) { return; } $asset = require $asset_file; $url = plugin_dir_url( LEITVIA_STUDIO_FILE ) . 'build/'; if ( ! $framed ) { wp_enqueue_script( 'leitvia-studio-admin', $url . 'admin.js', array_merge( $asset['dependencies'], array( 'leitvia-studio-api' ) ), $asset['version'], true ); } wp_enqueue_style( 'leitvia-studio-admin', $url . 'style-admin.css', array( 'wp-components' ), $asset['version'] ); wp_style_add_data( 'leitvia-studio-admin', 'rtl', 'replace' ); // Open Sans for the headings of Studio screens, served from the plugin (Latin subset, 600 and 700). $fonts = plugin_dir_url( LEITVIA_STUDIO_FILE ) . 'assets/fonts/open-sans/'; $faces = ''; foreach ( array( 600, 700 ) as $weight ) { $faces .= sprintf( '@font-face{font-family:"Open Sans";font-style:normal;font-weight:%1$d;font-display:swap;src:url("%2$s") format("woff2");}', $weight, esc_url( $fonts . 'open-sans-latin-' . $weight . '.woff2' ) ); } wp_add_inline_style( 'leitvia-studio-admin', $faces ); if ( $framed ) { return; } wp_set_script_translations( 'leitvia-studio-admin', 'leitvia-studio' ); $panels = array(); foreach ( leitvia_studio()->registry( 'lab_panels' )->all() as $name => $panel ) { if ( current_user_can( $panel['capability'] ) ) { wp_enqueue_script( $panel['script'] ); $panels[] = array( 'name' => $name, 'label' => $panel['label'], 'order' => $panel['order'], ); } } usort( $panels, static fn( array $a, array $b ): int => $a['order'] <=> $b['order'] ); $data = array( 'screen' => $screen_id, 'userId' => get_current_user_id(), 'previewUrl' => add_query_arg( array( 'leitvia_studio_preview' => 1, '_wpnonce' => wp_create_nonce( 'leitvia_studio_preview' ), ), home_url( '/' ) ), 'variations' => array_map( static fn( array $variation ): array => array( 'slug' => (string) ( $variation['slug'] ?? '' ), 'title' => (string) ( $variation['title'] ?? '' ), ), leitvia_studio_contract()->is_valid() ? leitvia_studio_contract()->variations() : array() ), 'labPanels' => $panels, // Exports of add-ons in the token list; Studio's own download is the action "Export". 'syncTargets' => array_map( static fn( string $name, array $target ): array => array( 'name' => $name, 'label' => (string) $target['label'], ), array_keys( leitvia_studio()->registry( 'sync_targets' )->all() ), leitvia_studio()->registry( 'sync_targets' )->all() ), 'notesDismissed' => (bool) get_user_meta( get_current_user_id(), self::NOTES_DISMISSED, true ), 'usageIndex' => \LeitviaStudio\Lab\Usage_Index::is_built(), 'brand' => leitvia_studio_brand()['name'], 'agentRole' => (string) ( wp_roles()->role_names[ \LeitviaStudio\Gateway\Gateway::ROLE ] ?? '' ), 'canManage' => current_user_can( 'manage_options' ), // Names of the steps and codes add-ons add to recipe runs, shown on the Recipes screen. 'recipeLabels' => array_map( static fn( array $labels ): object => (object) $labels, \LeitviaStudio\Gateway\Recipes::labels() ), ); wp_add_inline_script( 'leitvia-studio-admin', 'window.leitviaStudioAdmin = ' . wp_json_encode( $data ) . ';', 'before' ); } /** * Registers the Studio panels of the Lab detail view. Runs on leitvia_studio_init. Variants holds the test content * (states) too; the Lab hides Annotations for patterns without a wp_block post (src/lab/detail.js). * * @param \LeitviaStudio\Extension\Studio $studio Studio. */ public static function register_panels( \LeitviaStudio\Extension\Studio $studio ): void { $studio->registry( 'lab_panels' )->register( 'leitvia/variants', array( 'label' => __( 'Variants', 'leitvia-studio' ), 'script' => 'leitvia-studio-admin', 'order' => 10, 'capability' => 'edit_theme_options', ), 'leitvia-studio' ); $studio->registry( 'lab_panels' )->register( 'leitvia/checks', array( 'label' => __( 'Checks', 'leitvia-studio' ), 'script' => 'leitvia-studio-admin', 'order' => 30, 'capability' => 'edit_theme_options', ), 'leitvia-studio' ); $studio->registry( 'lab_panels' )->register( 'leitvia/annotations', array( 'label' => __( 'Annotations', 'leitvia-studio' ), 'script' => 'leitvia-studio-admin', 'order' => 40, 'capability' => 'edit_theme_options', ), 'leitvia-studio' ); $studio->registry( 'lab_panels' )->register( 'leitvia/usage', array( 'label' => __( 'Usage', 'leitvia-studio' ), 'script' => 'leitvia-studio-admin', 'order' => 50, 'capability' => 'edit_theme_options', ), 'leitvia-studio' ); } /** * Post types whose admin lists and REST responses show the inherited pattern status: pages, posts and the post * types of the content model. Complete after init, priority 9. * * @return list */ public static function status_types(): array { return array_values( array_unique( array_merge( array( 'page', 'post' ), \LeitviaStudio\Model\Registrar::post_types() ) ) ); } /** * Hooks the column "Pattern status" into the admin lists of status_types(). Runs on init, priority 20. */ public static function register_status_column(): void { foreach ( self::status_types() as $type ) { add_filter( "manage_{$type}_posts_columns", array( self::class, 'add_status_column' ) ); add_action( "manage_{$type}_posts_custom_column", array( self::class, 'render_status_column' ), 10, 2 ); } } /** * Adds the column "Pattern status". Runs on manage_{$post_type}_posts_columns. * * @param array $columns Columns. * @return array */ public static function add_status_column( array $columns ): array { $columns['leitvia_pattern_status'] = __( 'Pattern status', 'leitvia-studio' ); return $columns; } /** * Prints the least mature effective status of the patterns a post uses. Runs on * manage_{$post_type}_posts_custom_column. * * @param string $column Column name. * @param int $post_id Post ID. */ public static function render_status_column( string $column, int $post_id ): void { if ( 'leitvia_pattern_status' !== $column ) { return; } $status = \LeitviaStudio\Lab\Usage_Index::post_status( $post_id ); echo esc_html( '' === $status ? '' : \LeitviaStudio\Lab\Status::label( $status ) ); } }