@automattic/newspack-blocks 4.24.1 → 4.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/author-profile/view-rtl.css +1 -1
  3. package/dist/author-profile/view.asset.php +1 -1
  4. package/dist/author-profile/view.css +1 -1
  5. package/dist/blocks/author-list/block.json +2 -1
  6. package/dist/blocks/author-profile/block.json +18 -1
  7. package/dist/blocks/carousel/block.json +2 -1
  8. package/dist/blocks/donate/block.json +2 -1
  9. package/dist/blocks/homepage-articles/block.json +2 -1
  10. package/dist/blocks/iframe/block.json +2 -1
  11. package/dist/editor-rtl.css +2 -2
  12. package/dist/editor.asset.php +1 -1
  13. package/dist/editor.css +2 -2
  14. package/dist/editor.js +23 -19
  15. package/dist/placeholder_blocks.asset.php +1 -1
  16. package/dist/placeholder_blocks.js +1 -1
  17. package/includes/class-modal-checkout.php +43 -11
  18. package/includes/class-newspack-blocks-caching.php +13 -1
  19. package/includes/class-newspack-blocks.php +14 -7
  20. package/languages/newspack-blocks-de_DE-2d52b39fdbc5d6c94b3514803f3720b8.json +1 -1
  21. package/languages/newspack-blocks-de_DE-34e5c64f90b1444f3fc735376442eada.json +1 -1
  22. package/languages/newspack-blocks-de_DE-4fdea541976076f02d56139fb35e5b42.json +1 -1
  23. package/languages/newspack-blocks-de_DE-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  24. package/languages/newspack-blocks-de_DE-78456b164809d080adecb4d2b3895802.json +1 -1
  25. package/languages/newspack-blocks-de_DE-9ef9b2c60c897ad79f92951e6e9949a1.json +1 -1
  26. package/languages/newspack-blocks-de_DE-eccbc51a43c04f59165364eda71e0be7.json +1 -1
  27. package/languages/newspack-blocks-de_DE-fbe7f8c598cf05d4603ba49fec909ded.json +1 -1
  28. package/languages/newspack-blocks-de_DE.po +594 -493
  29. package/languages/newspack-blocks-es_ES-2d52b39fdbc5d6c94b3514803f3720b8.json +1 -1
  30. package/languages/newspack-blocks-es_ES-34e5c64f90b1444f3fc735376442eada.json +1 -1
  31. package/languages/newspack-blocks-es_ES-4fdea541976076f02d56139fb35e5b42.json +1 -1
  32. package/languages/newspack-blocks-es_ES-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  33. package/languages/newspack-blocks-es_ES-78456b164809d080adecb4d2b3895802.json +1 -1
  34. package/languages/newspack-blocks-es_ES-9ef9b2c60c897ad79f92951e6e9949a1.json +1 -1
  35. package/languages/newspack-blocks-es_ES-eccbc51a43c04f59165364eda71e0be7.json +1 -1
  36. package/languages/newspack-blocks-es_ES-fbe7f8c598cf05d4603ba49fec909ded.json +1 -1
  37. package/languages/newspack-blocks-es_ES.po +594 -493
  38. package/languages/newspack-blocks-fr_BE-2d52b39fdbc5d6c94b3514803f3720b8.json +1 -1
  39. package/languages/newspack-blocks-fr_BE-34e5c64f90b1444f3fc735376442eada.json +1 -1
  40. package/languages/newspack-blocks-fr_BE-4fdea541976076f02d56139fb35e5b42.json +1 -1
  41. package/languages/newspack-blocks-fr_BE-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  42. package/languages/newspack-blocks-fr_BE-78456b164809d080adecb4d2b3895802.json +1 -1
  43. package/languages/newspack-blocks-fr_BE-9ef9b2c60c897ad79f92951e6e9949a1.json +1 -1
  44. package/languages/newspack-blocks-fr_BE-eccbc51a43c04f59165364eda71e0be7.json +1 -1
  45. package/languages/newspack-blocks-fr_BE-fbe7f8c598cf05d4603ba49fec909ded.json +1 -1
  46. package/languages/newspack-blocks-fr_BE.po +594 -493
  47. package/languages/newspack-blocks-nb_NO-2d52b39fdbc5d6c94b3514803f3720b8.json +1 -1
  48. package/languages/newspack-blocks-nb_NO-34e5c64f90b1444f3fc735376442eada.json +1 -1
  49. package/languages/newspack-blocks-nb_NO-4fdea541976076f02d56139fb35e5b42.json +1 -1
  50. package/languages/newspack-blocks-nb_NO-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  51. package/languages/newspack-blocks-nb_NO-78456b164809d080adecb4d2b3895802.json +1 -1
  52. package/languages/newspack-blocks-nb_NO-9ef9b2c60c897ad79f92951e6e9949a1.json +1 -1
  53. package/languages/newspack-blocks-nb_NO-eccbc51a43c04f59165364eda71e0be7.json +1 -1
  54. package/languages/newspack-blocks-nb_NO-fbe7f8c598cf05d4603ba49fec909ded.json +1 -1
  55. package/languages/newspack-blocks-nb_NO.po +594 -493
  56. package/languages/newspack-blocks-pt_PT-2d52b39fdbc5d6c94b3514803f3720b8.json +1 -1
  57. package/languages/newspack-blocks-pt_PT-34e5c64f90b1444f3fc735376442eada.json +1 -1
  58. package/languages/newspack-blocks-pt_PT-4fdea541976076f02d56139fb35e5b42.json +1 -1
  59. package/languages/newspack-blocks-pt_PT-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  60. package/languages/newspack-blocks-pt_PT-78456b164809d080adecb4d2b3895802.json +1 -1
  61. package/languages/newspack-blocks-pt_PT-9ef9b2c60c897ad79f92951e6e9949a1.json +1 -1
  62. package/languages/newspack-blocks-pt_PT-eccbc51a43c04f59165364eda71e0be7.json +1 -1
  63. package/languages/newspack-blocks-pt_PT-fbe7f8c598cf05d4603ba49fec909ded.json +1 -1
  64. package/languages/newspack-blocks-pt_PT.po +594 -493
  65. package/languages/newspack-blocks.pot +806 -671
  66. package/newspack-blocks.php +5 -5
  67. package/package.json +1 -1
  68. package/src/blocks/author-list/block.json +3 -2
  69. package/src/blocks/author-list/edit.js +15 -8
  70. package/src/blocks/author-list/index.js +4 -3
  71. package/src/blocks/author-list/view.php +1 -0
  72. package/src/blocks/author-profile/block.json +16 -2
  73. package/src/blocks/author-profile/class-wp-rest-newspack-authors-controller.php +180 -9
  74. package/src/blocks/author-profile/context.js +26 -0
  75. package/src/blocks/author-profile/edit.js +847 -84
  76. package/src/blocks/author-profile/editor.scss +18 -14
  77. package/src/blocks/author-profile/index.js +14 -9
  78. package/src/blocks/author-profile/single-author.js +6 -2
  79. package/src/blocks/author-profile/view.php +386 -15
  80. package/src/blocks/author-profile/view.scss +48 -19
  81. package/src/blocks/carousel/block.json +2 -1
  82. package/src/blocks/carousel/edit.js +120 -117
  83. package/src/blocks/carousel/index.js +2 -1
  84. package/src/blocks/carousel/view.php +1 -0
  85. package/src/blocks/donate/block.json +3 -2
  86. package/src/blocks/donate/edit/FrequencyBasedLayout.tsx +2 -3
  87. package/src/blocks/donate/edit/index.tsx +56 -39
  88. package/src/blocks/donate/index.js +2 -1
  89. package/src/blocks/donate/view.php +1 -0
  90. package/src/blocks/homepage-articles/block.json +2 -1
  91. package/src/blocks/homepage-articles/edit.tsx +42 -37
  92. package/src/blocks/homepage-articles/index.js +2 -1
  93. package/src/blocks/homepage-articles/view.php +13 -1
  94. package/src/blocks/iframe/block.json +3 -2
  95. package/src/blocks/iframe/edit.js +36 -34
  96. package/src/blocks/iframe/index.js +2 -1
  97. package/src/blocks/iframe/view.php +1 -0
  98. package/src/blocks/video-playlist/edit.js +22 -15
  99. package/src/blocks/video-playlist/index.js +1 -0
  100. package/src/blocks/video-playlist/view.php +1 -0
  101. package/src/setup/placeholder-blocks.js +1 -0
  102. package/vendor/composer/installed.php +2 -2
