Icons.
*/
class AdminPage {
const MENU_SLUG = 'icon-library';
/**
* Collection registry.
*
* @var CollectionRegistry
*/
private $collection_registry;
/**
* SVG sanitizer.
*
* @var SvgSanitizer
*/
private $svg_sanitizer;
/**
* Constructor.
*
* @param CollectionRegistry $collection_registry Collection registry.
* @param SvgSanitizer $svg_sanitizer SVG sanitizer.
*/
public function __construct( CollectionRegistry $collection_registry, SvgSanitizer $svg_sanitizer ) {
$this->collection_registry = $collection_registry;
$this->svg_sanitizer = $svg_sanitizer;
}
/**
* Registers admin hooks.
*/
public function register() {
add_filter( 'plugin_action_links_' . plugin_basename( ICON_LIBRARY_FILE ), array( $this, 'plugin_action_links' ) );
add_action( 'admin_menu', array( $this, 'register_menu' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
add_action( 'wp_ajax_icon_library_preview_page', array( $this, 'preview_page' ) );
}
/**
* Adds a shortcut to icon management on the Plugins screen.
*
* @param array $links Existing plugin action links.
* @return array
*/
public function plugin_action_links( $links ) {
if ( ! current_user_can( 'manage_options' ) ) {
return $links;
}
$settings_link = sprintf(
'%s ',
esc_url( admin_url( 'themes.php?page=' . self::MENU_SLUG ) ),
esc_html__( 'Settings', 'aculect-icon-library' )
);
array_unshift( $links, $settings_link );
return $links;
}
/**
* Registers the Appearance submenu.
*/
public function register_menu() {
add_theme_page(
__( 'Icon Library', 'aculect-icon-library' ),
__( 'Icons', 'aculect-icon-library' ),
'manage_options',
self::MENU_SLUG,
array( $this, 'render' )
);
}
/**
* Enqueues screen-specific admin styles.
*
* @param string $hook_suffix Current admin page hook suffix.
*/
public function enqueue_assets( $hook_suffix ) {
if ( 'appearance_page_' . self::MENU_SLUG !== $hook_suffix ) {
return;
}
wp_enqueue_style(
'icon-library-admin',
ICON_LIBRARY_URL . 'assets/admin.css',
array(),
$this->asset_version( 'assets/admin.css' )
);
wp_enqueue_script(
'icon-library-admin',
ICON_LIBRARY_URL . 'assets/admin.js',
array( 'wp-api-fetch' ),
$this->asset_version( 'assets/admin.js' ),
true
);
$dataviews_asset = require ICON_LIBRARY_DIR . 'assets/build/custom-icons-dataviews.asset.php';
foreach ( $dataviews_asset['dependencies'] as $dependency ) {
wp_enqueue_script( $dependency );
}
wp_enqueue_style( 'wp-components' );
wp_localize_script(
'icon-library-admin',
'iconLibraryAdmin',
array(
'nonce' => wp_create_nonce( 'wp_rest' ),
'previewUrl' => admin_url( 'admin-ajax.php' ),
'previewNonce' => wp_create_nonce( 'icon_library_preview' ),
'restPath' => '/' . Plugin::REST_NAMESPACE . '/collections/',
'customPath' => '/' . Plugin::REST_NAMESPACE . '/custom-icons',
'dataViewsUrl' => ICON_LIBRARY_URL . 'assets/build/custom-icons-dataviews.js?ver=' . rawurlencode( $dataviews_asset['version'] ),
'dataViewsEnqueued' => 'custom' === $this->get_active_tab(),
'i18n' => array(
'updating' => __( 'Updating library...', 'aculect-icon-library' ),
'error' => __( 'The library could not be updated. Try again.', 'aculect-icon-library' ),
'uploading' => __( 'Validating and storing icon...', 'aculect-icon-library' ),
'updated' => __( 'Icon library updated.', 'aculect-icon-library' ),
'deleteConfirm' => __( 'Permanently delete this icon and its SVG file? Existing blocks using it will no longer render.', 'aculect-icon-library' ),
'purgeConfirm' => __( 'Permanently delete this archived icon and its SVG file? Existing blocks will no longer render it.', 'aculect-icon-library' ),
'fileTooLarge' => __( 'SVG files must be 64 KB or smaller.', 'aculect-icon-library' ),
'loadingMore' => __( 'Loading more icons...', 'aculect-icon-library' ),
'loadMoreError' => __( 'More icons could not be loaded. Try again.', 'aculect-icon-library' ),
/* translators: 1: number of icons loaded, 2: total matching icons. */
'loadMoreStatus' => __( 'Loaded %1$s of %2$s icons.', 'aculect-icon-library' ),
),
)
);
if ( 'custom' === $this->get_active_tab() ) {
wp_enqueue_script(
'icon-library-custom-dataviews',
ICON_LIBRARY_URL . 'assets/build/custom-icons-dataviews.js',
array_merge( array( 'icon-library-admin' ), $dataviews_asset['dependencies'] ),
$dataviews_asset['version'],
true
);
}
}
/**
* Renders the admin page.
*/
public function render() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$active_tab = $this->get_active_tab();
$collections = 'custom' === $active_tab ? array() : $this->collection_registry->get_collections();
?>
render_tabs( $active_tab ); ?>
render_notice(); ?>
render_custom_tab(); ?>
render_install_tab( $this->get_filters( $collections ), $collections ); ?>
render_library_tab(
array_filter(
$collections,
static function ( $collection ) {
return ! empty( $collection['installed'] );
}
)
);
?>
__( 'Choose an SVG file to upload.', 'aculect-icon-library' ),
'file_failed' => __( 'The SVG upload did not finish. Choose the file and try again.', 'aculect-icon-library' ),
'file_type' => __( 'Choose a file with an .svg extension.', 'aculect-icon-library' ),
'file_size' => __( 'SVG files must be 64 KB or smaller.', 'aculect-icon-library' ),
'icon_library_svg_empty' => __( 'The SVG file is empty. Choose an SVG with visible artwork.', 'aculect-icon-library' ),
'icon_library_svg_too_large' => __( 'SVG files must be 64 KB or smaller.', 'aculect-icon-library' ),
'icon_library_svg_invalid' => __( 'The file is not a valid SVG document. Export it as SVG and try again.', 'aculect-icon-library' ),
'icon_library_svg_declaration' => __( 'This SVG uses an unsupported declaration or processing instruction. Export a plain SVG and try again.', 'aculect-icon-library' ),
'icon_library_svg_namespace' => __( 'This SVG uses an unsupported namespace. Export a plain SVG and try again.', 'aculect-icon-library' ),
'icon_library_svg_element' => __( 'This SVG contains unsupported elements. Simplify the artwork and try again.', 'aculect-icon-library' ),
'icon_library_svg_attribute' => __( 'This SVG contains unsupported attributes. Simplify the artwork and try again.', 'aculect-icon-library' ),
'icon_library_svg_reference' => __( 'External or executable SVG references are not allowed. Remove them and try again.', 'aculect-icon-library' ),
'icon_library_svg_geometry' => __( 'The SVG has no supported visible artwork. Add a visible shape and try again.', 'aculect-icon-library' ),
'icon_library_custom_name_invalid' => __( 'Use a lowercase icon name containing letters, numbers, and hyphens.', 'aculect-icon-library' ),
'icon_library_custom_label_invalid' => __( 'Enter an icon label.', 'aculect-icon-library' ),
'icon_library_custom_duplicate' => __( 'An icon with that name already exists. Choose a different name.', 'aculect-icon-library' ),
'icon_library_custom_limit' => __( 'The custom icon limit has been reached. Remove an icon before uploading another.', 'aculect-icon-library' ),
'icon_library_custom_busy' => __( 'Another icon change is in progress. Try again.', 'aculect-icon-library' ),
'icon_library_custom_cleanup_failed' => __( 'The icon metadata could not be saved and its unpublished SVG could not be removed. Ask your site administrator to check the uploads directory before retrying.', 'aculect-icon-library' ),
'icon_library_custom_metadata_failed' => __( 'The icon change could not be confirmed. Refresh the icon list before retrying.', 'aculect-icon-library' ),
);
if ( $upload_error ) {
$message = isset( $upload_messages[ $upload_error ] ) ? $upload_messages[ $upload_error ] : __( 'The icon could not be stored. Check that uploads are available, then try again.', 'aculect-icon-library' );
echo '' . esc_html( $message ) . '
';
return;
}
// This read-only query parameter controls feedback after a verified mutation.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['icon-library-updated'] ) ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$updated = absint( $_GET['icon-library-updated'] );
$class = $updated ? 'notice-success' : 'notice-error';
$message = $updated
? __( 'Library settings updated.', 'aculect-icon-library' )
: __( 'Library settings could not be updated.', 'aculect-icon-library' );
?>
__( 'Library', 'aculect-icon-library' ),
'install' => __( 'Install Library', 'aculect-icon-library' ),
'custom' => _x( 'Upload', 'noun', 'aculect-icon-library' ),
);
?>
$label ) : ?>
self::MENU_SLUG,
'tab' => $tab,
),
admin_url( 'themes.php' )
);
?>
>
render_collection_detail( $collections[ $selected_slug ] );
return;
}
$install_url = add_query_arg(
array(
'page' => self::MENU_SLUG,
'tab' => 'install',
),
admin_url( 'themes.php' )
);
?>
render_collection_row( $collection ); ?>
collection_registry->get_bundled_collection_slugs(), true ) );
$selected_slug = $filters['collection'];
if ( $selected_slug && isset( $collections[ $selected_slug ] ) && CustomIconRepository::COLLECTION_SLUG !== $selected_slug ) {
$this->render_install_collection_detail( $collections[ $selected_slug ], $filters );
return;
}
?>
render_available_collection_row( $collection ); ?>
self::MENU_SLUG,
'tab' => 'install',
'collection' => $collection['slug'],
),
admin_url( 'themes.php' )
);
?>
$collection['slug'],
'variant' => $filters['variant'],
'category' => $filters['category'],
'search' => $filters['search'],
'page' => $page,
'per_page' => 72,
);
$query = $this->collection_registry->query_icons( $query_args );
$total = $query['total'];
$icons = $query['items'];
$installed = ! empty( $collection['installed'] );
$back_url = add_query_arg(
array(
'page' => self::MENU_SLUG,
'tab' => 'install',
),
admin_url( 'themes.php' )
);
?>
render_filters( $filters, array( $collection['slug'] => $collection ), $query['variant_counts'] ); ?>
render_icon_grid( $icons ); ?>
render_icon_pagination( $page, $total, 72, count( $icons ) ); ?>
add_query_arg( 'icon-page', '%#%', $base_url ),
'current' => min( $current_page, $total_pages ),
'total' => $total_pages,
'type' => 'list',
'prev_text' => __( 'Previous', 'aculect-icon-library' ),
'next_text' => __( 'Next', 'aculect-icon-library' ),
)
);
if ( $links ) {
echo '';
}
if ( $current_page >= $total_pages ) {
return;
}
?>
self::MENU_SLUG,
'tab' => 'custom',
),
admin_url( 'themes.php' )
)
: add_query_arg(
array(
'page' => self::MENU_SLUG,
'tab' => 'library',
'collection' => $collection['slug'],
),
admin_url( 'themes.php' )
);
?>
self::MENU_SLUG,
'tab' => 'library',
),
admin_url( 'themes.php' )
);
?>
get_categories( $filters['collection'], $filters['variant'], $filters['search'] );
if ( null === $variant_counts ) {
$variant_counts = $this->collection_registry->count_icons_by_variant(
array(
'collection' => isset( $selected_collection['slug'] ) ? $selected_collection['slug'] : '',
'category' => $filters['category'],
'search' => $filters['search'],
)
);
}
?>
collection_registry->get_enabled_collection_slugs();
$counts = $this->collection_registry->count_icons_by_category(
array(
'collection' => $collection_slug,
'variant' => $variant,
'search' => $search,
)
);
foreach ( $slugs as $slug ) {
$manifest = $this->collection_registry->get_manifest( $slug );
if ( ! is_array( $manifest ) ) {
continue;
}
if ( ! empty( $manifest['categories'] ) && is_array( $manifest['categories'] ) ) {
foreach ( $manifest['categories'] as $category ) {
if ( ! is_array( $category ) || empty( $category['slug'] ) || empty( $category['label'] ) ) {
continue;
}
$category_slug = sanitize_key( $category['slug'] );
if ( '' === $category_slug ) {
continue;
}
if ( ! isset( $categories[ $category_slug ] ) ) {
$categories[ $category_slug ] = array(
'slug' => $category_slug,
'label' => sanitize_text_field( $category['label'] ),
'iconCount' => 0,
);
}
$categories[ $category_slug ]['iconCount'] = $counts[ $category_slug ] ?? 0;
}
continue;
}
if ( empty( $manifest['icons'] ) || ! is_array( $manifest['icons'] ) ) {
continue;
}
foreach ( $manifest['icons'] as $icon ) {
if ( ! empty( $icon['categories'] ) && is_array( $icon['categories'] ) ) {
foreach ( $icon['categories'] as $category ) {
$category_slug = sanitize_key( $category );
if ( '' === $category_slug ) {
continue;
}
if ( ! isset( $categories[ $category_slug ] ) ) {
$categories[ $category_slug ] = array(
'slug' => $category_slug,
'label' => ucwords( str_replace( '-', ' ', $category_slug ) ),
'iconCount' => 0,
);
}
$categories[ $category_slug ]['iconCount'] = $counts[ $category_slug ] ?? 0;
}
}
}
}
$categories = array_values( $categories );
usort(
$categories,
static function ( $left, $right ) {
return strnatcasecmp( (string) ( $left['label'] ?? '' ), (string) ( $right['label'] ?? '' ) );
}
);
return $categories;
}
/** Sends only one preview grid, without rendering the admin shell or facets. */
public function preview_page() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'code' => 'icon_library_forbidden' ), 403 );
return;
}
check_ajax_referer( 'icon_library_preview', '_ajax_nonce' );
$filters = $this->get_filters( array() );
if ( '' === $filters['collection'] ) {
wp_send_json_error( array( 'code' => 'icon_library_invalid_collection' ), 400 );
return;
}
$page = isset( $_GET['icon-page'] ) && is_scalar( $_GET['icon-page'] ) ? min( CollectionRegistry::MAX_PAGE, max( 1, absint( $_GET['icon-page'] ) ) ) : 1;
$query = $this->collection_registry->query_icons(
array_merge(
$filters,
array(
'page' => $page,
'per_page' => 72,
)
)
);
ob_start();
$this->render_icon_grid( $query['items'] );
$html = ob_get_clean();
wp_send_json_success(
array(
'html' => $html,
'page' => $page,
'total' => $query['total'],
'has_more' => $page * 72 < $query['total'],
)
);
}
/**
* Renders preview icons.
*
* @param array[] $icons Icons.
*/
private function render_icon_grid( $icons ) {
if ( empty( $icons ) ) {
echo '' . esc_html__( 'No icons match the current filters.', 'aculect-icon-library' ) . '
';
return;
}
?>
collection_registry->get_svg_content( $icon['collection'], $icon['path'] ?? '' );
$svg = $this->svg_sanitizer->sanitize( $svg );
?>
isset( $_GET['collection'] ) && is_string( $_GET['collection'] ) ? sanitize_key( wp_unslash( $_GET['collection'] ) ) : '',
'variant' => isset( $_GET['variant'] ) && is_string( $_GET['variant'] ) ? sanitize_key( wp_unslash( $_GET['variant'] ) ) : '',
'category' => isset( $_GET['category'] ) && is_string( $_GET['category'] ) ? sanitize_key( wp_unslash( $_GET['category'] ) ) : '',
'search' => isset( $_GET['search'] ) && is_string( $_GET['search'] ) ? sanitize_text_field( wp_unslash( $_GET['search'] ) ) : '',
// phpcs:enable WordPress.Security.NonceVerification.Recommended
);
}
/**
* Returns the active page tab.
*
* @return string
*/
private function get_active_tab() {
// Read-only navigation state; no nonce is required.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$tab = isset( $_GET['tab'] ) && is_string( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'library';
return in_array( $tab, array( 'library', 'install', 'custom' ), true ) ? $tab : 'library';
}
}