diff --git a/includes/class-blocks.php b/includes/class-blocks.php
index ab7244b91c..c8b774bdbc 100644
--- a/includes/class-blocks.php
+++ b/includes/class-blocks.php
@@ -9,6 +9,8 @@
defined( 'ABSPATH' ) || exit;
+use Newspack\Optional_Modules\Collections;
+
/**
* Newspack Blocks Class.
*/
@@ -26,8 +28,12 @@ public static function init() {
if ( wp_is_block_theme() ) {
require_once NEWSPACK_ABSPATH . 'src/blocks/avatar/class-avatar-block.php';
}
+ if ( Collections::is_module_active() ) {
+ require_once NEWSPACK_ABSPATH . 'src/blocks/collections/index.php';
+ }
\add_action( 'enqueue_block_editor_assets', [ __CLASS__, 'enqueue_block_editor_assets' ] );
+ \add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_frontend_assets' ] );
}
/**
@@ -57,6 +63,7 @@ public static function enqueue_block_editor_assets() {
'has_recaptcha' => Recaptcha::can_use_captcha(),
'recaptcha_url' => admin_url( 'admin.php?page=newspack-settings' ),
'corrections_enabled' => wp_is_block_theme() && class_exists( 'Newspack\Corrections' ),
+ 'collections_enabled' => Collections::is_module_active(),
]
);
\wp_enqueue_style(
@@ -66,5 +73,37 @@ public static function enqueue_block_editor_assets() {
NEWSPACK_PLUGIN_VERSION
);
}
+
+ /**
+ * Enqueue blocks scripts and styles for frontend.
+ * Only load if we have blocks on the page that need these styles.
+ */
+ public static function enqueue_frontend_assets() {
+ if ( self::should_load_block_assets() ) {
+ \wp_enqueue_style(
+ 'newspack-blocks-frontend',
+ Newspack::plugin_url() . '/dist/blocks.css',
+ [],
+ NEWSPACK_PLUGIN_VERSION
+ );
+ }
+ }
+
+ /**
+ * Check if we should load block assets on current page.
+ *
+ * @return bool Whether to load block assets.
+ */
+ private static function should_load_block_assets() {
+ if (
+ is_singular() &&
+ Collections::is_module_active() &&
+ has_block( \Newspack\Blocks\Collections\Collections_Block::BLOCK_NAME, get_the_ID() )
+ ) {
+ return true;
+ }
+
+ return false;
+ }
}
Blocks::init();
diff --git a/includes/collections/class-collection-meta.php b/includes/collections/class-collection-meta.php
index e59b53422b..81e94efaa2 100644
--- a/includes/collections/class-collection-meta.php
+++ b/includes/collections/class-collection-meta.php
@@ -138,6 +138,7 @@ public static function get_meta_definitions() {
*/
public static function init() {
add_action( 'init', [ __CLASS__, 'register_meta' ] );
+ add_action( 'rest_api_init', [ __CLASS__, 'register_rest_fields' ] );
}
/**
@@ -208,4 +209,42 @@ private static function sanitize_single_cta( $cta ) {
return $sanitized_cta;
}
+
+ /**
+ * Register custom REST API fields for collections.
+ */
+ public static function register_rest_fields() {
+ register_rest_field(
+ Post_Type::get_post_type(),
+ 'ctas',
+ [
+ 'get_callback' => [ __CLASS__, 'get_collection_ctas_for_rest' ],
+ 'schema' => [
+ 'description' => __( 'Collection CTAs', 'newspack-plugin' ),
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'url' => [ 'type' => 'string' ],
+ 'label' => [ 'type' => 'string' ],
+ 'class' => [ 'type' => 'string' ],
+ ],
+ ],
+ 'context' => [ 'view', 'edit' ],
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Get collection CTAs for REST API response.
+ *
+ * @param array $post Post data.
+ * @return array Array of processed CTAs.
+ *
+ * @see Query_Helper::get_ctas()
+ */
+ public static function get_collection_ctas_for_rest( $post ) {
+ return Query_Helper::get_ctas( $post['id'] );
+ }
}
diff --git a/includes/collections/class-query-helper.php b/includes/collections/class-query-helper.php
index 6164d1af71..9bbed7a72b 100644
--- a/includes/collections/class-query-helper.php
+++ b/includes/collections/class-query-helper.php
@@ -541,4 +541,77 @@ function ( $post ) use ( $exclude ) {
*/
return apply_filters( 'newspack_collections_recent', array_slice( $filtered, 0, $limit ), $exclude, $limit );
}
+
+ /**
+ * Build and run the collections query based on block attributes.
+ *
+ * @param array $attributes Block attributes (sanitized).
+ * @return array Array of WP_Post collection objects.
+ */
+ public static function get_collections_by_attributes( $attributes ) {
+ $query_args = [
+ 'post_type' => Post_Type::get_post_type(),
+ 'post_status' => 'publish',
+ 'posts_per_page' => $attributes['numberOfItems'],
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'offset' => $attributes['offset'],
+ ];
+
+ // Handle specific collections selection.
+ if ( 'specific' === ( $attributes['queryType'] ?? '' ) && ! empty( $attributes['selectedCollections'] ) ) {
+ $query_args['post__in'] = $attributes['selectedCollections'];
+ $query_args['orderby'] = 'post__in';
+ unset( $query_args['offset'] ); // Offset doesn't apply to specific post selections.
+ }
+
+ // Handle category filtering.
+ if ( ! empty( $attributes['includeCategories'] ) || ! empty( $attributes['excludeCategories'] ) ) {
+ $tax_query = [];
+
+ if ( ! empty( $attributes['includeCategories'] ) ) {
+ $tax_query[] = [
+ 'taxonomy' => Collection_Category_Taxonomy::get_taxonomy(),
+ 'field' => 'term_id',
+ 'terms' => $attributes['includeCategories'],
+ 'operator' => 'IN',
+ ];
+ }
+
+ if ( ! empty( $attributes['excludeCategories'] ) ) {
+ $tax_query[] = [
+ 'taxonomy' => Collection_Category_Taxonomy::get_taxonomy(),
+ 'field' => 'term_id',
+ 'terms' => $attributes['excludeCategories'],
+ 'operator' => 'NOT IN',
+ ];
+ }
+
+ if ( count( $tax_query ) > 1 ) {
+ $tax_query['relation'] = 'AND';
+ }
+
+ $query_args['tax_query'] = $tax_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+ }
+
+ /**
+ * Generic filter for collections query args.
+ *
+ * @param array $query_args Query args.
+ * @param array $attributes Block attributes.
+ */
+ $query_args = apply_filters( 'newspack_collections_query_args', $query_args, $attributes );
+
+ $query = new \WP_Query( $query_args );
+ $posts = $query->posts;
+
+ /**
+ * Filter the collections posts returned by the query.
+ *
+ * @param array $posts Array of WP_Post objects.
+ * @param array $query_args Final query args.
+ * @param array $attributes Block attributes.
+ */
+ return apply_filters( 'newspack_collections_query_posts', $posts, $query_args, $attributes );
+ }
}
diff --git a/packages/icons/index.js b/packages/icons/index.js
index 59a6f45a8b..b7819aec75 100644
--- a/packages/icons/index.js
+++ b/packages/icons/index.js
@@ -5,6 +5,7 @@ export { default as aspectPortrait } from './src/aspect-portrait';
export { default as aspectSquare } from './src/aspect-square';
export { default as ballotBox } from './src/ballot-box';
export { default as broadcast } from './src/broadcast';
+export { default as collections } from './src/collections';
export { default as contentCarousel } from './src/content-carousel';
export { default as contentLoop } from './src/content-loop';
export { default as corrections } from './src/corrections';
diff --git a/packages/icons/src/collections.js b/packages/icons/src/collections.js
new file mode 100644
index 0000000000..5a5fe203d0
--- /dev/null
+++ b/packages/icons/src/collections.js
@@ -0,0 +1,12 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/primitives';
+
+const collections = (
+
+);
+
+export default collections;
diff --git a/src/blocks/collections/block.json b/src/blocks/collections/block.json
new file mode 100644
index 0000000000..4a146d0e09
--- /dev/null
+++ b/src/blocks/collections/block.json
@@ -0,0 +1,135 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "newspack/collections",
+ "version": "1.0.0",
+ "title": "Collections",
+ "category": "newspack",
+ "description": "An advanced block that allows displaying collections based on different parameters and visual configurations.",
+ "keywords": [ "collections", "issues", "publications", "magazines", "groups" ],
+ "supports": {
+ "html": false,
+ "align": [ "wide", "full" ],
+ "spacing": {
+ "margin": true,
+ "padding": true
+ }
+ },
+ "attributes": {
+ "queryType": {
+ "type": "string",
+ "default": "recent"
+ },
+ "numberOfItems": {
+ "type": "number",
+ "default": 4
+ },
+ "offset": {
+ "type": "number",
+ "default": 0
+ },
+ "selectedCollections": {
+ "type": "array",
+ "default": []
+ },
+ "includeCategories": {
+ "type": "array",
+ "default": []
+ },
+ "excludeCategories": {
+ "type": "array",
+ "default": []
+ },
+ "layout": {
+ "type": "string",
+ "default": "grid"
+ },
+ "columns": {
+ "type": "number",
+ "default": 4
+ },
+ "imageAlignment": {
+ "type": "string",
+ "default": "left"
+ },
+ "imageSize": {
+ "type": "string",
+ "default": "small"
+ },
+ "showFeaturedImage": {
+ "type": "boolean",
+ "default": true
+ },
+ "showTitle": {
+ "type": "boolean",
+ "default": true
+ },
+ "showCategory": {
+ "type": "boolean",
+ "default": true
+ },
+ "showExcerpt": {
+ "type": "boolean",
+ "default": false
+ },
+ "showPeriod": {
+ "type": "boolean",
+ "default": true
+ },
+ "showVolume": {
+ "type": "boolean",
+ "default": true
+ },
+ "showNumber": {
+ "type": "boolean",
+ "default": true
+ },
+ "showCTAs": {
+ "type": "boolean",
+ "default": true
+ },
+ "numberOfCTAs": {
+ "type": "number",
+ "default": 1
+ },
+ "showSubscriptionUrl": {
+ "type": "boolean",
+ "default": true
+ },
+ "showOrderUrl": {
+ "type": "boolean",
+ "default": true
+ },
+ "specificCTAs": {
+ "type": "string",
+ "default": ""
+ },
+ "showSeeAllLink": {
+ "type": "boolean",
+ "default": true
+ },
+ "seeAllLinkText": {
+ "type": "string",
+ "default": ""
+ }
+ },
+ "example": {
+ "attributes": {
+ "queryType": "recent",
+ "numberOfItems": 4,
+ "layout": "grid",
+ "columns": 4,
+ "showFeaturedImage": true,
+ "showTitle": true,
+ "showPeriod": true,
+ "showVolume": true,
+ "showNumber": true,
+ "showCTAs": true,
+ "numberOfCTAs": 1,
+ "showSeeAllLink": true,
+ "seeAllLinkText": "See all collections"
+ }
+ },
+ "textdomain": "newspack-plugin",
+ "editorScript": "file:./index.js"
+}
diff --git a/src/blocks/collections/class-collections-block.php b/src/blocks/collections/class-collections-block.php
new file mode 100644
index 0000000000..c52a985d5b
--- /dev/null
+++ b/src/blocks/collections/class-collections-block.php
@@ -0,0 +1,405 @@
+ 'recent',
+ 'numberOfItems' => 4,
+ 'offset' => 0,
+ 'selectedCollections' => [],
+ 'includeCategories' => [],
+ 'excludeCategories' => [],
+ 'layout' => 'grid',
+ 'columns' => 4,
+ 'imageAlignment' => 'top',
+ 'imageSize' => 'small',
+ 'showFeaturedImage' => true,
+ 'showCategory' => true,
+ 'showTitle' => true,
+ 'showExcerpt' => false,
+ 'showVolume' => true,
+ 'showNumber' => true,
+ 'showPeriod' => true,
+ 'showSubscriptionUrl' => true,
+ 'showOrderUrl' => true,
+ 'showCTAs' => true,
+ 'numberOfCTAs' => 1,
+ 'specificCTAs' => '',
+ 'showSeeAllLink' => true,
+ 'seeAllLinkText' => '',
+ ];
+
+ /**
+ * Initializes the block.
+ *
+ * @return void
+ */
+ public static function init() {
+ add_action( 'init', [ __CLASS__, 'register_block' ] );
+ }
+
+ /**
+ * Register newspack collections block.
+ *
+ * @return void
+ */
+ public static function register_block() {
+ register_block_type_from_metadata(
+ __DIR__ . '/block.json',
+ [
+ 'render_callback' => [ __CLASS__, 'render_block' ],
+ ]
+ );
+ }
+
+ /**
+ * Block render callback.
+ *
+ * @param array $attributes The block attributes.
+ *
+ * @return string The block HTML.
+ */
+ public static function render_block( array $attributes ) {
+
+ $attributes = wp_parse_args( $attributes, self::DEFAULT_ATTRIBUTES );
+
+ // Sanitize and normalize attributes that are used in queries/output.
+ $attributes['numberOfItems'] = max( 1, absint( $attributes['numberOfItems'] ) );
+ $attributes['offset'] = max( 0, absint( $attributes['offset'] ) );
+ $attributes['columns'] = max( 1, absint( $attributes['columns'] ) );
+ $attributes['numberOfCTAs'] = max( 1, absint( $attributes['numberOfCTAs'] ) );
+ $attributes['selectedCollections'] = array_map( 'absint', (array) $attributes['selectedCollections'] );
+ $attributes['includeCategories'] = array_map( 'absint', (array) $attributes['includeCategories'] );
+ $attributes['excludeCategories'] = array_map( 'absint', (array) $attributes['excludeCategories'] );
+
+ $collections = Query_Helper::get_collections_by_attributes( $attributes );
+
+ if ( empty( $collections ) ) {
+ return '
' . esc_html__( 'No collections found.', 'newspack-plugin' ) . '
';
+ }
+
+ /**
+ * Filter the CSS classes for the collections block wrapper.
+ *
+ * @param string $classes Wrapper classes string.
+ * @param array $attributes Block attributes.
+ */
+ $classes = apply_filters( 'newspack_collections_block_wrapper_classes', self::get_block_classes( $attributes ), $attributes );
+
+ $wrapper_attributes = get_block_wrapper_attributes(
+ [
+ 'class' => esc_attr( $classes ),
+ ]
+ );
+
+ ob_start();
+ ?>
+
+
+
+
+
+
+ ID, $collection_url, $image_size ) ); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ID, 'period' );
+ if ( $period ) {
+ $meta_parts[] = esc_html( $period );
+ }
+ }
+
+ // Volume and number.
+ $vol_number = [];
+
+ if ( $attributes['showVolume'] ) {
+ $volume = Collection_Meta::get( $collection->ID, 'volume' );
+ if ( $volume ) {
+ /* translators: %s is the volume number of a collection */
+ $vol_number[] = sprintf( _x( 'Vol. %s', 'collection volume number', 'newspack-plugin' ), esc_html( $volume ) );
+ }
+ }
+
+ if ( $attributes['showNumber'] ) {
+ $number = Collection_Meta::get( $collection->ID, 'number' );
+ if ( $number ) {
+ /* translators: %s is the issue number of a collection */
+ $vol_number[] = sprintf( _x( 'No. %s', 'collection issue number', 'newspack-plugin' ), esc_html( $number ) );
+ }
+ }
+
+ if ( $vol_number ) {
+ $meta_parts[] = implode( ' / ', $vol_number );
+ }
+
+ // Render meta text.
+ if ( ! empty( $meta_parts ) ) {
+ // Use different separator based on layout.
+ $separator = 'list' === $attributes['layout'] ? ' / ' : '
';
+ $meta_text = implode( $separator, $meta_parts );
+ ?>
+
+
+
+ ID );
+
+ // Filter CTAs based on toggle settings.
+ $filtered_ctas = array_filter(
+ $all_ctas,
+ function ( $cta ) use ( $attributes ) {
+ $cta_label = $cta['label'] ?? '';
+
+ if ( __( 'Subscribe', 'newspack-plugin' ) === $cta_label && ! $attributes['showSubscriptionUrl'] ) {
+ return false;
+ }
+
+ if ( __( 'Order', 'newspack-plugin' ) === $cta_label && ! $attributes['showOrderUrl'] ) {
+ return false;
+ }
+
+ return true;
+ }
+ );
+
+ // Filter by specific labels if provided.
+ if ( ! empty( $attributes['specificCTAs'] ) ) {
+ $specific_labels = array_map( 'trim', explode( ',', $attributes['specificCTAs'] ) );
+ $filtered_ctas = array_filter(
+ $filtered_ctas,
+ function ( $cta ) use ( $specific_labels ) {
+ $cta_label = $cta['label'] ?? '';
+ return in_array( $cta_label, $specific_labels, true );
+ }
+ );
+ }
+
+ // Limit to numberOfCTAs.
+ $max_ctas = $attributes['numberOfCTAs'] ?? 1;
+ $filtered_ctas = array_slice( $filtered_ctas, 0, $max_ctas );
+
+ /**
+ * Filter the CTAs rendered by the collections block for a given collection.
+ *
+ * @param array $filtered_ctas Filtered CTAs.
+ * @param array $all_ctas All available CTAs.
+ * @param \WP_Post $collection The collection post object.
+ * @param array $attributes Block attributes.
+ */
+ $filtered_ctas = apply_filters( 'newspack_collections_block_ctas', $filtered_ctas, $all_ctas, $collection, $attributes );
+
+ // Render CTAs.
+ if ( ! empty( $filtered_ctas ) ) {
+ ?>
+
+
+
+
+
+ {
+ const {
+ showFeaturedImage,
+ showCategory,
+ showTitle,
+ showExcerpt,
+ showPeriod,
+ showVolume,
+ showNumber,
+ showCTAs,
+ numberOfCTAs,
+ showSubscriptionUrl,
+ showOrderUrl,
+ specificCTAs,
+ layout,
+ } = attributes;
+
+ // Prevent default navigation for non-navigable anchors in the editor.
+ const preventNav = usePreventNav();
+
+ // Decode once per render instead of on every usage.
+ const titleText = useMemo( () => decodeEntities( collection?.title?.rendered || '' ), [ collection?.title?.rendered ] );
+
+ // Get featured image from embedded data.
+ const featuredImage = useMemo( () => collection?._embedded?.[ 'wp:featuredmedia' ]?.[ 0 ], [ collection?._embedded ] );
+
+ // Get categories from embedded data.
+ const categories = useMemo( () => {
+ return collection?._embedded?.[ 'wp:term' ]?.find( terms => terms?.[ 0 ]?.taxonomy === 'newspack_collection_category' ) || [];
+ }, [ collection?._embedded ] );
+
+ // Collection metadata.
+ const meta = collection.meta || {};
+ const volume = meta.newspack_collection_volume;
+ const number = meta.newspack_collection_number;
+ const period = meta.newspack_collection_period;
+
+ // Build filtered CTAs with memoization.
+ const filteredCtas = useMemo( () => {
+ let next = ( collection.ctas || [] ).filter( cta => {
+ // Filter by subscription toggle.
+ if ( cta.label === __( 'Subscribe', 'newspack-plugin' ) && ! showSubscriptionUrl ) {
+ return false;
+ }
+ // Filter by order toggle.
+ if ( cta.label === __( 'Order', 'newspack-plugin' ) && ! showOrderUrl ) {
+ return false;
+ }
+ return true;
+ } );
+
+ // Filter by specific labels if provided (case-insensitive, trims whitespace).
+ if ( specificCTAs && specificCTAs.trim() ) {
+ const specificLabels = new Set( specificCTAs.split( ',' ).map( label => label.trim().toLowerCase() ) );
+ next = next.filter( cta => specificLabels.has( String( cta.label ).toLowerCase() ) );
+ }
+
+ // Limit number of CTAs.
+ return next.slice( 0, numberOfCTAs || 1 );
+ }, [ collection.ctas, showSubscriptionUrl, showOrderUrl, specificCTAs, numberOfCTAs ] );
+
+ // Build meta text, memoized.
+ const metaText = useMemo( () => {
+ const metaParts = [];
+
+ // Add period first.
+ if ( showPeriod && period ) {
+ metaParts.push( period );
+ }
+
+ const volNumber = [];
+
+ if ( showVolume && volume ) {
+ volNumber.push( `Vol. ${ volume }` );
+ }
+
+ if ( showNumber && number ) {
+ volNumber.push( `No. ${ number }` );
+ }
+
+ if ( volNumber.length ) {
+ metaParts.push( volNumber.join( ' / ' ) );
+ }
+
+ if ( metaParts.length ) {
+ // Use different separator based on layout.
+ const separator = layout === 'list' ? ' / ' : '
';
+ return metaParts.join( separator );
+ }
+
+ return '';
+ }, [ showPeriod, period, showVolume, volume, showNumber, number, layout ] );
+
+ return (
+
+ { showFeaturedImage && (
+
+ { featuredImage ? (
+
+
+
+ ) : (
+
+ ) }
+
+ ) }
+
+
+ { showCategory && categories.length > 0 && (
+
+ ) }
+
+ { showTitle && (
+
+ ) }
+
+ { metaText && (
+
+ { metaText }
+
+ ) }
+
+ { showExcerpt && ( collection.excerpt?.rendered || collection.content?.rendered ) && (
+
+ { collection.excerpt?.rendered || collection.content?.rendered }
+
+ ) }
+
+ { showCTAs && filteredCtas.length > 0 && (
+
+ { filteredCtas.map( ( cta, index ) => (
+
+ ) ) }
+
+ ) }
+
+
+ );
+};
+
+export default CollectionItem;
diff --git a/src/blocks/collections/components/InspectorPanel.jsx b/src/blocks/collections/components/InspectorPanel.jsx
new file mode 100644
index 0000000000..252aa2640a
--- /dev/null
+++ b/src/blocks/collections/components/InspectorPanel.jsx
@@ -0,0 +1,320 @@
+import { __ } from '@wordpress/i18n';
+import { InspectorControls } from '@wordpress/block-editor';
+import {
+ BaseControl,
+ PanelBody,
+ RangeControl,
+ ToggleControl,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalToggleGroupControl as ToggleGroupControl,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalToggleGroupControlOption as ToggleGroupControlOption,
+} from '@wordpress/components';
+import { useCallback } from '@wordpress/element';
+
+import { AutocompleteTokenField } from '../../../components/src';
+import {
+ fetchCategorySuggestions as fetchCategorySuggestionsRaw,
+ fetchSavedCategories as fetchSavedCategoriesRaw,
+ fetchCollectionSuggestions as fetchCollectionSuggestionsRaw,
+ fetchSavedCollections as fetchSavedCollectionsRaw,
+} from '../utils/api';
+
+const InspectorPanel = ( { attributes, setAttributes } ) => {
+ const {
+ queryType,
+ numberOfItems,
+ offset,
+ selectedCollections,
+ includeCategories,
+ excludeCategories,
+ layout,
+ columns,
+ imageAlignment,
+ imageSize,
+ showFeaturedImage,
+ showCategory,
+ showTitle,
+ showExcerpt,
+ showVolume,
+ showNumber,
+ showPeriod,
+ showSubscriptionUrl,
+ showOrderUrl,
+ showCTAs,
+ numberOfCTAs,
+ specificCTAs,
+ showSeeAllLink,
+ seeAllLinkText,
+ } = attributes;
+
+ // Category suggestions.
+ const fetchCategorySuggestions = useCallback( search => fetchCategorySuggestionsRaw( search ), [] );
+
+ // Saved categories.
+ const fetchSavedCategories = useCallback( categoryIDs => fetchSavedCategoriesRaw( categoryIDs ), [] );
+
+ // Collection suggestions.
+ const fetchCollectionSuggestions = useCallback( search => fetchCollectionSuggestionsRaw( search ), [] );
+
+ // Saved collections.
+ const fetchSavedCollections = useCallback( collectionIDs => fetchSavedCollectionsRaw( collectionIDs ), [] );
+
+ return (
+
+
+ setAttributes( { queryType: value } ) }
+ isBlock
+ help={
+ queryType === 'recent'
+ ? __( 'The block will display the most recent collection(s).', 'newspack-plugin' )
+ : __( 'The block will display only the specifically selected collection(s).', 'newspack-plugin' )
+ }
+ __next40pxDefaultSize
+ >
+
+
+
+
+ { queryType === 'specific' && (
+ setAttributes( { selectedCollections: value } ) }
+ fetchSuggestions={ fetchCollectionSuggestions }
+ fetchSavedInfo={ fetchSavedCollections }
+ label={ __( 'Collections', 'newspack-plugin' ) }
+ help={ __( 'Begin typing any word in a collection title. Click on an autocomplete result to select it.', 'newspack-plugin' ) }
+ __next40pxDefaultSize
+ />
+ ) }
+
+ { queryType === 'recent' && (
+ <>
+ setAttributes( { numberOfItems: value } ) }
+ min={ 1 }
+ max={ 24 }
+ __next40pxDefaultSize
+ />
+
+ setAttributes( { offset: value } ) }
+ min={ 0 }
+ max={ 50 }
+ help={ __( 'Number of collections to skip from the beginning', 'newspack-plugin' ) }
+ __next40pxDefaultSize
+ />
+
+ setAttributes( { includeCategories: value } ) }
+ fetchSuggestions={ fetchCategorySuggestions }
+ fetchSavedInfo={ fetchSavedCategories }
+ label={ __( 'Included categories', 'newspack-plugin' ) }
+ style={ { marginBottom: '16px' } }
+ __next40pxDefaultSize
+ />
+
+ setAttributes( { excludeCategories: value } ) }
+ fetchSuggestions={ fetchCategorySuggestions }
+ fetchSavedInfo={ fetchSavedCategories }
+ label={ __( 'Excluded categories', 'newspack-plugin' ) }
+ style={ { marginBottom: '16px' } }
+ __next40pxDefaultSize
+ />
+ >
+ ) }
+
+ setAttributes( { showSeeAllLink: value } ) }
+ />
+
+ { showSeeAllLink && (
+
+ setAttributes( { seeAllLinkText: e.target.value } ) }
+ placeholder={ __( 'See all', 'newspack-plugin' ) }
+ className="components-text-control__input"
+ style={ { height: '40px' } }
+ />
+
+ ) }
+
+
+ { layout === 'grid' && (
+
+ setAttributes( { columns: value } ) }
+ min={ 1 }
+ max={ 6 }
+ __next40pxDefaultSize
+ />
+
+ ) }
+
+
+ setAttributes( { showFeaturedImage: value } ) }
+ />
+ { layout === 'list' && showFeaturedImage && (
+ <>
+ setAttributes( { imageAlignment: value } ) }
+ isBlock
+ __next40pxDefaultSize
+ >
+
+
+
+
+
+ setAttributes( { imageSize: value } ) }
+ isBlock
+ __next40pxDefaultSize
+ >
+
+
+
+
+ >
+ ) }
+
+
+
+ setAttributes( { showTitle: value } ) }
+ />
+
+ setAttributes( { showCategory: value } ) }
+ />
+
+ setAttributes( { showExcerpt: value } ) }
+ />
+
+ setAttributes( { showPeriod: value } ) }
+ />
+
+ setAttributes( { showVolume: value } ) }
+ />
+
+ setAttributes( { showNumber: value } ) }
+ />
+
+ setAttributes( { showCTAs: value } ) }
+ />
+
+ { showCTAs && (
+ <>
+ setAttributes( { numberOfCTAs: value } ) }
+ min={ 1 }
+ max={ 5 }
+ help={ __( 'Maximum number of CTAs to display', 'newspack-plugin' ) }
+ __next40pxDefaultSize
+ />
+
+ setAttributes( { showSubscriptionUrl: value } ) }
+ />
+
+ setAttributes( { showOrderUrl: value } ) }
+ />
+
+
+ setAttributes( { specificCTAs: e.target.value } ) }
+ placeholder={ __( '', 'newspack-plugin' ) }
+ className="components-text-control__input"
+ style={ { height: '40px' } }
+ />
+
+ >
+ ) }
+
+
+ );
+};
+
+export default InspectorPanel;
diff --git a/src/blocks/collections/edit.jsx b/src/blocks/collections/edit.jsx
new file mode 100644
index 0000000000..60a73732fc
--- /dev/null
+++ b/src/blocks/collections/edit.jsx
@@ -0,0 +1,155 @@
+import { __ } from '@wordpress/i18n';
+import { useBlockProps, BlockControls, RichText } from '@wordpress/block-editor';
+import { ToolbarGroup, ToolbarButton, Placeholder, Spinner } from '@wordpress/components';
+import { caution, list, grid, pullLeft, pullRight, postFeaturedImage } from '@wordpress/icons';
+import { useMemo } from '@wordpress/element';
+import classnames from 'classnames';
+
+import { useCollections } from './hooks/useCollections';
+import CollectionItem from './components/CollectionItem';
+import usePreventNav from './hooks/usePreventNav';
+import InspectorPanel from './components/InspectorPanel';
+
+const Edit = ( { attributes, setAttributes } ) => {
+ const { layout, columns, imageAlignment, imageSize, showFeaturedImage, showSeeAllLink, seeAllLinkText } = attributes;
+
+ const preventNav = usePreventNav();
+
+ // Fetch collections data.
+ const { collections, isLoading, hasCollections } = useCollections( attributes );
+
+ // Toolbar controls.
+ const layoutControls = useMemo(
+ () => [
+ {
+ icon: list,
+ title: __( 'List view', 'newspack-plugin' ),
+ onClick: () => setAttributes( { layout: 'list' } ),
+ isActive: layout === 'list',
+ },
+ {
+ icon: grid,
+ title: __( 'Grid view', 'newspack-plugin' ),
+ onClick: () => setAttributes( { layout: 'grid' } ),
+ isActive: layout === 'grid',
+ },
+ ],
+ [ layout, setAttributes ]
+ );
+
+ const imageAlignmentControls = useMemo(
+ () => [
+ {
+ icon: postFeaturedImage,
+ title: __( 'Show image on top', 'newspack-plugin' ),
+ isActive: imageAlignment === 'top',
+ onClick: () => setAttributes( { imageAlignment: 'top' } ),
+ },
+ {
+ icon: pullLeft,
+ title: __( 'Show image on left', 'newspack-plugin' ),
+ isActive: imageAlignment === 'left',
+ onClick: () => setAttributes( { imageAlignment: 'left' } ),
+ },
+ {
+ icon: pullRight,
+ title: __( 'Show image on right', 'newspack-plugin' ),
+ isActive: imageAlignment === 'right',
+ onClick: () => setAttributes( { imageAlignment: 'right' } ),
+ },
+ ],
+ [ imageAlignment, setAttributes ]
+ );
+
+ const wrapperClassName = useMemo(
+ () =>
+ classnames(
+ 'wp-block-newspack-collections',
+ `layout-${ layout }`,
+ layout === 'grid' && `columns-${ columns }`,
+ `image-${ imageAlignment }`,
+ layout === 'list' && `image-size-${ imageSize }`
+ ),
+ [ layout, columns, imageAlignment, imageSize ]
+ );
+
+ return (
+ <>
+
+
+ { layoutControls.map( control => (
+
+ ) ) }
+
+ { layout === 'list' && showFeaturedImage && (
+
+ { imageAlignmentControls.map( control => (
+
+ ) ) }
+
+ ) }
+
+
+
+
+
+ { /* Loading state */ }
+ { isLoading && (
+
}
+ label={ __( 'Loading collections…', 'newspack-plugin' ) }
+ className="collections-loading"
+ />
+ ) }
+
+ { /* No results */ }
+ { ! isLoading && ! hasCollections && (
+
+ ) }
+
+ { /* Collections display */ }
+ { ! isLoading && hasCollections && (
+ <>
+ { collections.map( collection => (
+
+ ) ) }
+ >
+ ) }
+
+ { /* See all link */ }
+ { showSeeAllLink && hasCollections && (
+
+ setAttributes( { seeAllLinkText: value } ) }
+ href="#"
+ onClick={ preventNav }
+ allowedFormats={ [] }
+ placeholder={ __( 'See all', 'newspack-plugin' ) }
+ />
+
+ ) }
+
+ >
+ );
+};
+
+export default Edit;
diff --git a/src/blocks/collections/hooks/useCollections.js b/src/blocks/collections/hooks/useCollections.js
new file mode 100644
index 0000000000..1c648c7723
--- /dev/null
+++ b/src/blocks/collections/hooks/useCollections.js
@@ -0,0 +1,73 @@
+import { useSelect } from '@wordpress/data';
+import { useMemo } from '@wordpress/element';
+
+/**
+ * Custom hook to fetch collections using WordPress core data.
+ *
+ * @param {Object} attributes Block attributes.
+ * @return {Object} Object containing collections data and loading state.
+ */
+export const useCollections = attributes => {
+ const { queryType, numberOfItems, offset = 0, selectedCollections = [], includeCategories = [], excludeCategories = [] } = attributes;
+
+ // Normalize and guard common inputs.
+ const isSpecific = queryType === 'specific';
+ const perPage = Number( numberOfItems );
+ const hasPerPage = Number.isFinite( perPage ) && perPage > 0;
+ const isDisabledQuery = ( isSpecific && selectedCollections.length === 0 ) || ! hasPerPage;
+
+ // Memoize query object so selectors aren't re-run due to new object identities.
+ const query = useMemo( () => {
+ if ( isDisabledQuery ) {
+ return null;
+ }
+
+ const q = {
+ per_page: perPage,
+ orderby: 'date',
+ order: 'desc',
+ _embed: true,
+ status: 'publish',
+ };
+
+ // Add offset for recent collections only.
+ if ( ! isSpecific && offset > 0 ) {
+ q.offset = offset;
+ }
+
+ if ( isSpecific ) {
+ // Specific collections mode ignores category filters.
+ q.include = selectedCollections;
+ } else {
+ // Category filtering for recent collections.
+ if ( includeCategories.length ) {
+ q.newspack_collection_category = includeCategories;
+ }
+ if ( excludeCategories.length ) {
+ q.newspack_collection_category_exclude = excludeCategories;
+ }
+ }
+
+ return q;
+ }, [ isDisabledQuery, isSpecific, perPage, offset, selectedCollections, includeCategories, excludeCategories ] );
+
+ return useSelect(
+ select => {
+ // If query is disabled (e.g., columns = 0 or specific with no selection), don't hit the store.
+ if ( ! query ) {
+ return { collections: [], isLoading: false, hasCollections: false };
+ }
+
+ const { getEntityRecords, isResolving } = select( 'core' );
+ const collections = getEntityRecords( 'postType', 'newspack_collection', query );
+ const isLoading = isResolving( 'getEntityRecords', [ 'postType', 'newspack_collection', query ] );
+
+ return {
+ collections: collections || [],
+ isLoading,
+ hasCollections: Array.isArray( collections ) && collections.length > 0,
+ };
+ },
+ [ query ]
+ );
+};
diff --git a/src/blocks/collections/hooks/usePreventNav.js b/src/blocks/collections/hooks/usePreventNav.js
new file mode 100644
index 0000000000..04969c78e0
--- /dev/null
+++ b/src/blocks/collections/hooks/usePreventNav.js
@@ -0,0 +1,15 @@
+import { useCallback } from '@wordpress/element';
+
+/**
+ * Hook returning a stable handler that prevents default navigation.
+ * Use for dummy anchor tags in editor preview.
+ *
+ * @return {(e: Event) => void} Stable callback preventing default.
+ */
+const usePreventNav = () => {
+ return useCallback( e => {
+ e.preventDefault();
+ }, [] );
+};
+
+export default usePreventNav;
diff --git a/src/blocks/collections/index.js b/src/blocks/collections/index.js
new file mode 100644
index 0000000000..425d3fe3ac
--- /dev/null
+++ b/src/blocks/collections/index.js
@@ -0,0 +1,44 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import Edit from './edit';
+import { collections as icon } from '../../../packages/icons';
+import colors from '../../../packages/colors/colors.module.scss';
+import './style.scss';
+
+export const title = __( 'Collections', 'newspack-plugin' );
+
+const { name } = metadata;
+
+export { metadata, name };
+
+export const settings = {
+ title,
+ icon: {
+ src: icon,
+ foreground: colors[ 'primary-400' ],
+ },
+ keywords: [
+ __( 'collections', 'newspack-plugin' ),
+ __( 'issues', 'newspack-plugin' ),
+ __( 'magazine', 'newspack-plugin' ),
+ __( 'publications', 'newspack-plugin' ),
+ __( 'content', 'newspack-plugin' ),
+ __( 'loop', 'newspack-plugin' ),
+ __( 'query', 'newspack-plugin' ),
+ __( 'latest', 'newspack-plugin' ),
+ __( 'newspack', 'newspack-plugin' ),
+ ],
+ description: __(
+ 'An advanced block that allows displaying collections based on different parameters and visual configurations.',
+ 'newspack-plugin'
+ ),
+ edit: Edit,
+ save: () => null, // Server-side rendered block.
+};
diff --git a/src/blocks/collections/index.php b/src/blocks/collections/index.php
new file mode 100644
index 0000000000..7114513c50
--- /dev/null
+++ b/src/blocks/collections/index.php
@@ -0,0 +1,11 @@
+ {
+ return apiFetch( {
+ path: addQueryArgs( ENDPOINTS.categories, {
+ search,
+ per_page: 20,
+ _fields: 'id,name,parent',
+ orderby: 'count',
+ order: 'desc',
+ } ),
+ } ).then( categories =>
+ Promise.all(
+ categories.map( category => {
+ if ( category.parent > 0 ) {
+ return apiFetch( {
+ path: addQueryArgs( `${ ENDPOINTS.categories }/${ category.parent }`, {
+ _fields: 'name',
+ } ),
+ } ).then( parentCategory => ( {
+ value: category.id,
+ label: `${ decodeEntities( category.name ) } – ${ decodeEntities( parentCategory.name ) }`,
+ } ) );
+ }
+ return Promise.resolve( {
+ value: category.id,
+ label: decodeEntities( category.name ),
+ } );
+ } )
+ )
+ );
+};
+
+export const fetchSavedCategories = categoryIDs => {
+ if ( ! categoryIDs.length ) {
+ return Promise.resolve( [] );
+ }
+
+ return apiFetch( {
+ path: addQueryArgs( ENDPOINTS.categories, {
+ per_page: 100,
+ _fields: 'id,name',
+ include: categoryIDs.join( ',' ),
+ } ),
+ } ).then( function ( categories ) {
+ const allCats = categories.map( category => ( {
+ value: category.id,
+ label: decodeEntities( category.name ),
+ } ) );
+
+ categoryIDs.forEach( catID => {
+ if ( ! allCats.find( cat => cat.value === parseInt( catID ) ) ) {
+ allCats.push( {
+ value: parseInt( catID ),
+ label: `(Deleted category - ID: ${ catID })`,
+ } );
+ }
+ } );
+
+ return allCats;
+ } );
+};
+
+export const fetchCollectionSuggestions = search => {
+ return apiFetch( {
+ path: addQueryArgs( ENDPOINTS.collections, {
+ search,
+ per_page: 20,
+ _fields: 'id,title',
+ orderby: 'title',
+ order: 'asc',
+ status: 'publish',
+ } ),
+ } ).then( collections =>
+ collections.map( collection => ( {
+ value: collection.id,
+ label: decodeEntities( collection.title.rendered ),
+ } ) )
+ );
+};
+
+export const fetchSavedCollections = collectionIDs => {
+ if ( ! collectionIDs.length ) {
+ return Promise.resolve( [] );
+ }
+
+ return apiFetch( {
+ path: addQueryArgs( ENDPOINTS.collections, {
+ per_page: 100,
+ _fields: 'id,title',
+ include: collectionIDs.join( ',' ),
+ status: 'publish',
+ } ),
+ } ).then( collections => {
+ const allCollections = collections.map( collection => ( {
+ value: collection.id,
+ label: decodeEntities( collection.title.rendered ),
+ } ) );
+
+ collectionIDs.forEach( collectionID => {
+ if ( ! allCollections.find( collection => collection.value === parseInt( collectionID ) ) ) {
+ allCollections.push( {
+ value: parseInt( collectionID ),
+ label: `(Deleted collection - ID: ${ collectionID })`,
+ } );
+ }
+ } );
+
+ return allCollections;
+ } );
+};
diff --git a/src/blocks/index.js b/src/blocks/index.js
index 50bd7a2e33..020b425f0e 100644
--- a/src/blocks/index.js
+++ b/src/blocks/index.js
@@ -12,16 +12,18 @@ import * as readerRegistration from './reader-registration';
import * as correctionBox from './correction-box';
import * as correctionItem from './correction-item';
import * as avatar from './avatar';
+import * as collections from './collections';
/**
* Block Scripts
*/
import './core-image';
-export const blocks = [ readerRegistration, correctionBox, correctionItem, avatar ];
+export const blocks = [ readerRegistration, correctionBox, correctionItem, avatar, collections ];
const readerActivationBlocks = [ 'newspack/reader-registration' ];
const correctionBlocks = [ 'newspack/correction-box', 'newspack/correction-item' ];
+const collectionsBlocks = [ 'newspack/collections' ];
/**
* Function to register an individual block.
@@ -45,6 +47,11 @@ const registerBlock = block => {
return;
}
+ /** Do not register collections blocks if Collections module is disabled. */
+ if ( collectionsBlocks.includes( name ) && ! newspack_blocks.collections_enabled ) {
+ return;
+ }
+
registerBlockType( { name, ...metadata }, settings );
};
diff --git a/src/components/src/autocomplete-tokenfield/index.js b/src/components/src/autocomplete-tokenfield/index.js
index 4acb8163ca..695238edce 100644
--- a/src/components/src/autocomplete-tokenfield/index.js
+++ b/src/components/src/autocomplete-tokenfield/index.js
@@ -207,7 +207,7 @@ class AutocompleteTokenField extends Component {
* Render.
*/
render() {
- const { help, label = '', placeholder = '', maxLength } = this.props;
+ const { help, label = '', placeholder = '', maxLength, style, __next40pxDefaultSize } = this.props;
const { suggestions, loading } = this.state;
const classNames = [ 'newspack-autocomplete-tokenfield__input-container' ];
@@ -217,7 +217,7 @@ class AutocompleteTokenField extends Component {
}
return (
-