@@ -2,32 +2,102 @@
2
2
  * WordPress dependencies
3
3
  */
4
4
  import apiFetch from '@wordpress/api-fetch';
5
- import { BlockControls, InspectorControls } from '@wordpress/block-editor';
5
+ import { BlockControls, InnerBlocks, InspectorControls, useBlockProps } from '@wordpress/block-editor';
6
+ import { createBlocksFromInnerBlocksTemplate, getBlockType, registerBlockBindingsSource } from '@wordpress/blocks';
6
7
  import {
8
+ Button,
9
+ Card,
10
+ CardBody,
7
11
  Notice,
8
12
  PanelBody,
9
13
  Placeholder,
14
+ SelectControl,
10
15
  Spinner,
11
16
  ToggleControl,
12
17
  Toolbar,
18
+ ToolbarButton,
19
+ ToolbarGroup,
20
+ Tooltip,
13
21
  // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
14
22
  __experimentalUnitControl as UnitControl,
15
23
  // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
16
24
  __experimentalToggleGroupControl as ToggleGroupControl,
17
25
  // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
18
26
  __experimentalToggleGroupControlOption as ToggleGroupControlOption,
27
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
28
+ __experimentalVStack as VStack,
19
29
  } from '@wordpress/components';
20
- import { useEffect, useState } from '@wordpress/element';
30
+ import { store as coreStore } from '@wordpress/core-data';
31
+ import { useEffect, useState, useMemo } from '@wordpress/element';
32
+ import { useDispatch, useSelect } from '@wordpress/data';
21
33
  import { decodeEntities } from '@wordpress/html-entities';
22
34
  import { pencil, postAuthor, pullLeft, pullRight } from '@wordpress/icons';
23
35
  import { __, sprintf } from '@wordpress/i18n';
24
36
  import { addQueryArgs } from '@wordpress/url';
25
37
 
38
+ /**
39
+ * Register block bindings source for author data in the editor.
40
+ * This enables core blocks to display author data via bindings.
41
+ *
42
+ * Note: The binding reads from a global author object that is set by the
43
+ * AuthorContext.Provider. This is a workaround since bindings don't have
44
+ * direct access to React context.
45
+ */
46
+ // Per-instance author map for block bindings.
47
+ // Each Author Profile block registers its author here keyed by clientId,
48
+ // so multiple instances on the same page don't overwrite each other.
49
+ window.__newspackAuthorsByBlock = window.__newspackAuthorsByBlock || {};
50
+
51
+ if ( typeof registerBlockBindingsSource === 'function' ) {
52
+ registerBlockBindingsSource( {
53
+ name: 'newspack-blocks/author',
54
+ label: __( 'Author Profile', 'newspack-blocks' ),
55
+ getValues( { bindings, clientId, select } ) {
56
+ // Find the parent Author Profile block and look up its author.
57
+ const parents = select( 'core/block-editor' ).getBlockParents( clientId );
58
+ const authorMap = window.__newspackAuthorsByBlock;
59
+ const parentId = parents.find( id => authorMap[ id ] );
60
+ const author = ( parentId && authorMap[ parentId ] ) || {};
61
+ // Return empty for missing fields so WordPress core shows each block's
62
+ // own `placeholder` attribute in its native greyed-out style.
63
+ // Skip placeholder authors entirely (Site Editor template context).
64
+ const isPlaceholder = author.id === 'placeholder';
65
+ return Object.fromEntries(
66
+ Object.entries( bindings ).map( ( [ attribute, { args } ] ) => {
67
+ const key = args?.key;
68
+ if ( ! key || isPlaceholder ) {
69
+ return [ attribute, '' ];
70
+ }
71
+ // Handle special cases.
72
+ if ( key === 'url' || key === 'archive_url' ) {
73
+ return [ attribute, author.url || '' ];
74
+ }
75
+ // "More by Author Name" link text.
76
+ if ( key === 'archive_link_text' ) {
77
+ const linkText = author.name
78
+ ? sprintf(
79
+ /* translators: %s: author name */
80
+ __( 'More by %s', 'newspack-blocks' ),
81
+ author.name
82
+ )
83
+ : '';
84
+ // Return HTML with link tag for editor preview.
85
+ const linkUrl = author.url || '#';
86
+ return [ attribute, linkText ? `<a href="${ linkUrl }" class="no-op">${ linkText }</a>` : '' ];
87
+ }
88
+ return [ attribute, author[ key ] || '' ];
89
+ } )
90
+ );
91
+ },
92
+ } );
93
+ }
94
+
26
95
  /**
27
96
  * Internal dependencies
28
97
  */
29
98
  import { SingleAuthor } from './single-author';
30
99
  import { AuthorDisplaySettings } from '../shared/author';
100
+ import { AuthorContext } from './context';
31
101
 
32
102
  /**
33
103
  * External dependencies
@@ -102,15 +172,212 @@ export const avatarSizeOptions = [
102
172
  },
103
173
  ];
104
174
 
105
- const AuthorProfile = ( { attributes, setAttributes } ) => {
175
+ // Helper to create a bound paragraph block with custom list view name.
176
+ // If wrapInLink is true, the content will be wrapped in an anchor tag for editor preview.
177
+ const createBoundParagraph = ( key, className, name, placeholder, wrapInLink = false ) => {
178
+ const attributes = {
179
+ metadata: {
180
+ name, // Custom name shown in list view.
181
+ bindings: {
182
+ content: {
183
+ source: 'newspack-blocks/author',
184
+ args: { key },
185
+ },
186
+ },
187
+ },
188
+ className,
189
+ placeholder: placeholder || name,
190
+ };
191
+
192
+ // If wrapInLink is true, set initial content with link wrapper for editor preview.
193
+ if ( wrapInLink ) {
194
+ const linkText = placeholder || name;
195
+ attributes.content = `<a href="#" class="no-op">${ linkText }</a>`;
196
+ }
197
+
198
+ return [ 'core/paragraph', attributes ];
199
+ };
200
+
201
+ // Template for nested inner blocks.
202
+ // Each author field is a separate block that can be reordered or removed.
203
+ // Block bindings connect core block attributes to author data via 'newspack-blocks/author' source.
204
+ const NESTED_TEMPLATE = [
205
+ [
206
+ 'core/columns',
207
+ { isStackedOnMobile: true, className: 'author-profile-columns', templateLock: 'insert' },
208
+ [
209
+ [
210
+ 'core/column',
211
+ {
212
+ className: 'author-profile-avatar-column',
213
+ templateLock: 'insert',
214
+ allowedBlocks: [ 'newspack/avatar' ],
215
+ },
216
+ [ [ 'newspack/avatar', { size: 128 } ] ],
217
+ ],
218
+ [
219
+ 'core/column',
220
+ {
221
+ className: 'author-profile-content-column',
222
+ templateLock: false,
223
+ allowedBlocks: [ 'core/heading', 'core/paragraph', 'core/separator', 'core/spacer', 'newspack/author-profile-social' ],
224
+ style: {
225
+ spacing: {
226
+ blockGap: 'var:preset|spacing|20',
227
+ },
228
+ elements: {
229
+ link: {
230
+ color: {
231
+ text: 'var:preset|color|contrast-3',
232
+ },
233
+ },
234
+ },
235
+ },
236
+ textColor: 'contrast-3',
237
+ fontSize: 'small',
238
+ },
239
+ [
240
+ [
241
+ 'core/heading',
242
+ {
243
+ level: 3,
244
+ metadata: {
245
+ name: __( 'Author Name', 'newspack-blocks' ),
246
+ bindings: {
247
+ content: {
248
+ source: 'newspack-blocks/author',
249
+ args: { key: 'name' },
250
+ },
251
+ },
252
+ },
253
+ className: 'author-name',
254
+ placeholder: __( 'Author Name', 'newspack-blocks' ),
255
+ textColor: 'contrast',
256
+ fontSize: 'large',
257
+ },
258
+ ],
259
+ [
260
+ 'core/paragraph',
261
+ {
262
+ metadata: {
263
+ name: __( 'Job Title', 'newspack-blocks' ),
264
+ bindings: {
265
+ content: {
266
+ source: 'newspack-blocks/author',
267
+ args: { key: 'newspack_job_title' },
268
+ },
269
+ },
270
+ },
271
+ className: 'author-job-title',
272
+ placeholder: __( 'Job Title', 'newspack-blocks' ),
273
+ style: {
274
+ typography: {
275
+ fontStyle: 'normal',
276
+ fontWeight: '600',
277
+ },
278
+ elements: {
279
+ link: {
280
+ color: {
281
+ text: 'var:preset|color|contrast',
282
+ },
283
+ },
284
+ },
285
+ },
286
+ textColor: 'contrast',
287
+ },
288
+ ],
289
+ createBoundParagraph( 'newspack_role', 'author-role', __( 'Role', 'newspack-blocks' ) ),
290
+ createBoundParagraph( 'newspack_employer', 'author-employer', __( 'Employer', 'newspack-blocks' ) ),
291
+ createBoundParagraph( 'bio', 'author-bio', __( 'Biography', 'newspack-blocks' ) ),
292
+ createBoundParagraph(
293
+ 'archive_link_text',
294
+ 'author-archive-link',
295
+ sprintf(
296
+ /* translators: %s: author name. */
297
+ __( 'More by %s', 'newspack-blocks' ),
298
+ __( 'Author Name', 'newspack-blocks' )
299
+ ),
300
+ undefined,
301
+ true
302
+ ),
303
+ [
304
+ 'newspack/author-profile-social',
305
+ {
306
+ style: {
307
+ spacing: {
308
+ padding: {
309
+ top: 'var:preset|spacing|20',
310
+ },
311
+ },
312
+ },
313
+ },
314
+ ],
315
+ ],
316
+ ],
317
+ ],
318
+ ],
319
+ ];
320
+
321
+ // Module-level cache for social icon SVGs so multiple block instances share one fetch.
322
+ let socialIconSvgsCache = null;
323
+ const fetchSocialIconSvgs = () => {
324
+ if ( ! socialIconSvgsCache ) {
325
+ socialIconSvgsCache = apiFetch( { path: '/newspack/v1/social-icons' } ).catch( () => ( {} ) );
326
+ }
327
+ return socialIconSvgsCache;
328
+ };
329
+
330
+ // Placeholder author for Site Editor template context.
331
+ // Builds the social entries from the SVG map so every supported service gets an
332
+ // inner block in the template. Publishers can then remove the ones they don't need.
333
+ const DEFAULT_PLACEHOLDER_AUTHOR = Object.freeze( {
334
+ id: 'placeholder',
335
+ url: '#',
336
+ avatar: '', // Empty triggers the avatar block's built-in placeholder rendering.
337
+ social: Object.freeze( {} ),
338
+ email: Object.freeze( { url: 'mailto:placeholder@example.com', svg: '' } ),
339
+ newspack_phone_number: Object.freeze( { url: 'tel:0000000000', svg: '' } ),
340
+ } );
341
+
342
+ const getPlaceholderAuthor = ( socialIconSvgs = {} ) => {
343
+ const hasSocialSvgs = Object.keys( socialIconSvgs ).length > 0;
344
+ if ( ! hasSocialSvgs ) {
345
+ return DEFAULT_PLACEHOLDER_AUTHOR;
346
+ }
347
+
348
+ const social = Object.fromEntries(
349
+ Object.entries( socialIconSvgs )
350
+ .filter( ( [ key ] ) => ! [ 'email', 'phone' ].includes( key ) ) // Exclude top-level properties on the author object.
351
+ .map( ( [ service, svg ] ) => [ service, { url: '#', svg: svg || '' } ] )
352
+ );
353
+
354
+ return {
355
+ ...DEFAULT_PLACEHOLDER_AUTHOR,
356
+ social,
357
+ email: { url: 'mailto:placeholder@example.com', svg: socialIconSvgs.email || '' },
358
+ newspack_phone_number: { url: 'tel:0000000000', svg: socialIconSvgs.phone || '' },
359
+ };
360
+ };
361
+
362
+ const AuthorProfile = ( { attributes, setAttributes, context, clientId } ) => {
363
+ const { replaceInnerBlocks } = useDispatch( 'core/block-editor' );
364
+
365
+ // ALL HOOKS MUST BE CALLED UNCONDITIONALLY (React rules of hooks)
106
366
  const [ author, setAuthor ] = useState( null );
367
+ const [ contextualAuthors, setContextualAuthors ] = useState( [] );
107
368
  const [ suggestions, setSuggestions ] = useState( null );
108
369
  const [ error, setError ] = useState( null );
109
370
  const [ isLoading, setIsLoading ] = useState( false );
110
371
  const [ maxItemsToSuggest, setMaxItemsToSuggest ] = useState( 0 );
372
+ const [ showSpecificSelector, setShowSpecificSelector ] = useState( false );
373
+ const [ previewAuthorIndex, setPreviewAuthorIndex ] = useState( 0 );
374
+ const [ socialIconSvgs, setSocialIconSvgs ] = useState( {} );
375
+
111
376
  const {
112
377
  authorId,
113
378
  isGuestAuthor,
379
+ isContextual,
380
+ layoutVersion,
114
381
  showSocial,
115
382
  showEmail,
116
383
  textSize,
@@ -119,13 +386,94 @@ const AuthorProfile = ( { attributes, setAttributes } ) => {
119
386
  avatarBorderRadius,
120
387
  avatarSize,
121
388
  avatarHideDefault,
389
+ showEmptyBio,
122
390
  } = attributes;
391
+ const blockProps = useBlockProps();
392
+
393
+ // Get post ID from block context or editor
394
+ const editorPostId = useSelect( select => select( 'core/editor' )?.getCurrentPostId?.(), [] );
395
+ const postId = context?.postId || editorPostId;
396
+
397
+ // Check if custom byline is active and extract referenced author IDs.
398
+ // Returns IDs as a comma-separated string to avoid new array references on each render.
399
+ const { customBylineActive, bylineAuthorIdsStr } = useSelect(
400
+ select => {
401
+ if ( ! isContextual ) {
402
+ return { customBylineActive: false, bylineAuthorIdsStr: '' };
403
+ }
404
+ const meta = select( 'core/editor' )?.getEditedPostAttribute?.( 'meta' );
405
+ const isActive = meta?._newspack_byline_active ?? false;
406
+ if ( ! isActive ) {
407
+ return { customBylineActive: false, bylineAuthorIdsStr: '' };
408
+ }
409
+ const byline = meta?._newspack_byline ?? '';
410
+ const ids = [ ...byline.matchAll( /\[Author\s+id\s*=\s*(\d+)\]/gi ) ].map( m => m[ 1 ] );
411
+ return { customBylineActive: true, bylineAuthorIdsStr: ids.join( ',' ) };
412
+ },
413
+ [ isContextual ]
414
+ );
415
+ const bylineAuthorIds = bylineAuthorIdsStr ? bylineAuthorIdsStr.split( ',' ).map( Number ) : [];
416
+
417
+ // Detect Site Editor template context where real author data is not meaningful.
418
+ const isTemplateLikeContext = useSelect( select => {
419
+ const postType = select( 'core/editor' )?.getCurrentPostType?.();
420
+ return postType === 'wp_template' || postType === 'wp_template_part';
421
+ }, [] );
422
+
423
+ // Fetch social icon SVGs for the placeholder in template context.
424
+ useEffect( () => {
425
+ if ( ! isTemplateLikeContext ) {
426
+ return;
427
+ }
428
+ fetchSocialIconSvgs().then( setSocialIconSvgs );
429
+ }, [ isTemplateLikeContext ] );
430
+
431
+ // Nested inner blocks mode is enabled automatically in block themes when
432
+ // Newspack Plugin is active (provides the avatar and social links blocks).
433
+ const isNestedMode = useSelect( select => {
434
+ const theme = select( coreStore ).getCurrentTheme();
435
+ return ( theme?.is_block_theme ?? false ) && !! getBlockType( 'newspack/avatar' ) && !! getBlockType( 'newspack/author-profile-social' );
436
+ }, [] );
123
437
 
438
+ // Set layoutVersion to 2 for brand new blocks in block themes.
439
+ // This persists the mode choice and enables InnerBlocks-based layout.
440
+ // Only converts unconfigured blocks to preserve existing blocks created in classic themes.
441
+ const isUnconfiguredBlock = authorId === 0 && ! isContextual;
124
442
  useEffect( () => {
125
- if ( 0 !== authorId ) {
126
- getAuthorById();
443
+ if ( isNestedMode && layoutVersion === 1 && isUnconfiguredBlock ) {
444
+ setAttributes( { layoutVersion: 2 } );
127
445
  }
128
- }, [ authorId, avatarHideDefault, isGuestAuthor ] );
446
+ }, [ isNestedMode, layoutVersion, isUnconfiguredBlock, setAttributes ] );
447
+
448
+ // Fetch author for specific mode
449
+ useEffect( () => {
450
+ if ( isContextual || 0 === authorId ) {
451
+ return;
452
+ }
453
+ getAuthorById();
454
+ }, [ authorId, avatarHideDefault, isGuestAuthor, isContextual ] );
455
+
456
+ // Fetch authors for contextual mode
457
+ useEffect( () => {
458
+ if ( ! isContextual || isTemplateLikeContext ) {
459
+ setContextualAuthors( [] );
460
+ return;
461
+ }
462
+ // When custom byline is active, fetch the specific byline authors.
463
+ if ( customBylineActive ) {
464
+ if ( bylineAuthorIds.length ) {
465
+ getBylineAuthors();
466
+ } else {
467
+ setContextualAuthors( [] );
468
+ }
469
+ return;
470
+ }
471
+ if ( ! postId ) {
472
+ setContextualAuthors( [] );
473
+ return;
474
+ }
475
+ getContextualAuthors();
476
+ }, [ isContextual, postId, avatarHideDefault, showEmail, customBylineActive, bylineAuthorIdsStr, isTemplateLikeContext ] );
129
477
 
130
478
  const getAuthorById = async () => {
131
479
  setError( null );
@@ -169,20 +517,142 @@ const AuthorProfile = ( { attributes, setAttributes } ) => {
169
517
  setIsLoading( false );
170
518
  };
171
519
 
520
+ const getContextualAuthors = async () => {
521
+ setError( null );
522
+ setIsLoading( true );
523
+ try {
524
+ // Only fetch email if showEmail is enabled (privacy consideration)
525
+ const fields = [ 'id', 'name', 'bio', 'social', 'avatar', 'url' ];
526
+ if ( showEmail ) {
527
+ fields.push( 'email' );
528
+ }
529
+
530
+ const params = {
531
+ post_id: postId,
532
+ fields: fields.join( ',' ),
533
+ };
534
+
535
+ if ( avatarHideDefault ) {
536
+ params.avatar_hide_default = 1;
537
+ }
538
+
539
+ const response = await apiFetch( {
540
+ path: addQueryArgs( '/newspack-blocks/v1/authors', params ),
541
+ } );
542
+
543
+ setContextualAuthors( response || [] );
544
+ } catch ( e ) {
545
+ setError( e.message || e || __( 'Error fetching authors for this post.', 'newspack-blocks' ) );
546
+ setContextualAuthors( [] );
547
+ }
548
+ setIsLoading( false );
549
+ };
550
+
551
+ const getBylineAuthors = async () => {
552
+ setError( null );
553
+ setIsLoading( true );
554
+ try {
555
+ const fields = [ 'id', 'name', 'bio', 'social', 'avatar', 'url' ];
556
+ if ( showEmail ) {
557
+ fields.push( 'email' );
558
+ }
559
+ const results = await Promise.all(
560
+ bylineAuthorIds.map( id => {
561
+ const params = {
562
+ author_id: id,
563
+ is_guest_author: 0,
564
+ fields: fields.join( ',' ),
565
+ };
566
+ if ( avatarHideDefault ) {
567
+ params.avatar_hide_default = 1;
568
+ }
569
+ return apiFetch( {
570
+ path: addQueryArgs( '/newspack-blocks/v1/authors', params ),
571
+ } );
572
+ } )
573
+ );
574
+ setContextualAuthors( results.flat().filter( Boolean ) );
575
+ } catch ( e ) {
576
+ setError( e.message || e || __( 'Error fetching byline authors.', 'newspack-blocks' ) );
577
+ setContextualAuthors( [] );
578
+ }
579
+ setIsLoading( false );
580
+ };
581
+
582
+ // Memoize authors for rendering based on mode
583
+ const authorsToRender = useMemo( () => {
584
+ let authors;
585
+ if ( isContextual ) {
586
+ if ( isTemplateLikeContext ) {
587
+ return [ getPlaceholderAuthor( socialIconSvgs ) ];
588
+ }
589
+ authors = contextualAuthors;
590
+ } else {
591
+ authors = author ? [ author ] : [];
592
+ }
593
+ if ( ! showEmptyBio ) {
594
+ authors = authors.filter( a => a.bio );
595
+ }
596
+ return authors;
597
+ }, [ isContextual, showEmptyBio, isTemplateLikeContext, socialIconSvgs, contextualAuthors, author ] );
598
+
599
+ // Reset preview index when authors list changes (e.g., switching posts)
600
+ useEffect( () => {
601
+ setPreviewAuthorIndex( 0 );
602
+ }, [ authorsToRender.length ] );
603
+
604
+ // Register author in the per-instance map for block bindings (nested mode only).
605
+ // Each Author Profile block stores its author keyed by clientId, so bindings
606
+ // in child blocks can look up the correct author via getBlockParents().
607
+ useEffect( () => {
608
+ if ( layoutVersion !== 2 ) {
609
+ return;
610
+ }
611
+ const safeIndex = Math.min( previewAuthorIndex, Math.max( 0, authorsToRender.length - 1 ) );
612
+ const previewAuthor = authorsToRender[ safeIndex ] || null;
613
+ window.__newspackAuthorsByBlock[ clientId ] = previewAuthor;
614
+ return () => {
615
+ delete window.__newspackAuthorsByBlock[ clientId ];
616
+ };
617
+ }, [ authorsToRender, previewAuthorIndex, layoutVersion, clientId ] );
618
+
172
619
  // Combine social links and email, which are shown together.
173
- const socialLinks = ( showSocial && author && author.social ) || {};
174
- if ( showEmail && author && author.email ) {
175
- socialLinks.email = author.email;
176
- } else {
177
- delete socialLinks.email;
178
- }
620
+ const getSocialLinks = authorData => {
621
+ const socialLinks = ( showSocial && authorData?.social ) || {};
622
+ if ( showEmail && authorData?.email ) {
623
+ socialLinks.email = authorData.email;
624
+ } else {
625
+ delete socialLinks.email;
626
+ }
627
+ return socialLinks;
628
+ };
179
629
 
180
- return (
181
- <>
182
- <InspectorControls>
183
- <PanelBody title={ __( 'Author Profile Settings', 'newspack-blocks' ) }>
630
+ // Determine if we're in nested layout mode (publisher-controlled composition).
631
+ // In nested mode, hide field toggles since publishers control display by adding/removing blocks.
632
+ const isNestedLayout = layoutVersion === 2;
633
+
634
+ const resetLayout = () => {
635
+ replaceInnerBlocks( clientId, createBlocksFromInnerBlocksTemplate( NESTED_TEMPLATE ), false );
636
+ };
637
+
638
+ // Inspector controls for display settings
639
+ const inspectorControls = (
640
+ <InspectorControls>
641
+ { isNestedLayout && (
642
+ <PanelBody title={ __( 'Display', 'newspack-blocks' ) }>
643
+ <ToggleControl
644
+ label={ __( 'Show authors without bio', 'newspack-blocks' ) }
645
+ help={ __( 'Display author profiles even if their bio is empty.', 'newspack-blocks' ) }
646
+ checked={ showEmptyBio }
647
+ onChange={ () => setAttributes( { showEmptyBio: ! showEmptyBio } ) }
648
+ __nextHasNoMarginBottom
649
+ />
650
+ </PanelBody>
651
+ ) }
652
+ { ! isNestedLayout && (
653
+ <PanelBody title={ __( 'Settings', 'newspack-blocks' ) }>
184
654
  <ToggleGroupControl
185
- label={ __( 'Text Size', 'newspack-blocks' ) }
655
+ label={ __( 'Text size', 'newspack-blocks' ) }
186
656
  value={ textSize }
187
657
  onChange={ value => setAttributes( { textSize: value } ) }
188
658
  isBlock
@@ -194,17 +664,22 @@ const AuthorProfile = ( { attributes, setAttributes } ) => {
194
664
  </ToggleGroupControl>
195
665
  <AuthorDisplaySettings attributes={ attributes } setAttributes={ setAttributes } />
196
666
  </PanelBody>
667
+ ) }
668
+ { /* In nested mode, avatar is controlled via the inner newspack/avatar block */ }
669
+ { ! isNestedLayout && (
197
670
  <PanelBody title={ __( 'Avatar', 'newspack-blocks' ) }>
198
671
  <ToggleControl
199
672
  label={ __( 'Display avatar', 'newspack-blocks' ) }
200
673
  checked={ showAvatar }
201
674
  onChange={ () => setAttributes( { showAvatar: ! showAvatar } ) }
675
+ __nextHasNoMarginBottom
202
676
  />
203
677
  { showAvatar && (
204
678
  <ToggleControl
205
679
  label={ __( 'Hide default avatar', 'newspack-blocks' ) }
206
680
  checked={ avatarHideDefault }
207
681
  onChange={ () => setAttributes( { avatarHideDefault: ! avatarHideDefault } ) }
682
+ __nextHasNoMarginBottom
208
683
  />
209
684
  ) }
210
685
  { showAvatar && (
@@ -234,69 +709,153 @@ const AuthorProfile = ( { attributes, setAttributes } ) => {
234
709
  />
235
710
  ) }
236
711
  </PanelBody>
237
- </InspectorControls>
238
- { author && (
239
- <BlockControls>
240
- { showAvatar && ! attributes.className?.includes( 'is-style-center' ) && (
241
- <Toolbar
242
- controls={ [
243
- {
244
- icon: pullLeft,
245
- title: __( 'Show avatar on left', 'newspack-blocks' ),
246
- isActive: avatarAlignment === 'left',
247
- onClick: () => setAttributes( { avatarAlignment: 'left' } ),
248
- },
249
- {
250
- icon: pullRight,
251
- title: __( 'Show avatar on right', 'newspack-blocks' ),
252
- isActive: avatarAlignment === 'right',
253
- onClick: () => setAttributes( { avatarAlignment: 'right' } ),
254
- },
255
- ] }
256
- />
257
- ) }
258
- <Toolbar
259
- controls={ [
260
- {
261
- icon: pencil,
262
- title: __( 'Edit selection', 'newspack-blocks' ),
263
- onClick: () => {
264
- setAttributes( { authorId: 0 } );
265
- setAuthor( null );
266
- },
712
+ ) }
713
+ </InspectorControls>
714
+ );
715
+
716
+ // Loading placeholder shared between nested and flat mode.
717
+ const loadingPlaceholder = (
718
+ <div { ...blockProps }>
719
+ { inspectorControls }
720
+ <Placeholder className="newspack-blocks-author-profile" icon={ postAuthor } label={ __( 'Author Profile', 'newspack-blocks' ) }>
721
+ <VStack alignment="center" style={ { width: '100%' } }>
722
+ <Spinner style={ { margin: '0' } } />
723
+ <span style={ { fontWeight: '500' } }>{ __( 'Fetching authors…', 'newspack-blocks' ) }</span>
724
+ </VStack>
725
+ </Placeholder>
726
+ </div>
727
+ );
728
+
729
+ // Block controls for avatar alignment and edit button
730
+ const blockControls = authorsToRender.length > 0 && (
731
+ <BlockControls>
732
+ { ! isNestedLayout && showAvatar && ! attributes.className?.includes( 'is-style-center' ) && (
733
+ <Toolbar
734
+ controls={ [
735
+ {
736
+ icon: pullLeft,
737
+ title: __( 'Show avatar on left', 'newspack-blocks' ),
738
+ isActive: avatarAlignment === 'left',
739
+ onClick: () => setAttributes( { avatarAlignment: 'left' } ),
740
+ },
741
+ {
742
+ icon: pullRight,
743
+ title: __( 'Show avatar on right', 'newspack-blocks' ),
744
+ isActive: avatarAlignment === 'right',
745
+ onClick: () => setAttributes( { avatarAlignment: 'right' } ),
746
+ },
747
+ ] }
748
+ />
749
+ ) }
750
+ { ! isContextual && (
751
+ <Toolbar
752
+ controls={ [
753
+ {
754
+ icon: pencil,
755
+ title: __( 'Edit selection', 'newspack-blocks' ),
756
+ onClick: () => {
757
+ setAttributes( { authorId: 0 } );
758
+ setAuthor( null );
267
759
  },
268
- ] }
269
- />
270
- </BlockControls>
760
+ },
761
+ ] }
762
+ />
763
+ ) }
764
+ { isNestedLayout && (
765
+ <ToolbarGroup>
766
+ <Tooltip text={ __( 'Reset layout', 'newspack-blocks' ) }>
767
+ <ToolbarButton label={ __( 'Reset layout', 'newspack-blocks' ) } onClick={ resetLayout }>
768
+ { __( 'Reset', 'newspack-blocks' ) }
769
+ </ToolbarButton>
770
+ </Tooltip>
771
+ </ToolbarGroup>
271
772
  ) }
272
- { author ? (
273
- <SingleAuthor author={ author } attributes={ attributes } />
274
- ) : (
275
- <Placeholder className="newspack-blocks-author-profile" icon={ postAuthor } label={ __( 'Author Profile', 'newspack-blocks' ) }>
276
- { error && (
277
- <Notice status="error" isDismissible={ false }>
278
- { error }
773
+ </BlockControls>
774
+ );
775
+
776
+ // Mode selection placeholder for new blocks (shared by nested and flat modes).
777
+ const modeSelectionPlaceholder = (
778
+ <div { ...blockProps }>
779
+ { inspectorControls }
780
+ <Placeholder
781
+ className="newspack-blocks-author-profile"
782
+ icon={ postAuthor }
783
+ label={ __( 'Author Profile', 'newspack-blocks' ) }
784
+ instructions={ __( 'Select a type to start with.', 'newspack-blocks' ) }
785
+ >
786
+ <Button variant="primary" onClick={ () => setAttributes( { isContextual: true } ) }>
787
+ { __( 'Contextual', 'newspack-blocks' ) }
788
+ </Button>
789
+ <Button variant="secondary" onClick={ () => setShowSpecificSelector( true ) }>
790
+ { __( 'Specific', 'newspack-blocks' ) }
791
+ </Button>
792
+ </Placeholder>
793
+ </div>
794
+ );
795
+
796
+ // NESTED MODE: Use InnerBlocks for publisher-controlled layout (layoutVersion 2)
797
+ // This respects the block's saved mode regardless of current theme
798
+ if ( isNestedLayout ) {
799
+ // A v2 block opened in a classic theme can't render its inner blocks properly.
800
+ if ( ! isNestedMode ) {
801
+ return (
802
+ <div { ...blockProps }>
803
+ { inspectorControls }
804
+ <Placeholder className="newspack-blocks-author-profile" icon={ postAuthor } label={ __( 'Author Profile', 'newspack-blocks' ) }>
805
+ <Notice status="warning" isDismissible={ false }>
806
+ { __(
807
+ 'This block was created with a block theme and is not supported in the current theme. It will render using the classic layout on the frontend.',
808
+ 'newspack-blocks'
809
+ ) }
279
810
  </Notice>
280
- ) }
281
- { isLoading && (
282
- <div className="is-loading">
283
- { __( 'Fetching author info…', 'newspack-blocks' ) }
284
- <Spinner />
285
- </div>
286
- ) }
287
- { ! isLoading && (
811
+ </Placeholder>
812
+ </div>
813
+ );
814
+ }
815
+
816
+ // Mode selection for new blocks in nested mode
817
+ if ( ! authorId && ! isContextual && ! showSpecificSelector ) {
818
+ return modeSelectionPlaceholder;
819
+ }
820
+
821
+ // Loading state
822
+ if ( isLoading ) {
823
+ return loadingPlaceholder;
824
+ }
825
+
826
+ // Custom byline with no real authors referenced
827
+ if ( isContextual && customBylineActive && ! bylineAuthorIds.length ) {
828
+ return (
829
+ <div { ...blockProps }>
830
+ { inspectorControls }
831
+ <div className="newspack-author-profile-disabled">
832
+ <Notice status="warning" isDismissible={ false }>
833
+ { __( 'Author bio is hidden because Custom Byline is active on this post.', 'newspack-blocks' ) }
834
+ </Notice>
835
+ </div>
836
+ </div>
837
+ );
838
+ }
839
+
840
+ // Specific mode: show author search when no author selected
841
+ if ( ! isContextual && ! authorId ) {
842
+ return (
843
+ <div { ...blockProps }>
844
+ { inspectorControls }
845
+ <Placeholder className="newspack-blocks-author-profile" icon={ postAuthor } label={ __( 'Author Profile', 'newspack-blocks' ) }>
846
+ { error && (
847
+ <Notice status="error" isDismissible={ false }>
848
+ { error }
849
+ </Notice>
850
+ ) }
288
851
  <AutocompleteWithSuggestions
289
852
  label={ __( 'Search for an author to display', 'newspack-blocks' ) }
290
853
  help={ __( 'Begin typing name, click autocomplete result to select.', 'newspack-blocks' ) }
291
854
  fetchSuggestions={ async ( search = null, offset = 0 ) => {
292
- // Reset suggestions in state.
293
855
  setSuggestions( null );
294
-
295
- // If we already have a selected author, no need to fetch suggestions.
296
856
  if ( authorId && ! error ) {
297
857
  return [];
298
858
  }
299
-
300
859
  const response = await apiFetch( {
301
860
  parse: false,
302
861
  path: addQueryArgs( '/newspack-blocks/v1/authors', {
@@ -305,41 +864,32 @@ const AuthorProfile = ( { attributes, setAttributes } ) => {
305
864
  fields: 'id,name',
306
865
  } ),
307
866
  } );
308
-
309
- const total = parseInt( response.headers.get( 'x-wp-total' ) || 0 );
867
+ const total = parseInt( response.headers.get( 'x-wp-total' ) || 0, 10 );
310
868
  const authors = await response.json();
311
-
312
- // Set max items for "load more" functionality in suggestions list.
313
869
  if ( ! maxItemsToSuggest && ! search ) {
314
870
  setMaxItemsToSuggest( total );
315
871
  }
316
-
317
872
  const _suggestions = authors.map( _author => ( {
318
873
  value: _author.id,
319
874
  label: decodeEntities( _author.name ) || __( '(no name)', 'newspack-blocks' ),
320
875
  isGuestAuthor: _author.is_guest,
321
876
  } ) );
322
-
323
877
  setSuggestions( _suggestions );
324
-
325
878
  return _suggestions;
326
879
  } }
327
880
  maxItemsToSuggest={ maxItemsToSuggest }
328
881
  onChange={ items => {
329
882
  let selectionIsGuest = false;
330
883
  const selection = items[ 0 ];
331
-
332
- // We need to check whether the selected author is a guest author or not.
333
884
  if ( suggestions ) {
334
885
  suggestions.forEach( suggestion => {
335
- if ( parseInt( selection?.value ) === parseInt( suggestion?.value ) && suggestion?.isGuestAuthor ) {
886
+ if ( parseInt( selection?.value, 10 ) === parseInt( suggestion?.value, 10 ) && suggestion?.isGuestAuthor ) {
336
887
  selectionIsGuest = true;
337
888
  }
338
889
  } );
339
890
  }
340
-
341
891
  setAttributes( {
342
- authorId: parseInt( selection?.value || 0 ),
892
+ authorId: parseInt( selection?.value || 0, 10 ),
343
893
  isGuestAuthor: selectionIsGuest,
344
894
  } );
345
895
  } }
@@ -347,10 +897,223 @@ const AuthorProfile = ( { attributes, setAttributes } ) => {
347
897
  postTypeLabelPlural={ __( 'authors', 'newspack-blocks' ) }
348
898
  selectedItems={ [] }
349
899
  />
900
+ </Placeholder>
901
+ </div>
902
+ );
903
+ }
904
+
905
+ // Contextual mode: no authors found
906
+ if ( isContextual && ! authorsToRender.length ) {
907
+ return (
908
+ <div { ...blockProps }>
909
+ { inspectorControls }
910
+ <Placeholder className="newspack-blocks-author-profile" icon={ postAuthor } label={ __( 'Author Profile', 'newspack-blocks' ) }>
911
+ { __( 'No authors found for this post.', 'newspack-blocks' ) }
912
+ </Placeholder>
913
+ </div>
914
+ );
915
+ }
916
+
917
+ // Get preview author (bounds-checked)
918
+ const safeIndex = Math.min( previewAuthorIndex, authorsToRender.length - 1 );
919
+ const previewAuthor = authorsToRender[ safeIndex ];
920
+
921
+ // Set in the per-instance map synchronously so bindings have access on first render.
922
+ // The useEffect handles cleanup when component unmounts.
923
+ window.__newspackAuthorsByBlock[ clientId ] = previewAuthor;
924
+
925
+ const nestedBlockProps = {
926
+ ...blockProps,
927
+ className: `${ blockProps.className } wp-block-newspack-blocks-author-profile is-nested-mode`,
928
+ };
929
+
930
+ return (
931
+ <AuthorContext.Provider value={ previewAuthor }>
932
+ <div { ...nestedBlockProps }>
933
+ { inspectorControls }
934
+ { blockControls }
935
+ { /* Author selector: only shown in contextual mode with multiple authors */ }
936
+ { isContextual && authorsToRender.length > 1 && (
937
+ <Card isRounded={ false } size="small" style={ { marginBottom: '32px' } } variant="secondary">
938
+ <CardBody>
939
+ <SelectControl
940
+ label={ __( 'Preview author', 'newspack-blocks' ) }
941
+ value={ safeIndex }
942
+ options={ authorsToRender.map( ( a, index ) => ( {
943
+ label: a.name,
944
+ value: index,
945
+ } ) ) }
946
+ onChange={ value => setPreviewAuthorIndex( parseInt( value, 10 ) ) }
947
+ help={ sprintf(
948
+ /* translators: %d: number of authors */
949
+ __( 'Previewing 1 of %d authors. All authors display on frontend.', 'newspack-blocks' ),
950
+ authorsToRender.length
951
+ ) }
952
+ __next40pxDefaultSize
953
+ __nextHasNoMarginBottom
954
+ />
955
+ </CardBody>
956
+ </Card>
350
957
  ) }
351
- </Placeholder>
352
- ) }
353
- </>
958
+ { /* Key forces re-render when author changes, which re-evaluates bindings */ }
959
+ <InnerBlocks
960
+ key={ `author-${ previewAuthor?.id || 'none' }` }
961
+ template={ NESTED_TEMPLATE }
962
+ templateLock="insert"
963
+ allowedBlocks={ [ 'core/columns' ] }
964
+ />
965
+ </div>
966
+ </AuthorContext.Provider>
967
+ );
968
+ }
969
+
970
+ // MODE SELECTION: Show mode selector for NEW blocks (no authorId and not contextual)
971
+ if ( ! authorId && ! isContextual && ! showSpecificSelector ) {
972
+ return modeSelectionPlaceholder;
973
+ }
974
+
975
+ // CONTEXTUAL MODE
976
+ if ( isContextual ) {
977
+ // Loading state
978
+ if ( isLoading ) {
979
+ return loadingPlaceholder;
980
+ }
981
+
982
+ // Custom byline with no real authors referenced
983
+ if ( customBylineActive && ! bylineAuthorIds.length ) {
984
+ return (
985
+ <div { ...blockProps }>
986
+ { inspectorControls }
987
+ <div className="newspack-author-profile-disabled">
988
+ <Notice status="warning" isDismissible={ false }>
989
+ { __( 'Author bio is hidden because Custom Byline is active on this post.', 'newspack-blocks' ) }
990
+ </Notice>
991
+ </div>
992
+ </div>
993
+ );
994
+ }
995
+
996
+ // No authors found
997
+ if ( ! authorsToRender.length ) {
998
+ return (
999
+ <div { ...blockProps }>
1000
+ { inspectorControls }
1001
+ <Placeholder className="newspack-blocks-author-profile" icon={ postAuthor } label={ __( 'Author Profile', 'newspack-blocks' ) }>
1002
+ { __( 'No authors found for this post.', 'newspack-blocks' ) }
1003
+ </Placeholder>
1004
+ </div>
1005
+ );
1006
+ }
1007
+
1008
+ // Render contextual authors
1009
+ return (
1010
+ <div { ...blockProps }>
1011
+ { inspectorControls }
1012
+ { blockControls }
1013
+ { authorsToRender.map( authorData => (
1014
+ <SingleAuthor
1015
+ key={ authorData.id }
1016
+ author={ { ...authorData, social: getSocialLinks( authorData ) } }
1017
+ attributes={ attributes }
1018
+ />
1019
+ ) ) }
1020
+ </div>
1021
+ );
1022
+ }
1023
+
1024
+ // SPECIFIC MODE: Author selected - render it
1025
+ if ( author ) {
1026
+ return (
1027
+ <div { ...blockProps }>
1028
+ { inspectorControls }
1029
+ { blockControls }
1030
+ <SingleAuthor author={ { ...author, social: getSocialLinks( author ) } } attributes={ attributes } />
1031
+ </div>
1032
+ );
1033
+ }
1034
+
1035
+ // SPECIFIC MODE: No author selected - show search
1036
+ return (
1037
+ <div { ...blockProps }>
1038
+ { inspectorControls }
1039
+ <Placeholder className="newspack-blocks-author-profile" icon={ postAuthor } label={ __( 'Author Profile', 'newspack-blocks' ) }>
1040
+ { error && (
1041
+ <Notice status="error" isDismissible={ false }>
1042
+ { error }
1043
+ </Notice>
1044
+ ) }
1045
+ { isLoading && (
1046
+ <VStack alignment="center" style={ { width: '100%' } }>
1047
+ <Spinner style={ { margin: '0' } } />
1048
+ <span style={ { fontWeight: '500' } }>{ __( 'Fetching authors…', 'newspack-blocks' ) }</span>
1049
+ </VStack>
1050
+ ) }
1051
+ { ! isLoading && (
1052
+ <AutocompleteWithSuggestions
1053
+ label={ __( 'Search for an author to display', 'newspack-blocks' ) }
1054
+ help={ __( 'Begin typing name, click autocomplete result to select.', 'newspack-blocks' ) }
1055
+ fetchSuggestions={ async ( search = null, offset = 0 ) => {
1056
+ // Reset suggestions in state.
1057
+ setSuggestions( null );
1058
+
1059
+ // If we already have a selected author, no need to fetch suggestions.
1060
+ if ( authorId && ! error ) {
1061
+ return [];
1062
+ }
1063
+
1064
+ const response = await apiFetch( {
1065
+ parse: false,
1066
+ path: addQueryArgs( '/newspack-blocks/v1/authors', {
1067
+ search,
1068
+ offset,
1069
+ fields: 'id,name',
1070
+ } ),
1071
+ } );
1072
+
1073
+ const total = parseInt( response.headers.get( 'x-wp-total' ) || 0, 10 );
1074
+ const authors = await response.json();
1075
+
1076
+ // Set max items for "load more" functionality in suggestions list.
1077
+ if ( ! maxItemsToSuggest && ! search ) {
1078
+ setMaxItemsToSuggest( total );
1079
+ }
1080
+
1081
+ const _suggestions = authors.map( _author => ( {
1082
+ value: _author.id,
1083
+ label: decodeEntities( _author.name ) || __( '(no name)', 'newspack-blocks' ),
1084
+ isGuestAuthor: _author.is_guest,
1085
+ } ) );
1086
+
1087
+ setSuggestions( _suggestions );
1088
+
1089
+ return _suggestions;
1090
+ } }
1091
+ maxItemsToSuggest={ maxItemsToSuggest }
1092
+ onChange={ items => {
1093
+ let selectionIsGuest = false;
1094
+ const selection = items[ 0 ];
1095
+
1096
+ // We need to check whether the selected author is a guest author or not.
1097
+ if ( suggestions ) {
1098
+ suggestions.forEach( suggestion => {
1099
+ if ( parseInt( selection?.value, 10 ) === parseInt( suggestion?.value, 10 ) && suggestion?.isGuestAuthor ) {
1100
+ selectionIsGuest = true;
1101
+ }
1102
+ } );
1103
+ }
1104
+
1105
+ setAttributes( {
1106
+ authorId: parseInt( selection?.value || 0, 10 ),
1107
+ isGuestAuthor: selectionIsGuest,
1108
+ } );
1109
+ } }
1110
+ postTypeLabel={ __( 'author', 'newspack-blocks' ) }
1111
+ postTypeLabelPlural={ __( 'authors', 'newspack-blocks' ) }
1112
+ selectedItems={ [] }
1113
+ />
1114
+ ) }
1115
+ </Placeholder>
1116
+ </div>
354
1117
  );
355
1118
  };
356
1119