diff --git a/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-external-media.php b/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-external-media.php new file mode 100644 index 0000000000000..09f095d3ca524 --- /dev/null +++ b/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-external-media.php @@ -0,0 +1,421 @@ + array( + 'type' => 'object', + 'required' => true, + 'properties' => array( + 'caption' => array( + 'type' => 'string', + ), + 'guid' => array( + 'items' => array( + 'caption' => array( + 'type' => 'string', + ), + 'name' => array( + 'type' => 'string', + ), + 'title' => array( + 'type' => 'string', + ), + 'url' => array( + 'format' => 'uri', + 'type' => 'string', + ), + ), + 'type' => 'array', + ), + 'title' => array( + 'type' => 'string', + ), + ), + ), + ); + + /** + * Service regex. + * + * @var string + */ + private static $services_regex = '(?Pgoogle_photos|pexels)'; + + /** + * Temporary filename. + * + * Needed to cope with Google's very long file names. + * + * @var string + */ + private $tmp_name; + + /** + * Constructor. + */ + public function __construct() { + $this->namespace = 'wpcom/v2'; + $this->rest_base = 'external-media'; + + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Registers the routes for external media. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + $this->rest_base . '/list/' . self::$services_regex, + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_external_media' ), + 'permission_callback' => array( $this, 'permission_callback' ), + 'args' => array( + 'search' => array( + 'description' => __( 'Media collection search term.', 'jetpack' ), + 'type' => 'string', + ), + 'number' => array( + 'description' => __( 'Number of media items in the request', 'jetpack' ), + 'type' => 'number', + 'default' => 20, + ), + 'path' => array( + 'type' => 'string', + ), + 'page_handle' => array( + 'type' => 'string', + ), + ), + ) + ); + + register_rest_route( + $this->namespace, + $this->rest_base . '/copy/' . self::$services_regex, + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'copy_external_media' ), + 'permission_callback' => array( $this, 'permission_callback' ), + 'args' => array( + 'media' => array( + 'description' => __( 'Media data to copy.', 'jetpack' ), + 'items' => array_values( $this->media_schema ), + 'required' => true, + 'type' => 'array', + 'sanitize_callback' => array( $this, 'sanitize_media' ), + 'validate_callback' => array( $this, 'validate_media' ), + ), + ), + ) + ); + + register_rest_route( + $this->namespace, + $this->rest_base . '/connection/(?Pgoogle_photos)', + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_connection_details' ), + 'permission_callback' => array( $this, 'permission_callback' ), + ) + ); + } + + /** + * Checks if a given request has access to external media libraries. + */ + public function permission_callback() { + return current_user_can( 'edit_posts' ); + } + + /** + * Sanitization callback for media parameter. + * + * @param array $param Media parameter. + * @return true|\WP_Error + */ + public function sanitize_media( $param ) { + $param = $this->prepare_media_param( $param ); + + return rest_sanitize_value_from_schema( $param, $this->media_schema ); + } + + /** + * Validation callback for media parameter. + * + * @param array $param Media parameter. + * @return true|\WP_Error + */ + public function validate_media( $param ) { + $param = $this->prepare_media_param( $param ); + + return rest_validate_value_from_schema( $param, $this->media_schema, 'media' ); + } + + /** + * Decodes guid json and sets parameter defaults. + * + * @param array $param Media parameter. + * @return array + */ + private function prepare_media_param( $param ) { + foreach ( $param as $key => $item ) { + if ( ! empty( $item['guid'] ) ) { + $param[ $key ]['guid'] = json_decode( $item['guid'], true ); + } + + if ( empty( $param[ $key ]['caption'] ) ) { + $param[ $key ]['caption'] = ''; + } + if ( empty( $param[ $key ]['title'] ) ) { + $param[ $key ]['title'] = ''; + } + } + + return $param; + } + + /** + * Retrieves media items from external libraries. + * + * @param \WP_REST_Request $request Full details about the request. + * @return array|\WP_Error|mixed + */ + public function get_external_media( \WP_REST_Request $request ) { + $params = $request->get_params(); + $wpcom_path = sprintf( '/meta/external-media/%s', rawurlencode( $params['service'] ) ); + + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { + $request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path ); + $request->set_query_params( $params ); + + return rest_do_request( $request ); + } + + // Build query string to pass to wpcom endpoint. + $service_args = array_filter( + $params, + function( $key ) { + return in_array( $key, array( 'search', 'number', 'path', 'page_handle', 'filter' ), true ); + }, + ARRAY_FILTER_USE_KEY + ); + if ( ! empty( $service_args ) ) { + $wpcom_path .= '?' . http_build_query( $service_args ); + } + + $response = Client::wpcom_json_api_request_as_user( $wpcom_path ); + + switch ( wp_remote_retrieve_response_code( $response ) ) { + case 200: + $response = json_decode( wp_remote_retrieve_body( $response ) ); + break; + + case 403: + $error = json_decode( wp_remote_retrieve_body( $response ) ); + $response = new WP_Error( $error->code, $error->message, $error->data ); + break; + + default: + if ( is_wp_error( $response ) ) { + $response->add_data( array( 'status' => 400 ) ); + break; + } + $response = new WP_Error( + 'rest_request_error', + __( 'An unknown error has occurred. Please try again later.', 'jetpack' ), + array( 'status' => wp_remote_retrieve_response_code( $response ) ) + ); + } + + return $response; + } + + /** + * Saves an external media item to the media library. + * + * @param \WP_REST_Request $request Full details about the request. + * @return array|\WP_Error|mixed + */ + public function copy_external_media( \WP_REST_Request $request ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + + $responses = array(); + foreach ( $request->get_param( 'media' ) as $item ) { + // Download file to temp dir. + $download_url = $this->get_download_url( $item['guid'] ); + if ( is_wp_error( $download_url ) ) { + $responses[] = $download_url; + continue; + } + + $id = $this->sideload_media( $item['guid']['name'], $download_url ); + if ( is_wp_error( $id ) ) { + $responses[] = $id; + continue; + } + + $this->update_attachment_meta( $id, $item ); + + // Add attachment data or WP_Error. + $responses[] = $this->get_attachment_data( $id, $item ); + } + + return $responses; + } + + /** + * Gets connection authorization details. + * + * @param \WP_REST_Request $request Full details about the request. + * @return array|\WP_Error|mixed + */ + public function get_connection_details( \WP_REST_Request $request ) { + $service = rawurlencode( $request->get_param( 'service' ) ); + $wpcom_path = sprintf( '/meta/external-media/connection/%s', $service ); + + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { + $request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path ); + $request->set_query_params( $request->get_params() ); + + return rest_do_request( $request ); + } + + $response = Client::wpcom_json_api_request_as_user( $wpcom_path ); + $response = json_decode( wp_remote_retrieve_body( $response ) ); + + if ( isset( $response->code, $response->message, $response->data ) ) { + $response->data = empty( $response->data->status ) ? array( 'status' => $response->data ) : $response->data; + $response = new WP_Error( $response->code, $response->message, $response->data ); + } + + return $response; + } + + /** + * Filter callback to provide a shorter file name for google images. + * + * @return string + */ + public function tmp_name() { + return $this->tmp_name; + } + + /** + * Returns a download URL, dealing with Google's long file names. + * + * @param array $guid Media information. + * @return string|\WP_Error + */ + public function get_download_url( $guid ) { + $this->tmp_name = $guid['name']; + add_filter( 'wp_unique_filename', array( $this, 'tmp_name' ) ); + $download_url = download_url( $guid['url'] ); + remove_filter( 'wp_unique_filename', array( $this, 'tmp_name' ) ); + + if ( is_wp_error( $download_url ) ) { + $download_url->add_data( array( 'status' => 400 ) ); + } + + return $download_url; + } + + /** + * Uploads media file and creates attachment object. + * + * @param string $file_name Name of media file. + * @param string $download_url Download URL. + * + * @return int|\WP_Error + */ + public function sideload_media( $file_name, $download_url ) { + $file = array( + 'name' => wp_basename( $file_name ), + 'tmp_name' => $download_url, + ); + + $id = media_handle_sideload( $file, 0, null ); + if ( is_wp_error( $id ) ) { + @unlink( $file['tmp_name'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + $id->add_data( array( 'status' => 400 ) ); + } + + return $id; + } + + /** + * Updates attachment meta data for media item. + * + * @param int $id Attachment ID. + * @param array $item Media item. + */ + public function update_attachment_meta( $id, $item ) { + $meta = wp_get_attachment_metadata( $id ); + $meta['image_meta']['title'] = $item['title']; + $meta['image_meta']['caption'] = $item['caption']; + + wp_update_attachment_metadata( $id, $meta ); + + update_post_meta( $id, '_wp_attachment_image_alt', $item['title'] ); + wp_update_post( + array( + 'ID' => $id, + 'post_excerpt' => $item['caption'], + ) + ); + } + + /** + * Retrieves attachment data for media item. + * + * @param int $id Attachment ID. + * @param array $item Media item. + * + * @return array|\WP_REST_Response Attachment data on success, WP_Error on failure. + */ + public function get_attachment_data( $id, $item ) { + $image_src = wp_get_attachment_image_src( $id, 'full' ); + + if ( empty( $image_src[0] ) ) { + $response = new WP_Error( + 'rest_upload_error', + __( 'Could not retrieve source URL.', 'jetpack' ), + array( 'status' => 400 ) + ); + } else { + $response = array( + 'id' => $id, + 'caption' => $item['caption'], + 'alt' => $item['title'], + 'type' => 'image', + 'url' => $image_src[0], + ); + } + + return $response; + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_External_Media' ); diff --git a/bin/phpcs-whitelist.js b/bin/phpcs-whitelist.js index 58a8a1ba002c3..ffd965cf1e863 100644 --- a/bin/phpcs-whitelist.js +++ b/bin/phpcs-whitelist.js @@ -24,6 +24,7 @@ module.exports = [ '_inc/lib/class-jetpack-wizard.php', '_inc/lib/components.php', '_inc/lib/core-api/class.jetpack-core-api-site-endpoints.php', + '_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-external-media.php', '_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-instagram-gallery.php', '_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-podcast-player.php', '_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-resolve-redirect.php', diff --git a/extensions/editor.js b/extensions/editor.js index 3d61997fa354f..fb53f82d101e3 100644 --- a/extensions/editor.js +++ b/extensions/editor.js @@ -5,6 +5,7 @@ import './shared/public-path'; import './shared/block-category'; import './shared/plan-upgrade-notification'; import './shared/stripe-connection-notification'; +import './shared/external-media'; import analytics from '../_inc/client/lib/analytics'; // @TODO Please make a shared analytics solution and remove this! diff --git a/extensions/shared/external-media/constants.js b/extensions/shared/external-media/constants.js new file mode 100644 index 0000000000000..526f9a731ab67 --- /dev/null +++ b/extensions/shared/external-media/constants.js @@ -0,0 +1,137 @@ +/** + * External dependencies + */ + +import { __ } from '@wordpress/i18n'; + +export const SOURCE_WORDPRESS = 'wordpress'; +export const SOURCE_GOOGLE_PHOTOS = 'google_photos'; +export const SOURCE_PEXELS = 'pexels'; +export const PATH_RECENT = 'recent'; +export const PATH_ROOT = '/'; +export const PATH_OPTIONS = [ + { + value: PATH_RECENT, + label: __( 'Recent Photos', 'jetpack' ), + }, + { + value: PATH_ROOT, + label: __( 'Albums', 'jetpack' ), + }, +]; +export const GOOGLE_PHOTOS_CATEGORIES = [ + { + value: '', + /* translators: category of images */ + label: __( 'All categories', 'jetpack' ), + }, + { + value: 'animals', + /* translators: category of images */ + label: __( 'Animals', 'jetpack' ), + }, + { + value: 'arts', + /* translators: category of images */ + label: __( 'Arts', 'jetpack' ), + }, + { + value: 'birthdays', + /* translators: category of images */ + label: __( 'Birthdays', 'jetpack' ), + }, + { + value: 'cityscapes', + /* translators: category of images */ + label: __( 'Cityscapes', 'jetpack' ), + }, + { + value: 'crafts', + /* translators: category of images */ + label: __( 'Crafts', 'jetpack' ), + }, + { + value: 'fashion', + /* translators: category of images */ + label: __( 'Fashion', 'jetpack' ), + }, + { + value: 'food', + /* translators: category of images */ + label: __( 'Food', 'jetpack' ), + }, + { + value: 'flowers', + /* translators: category of images */ + label: __( 'Flowers', 'jetpack' ), + }, + { + value: 'gardens', + /* translators: category of images */ + label: __( 'Gardens', 'jetpack' ), + }, + { + value: 'holidays', + /* translators: category of images */ + label: __( 'Holidays', 'jetpack' ), + }, + { + value: 'houses', + /* translators: category of images */ + label: __( 'Houses', 'jetpack' ), + }, + { + value: 'landmarks', + /* translators: category of images */ + label: __( 'Landmarks', 'jetpack' ), + }, + { + value: 'landscapes', + /* translators: category of images */ + label: __( 'Landscapes', 'jetpack' ), + }, + { + value: 'night', + /* translators: category of images */ + label: __( 'Night', 'jetpack' ), + }, + { + value: 'people', + /* translators: category of images */ + label: __( 'People', 'jetpack' ), + }, + { + value: 'pets', + /* translators: category of images */ + label: __( 'Pets', 'jetpack' ), + }, + { + value: 'selfies', + /* translators: category of images */ + label: __( 'Selfies', 'jetpack' ), + }, + { + value: 'sport', + /* translators: category of images */ + label: __( 'Sport', 'jetpack' ), + }, + { + value: 'travel', + /* translators: category of images */ + label: __( 'Travel', 'jetpack' ), + }, + { + value: 'weddings', + /* translators: category of images */ + label: __( 'Weddings', 'jetpack' ), + }, +]; +export const PEXELS_EXAMPLE_QUERIES = [ + 'mountain', + 'ocean', + 'river', + 'clouds', + 'pattern', + 'abstract', + 'sky', +]; diff --git a/extensions/shared/external-media/editor.scss b/extensions/shared/external-media/editor.scss new file mode 100644 index 0000000000000..7c4e58f5f5f03 --- /dev/null +++ b/extensions/shared/external-media/editor.scss @@ -0,0 +1,363 @@ +/** + * External Media + */ +@import '../styles/gutenberg-base-styles.scss'; + +$grid-size: 8px; + +@keyframes loading-fade { + 0% { + opacity: 0.5; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.5; + } +} + +/** + * Media item container + */ + +.jetpack-external-media-browser__is-copying { + max-height: 20%; + max-width: 20%; + min-width: 480px; + min-height: 360px + 56px; + + .jetpack-external-media-browser__single { + height: 300px; + } + + .jetpack-external-media-browser__media { + height: 100%; + } + + .is-transient { + border: 0; + background: 0; + padding: 0; + height: 100%; + + &.jetpack-external-media-browser__media__item__selected { + box-shadow: none; + border-radius: 0; + } + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + } +} + +.jetpack-external-media-browser:not( .jetpack-external-media-browser__is-copying ) { + .is-error { + margin-bottom: 1em; + margin-left: 0; + margin-right: 0; + } + + .components-placeholder { + background-color: transparent; + } + + .components-modal__content { + overflow: auto; + padding-bottom: 0; + } +} + +@media ( min-width: 600px ) { + .components-modal__content { + width: 90vw; + height: 90vh; + } +} + +.jetpack-external-media-browser__single { + position: relative; + display: flex; + justify-content: center; + align-items: center; + + .is-transient img { + opacity: 0.3; + } + + .components-spinner { + position: absolute; + top: 50%; + right: 50%; + margin-top: -9px; + margin-right: -9px; + } +} + +.jetpack-external-media-browser { + background: white; + display: flex; + flex-direction: column; + align-items: flex-start; + + .jetpack-external-media-browser__media { + width: 100%; + } + + // Individual Thumbnails + .jetpack-external-media-browser__media__item { + height: 0; + width: 50%; + padding-top: 50%; + display: inline-flex; + position: relative; + + // Unset button appearance. + border: 0; + background: transparent; + + img { + display: block; + position: absolute; + top: $grid-size; + left: $grid-size; + width: calc( 100% - #{$grid-size * 2} ); + height: calc( 100% - #{$grid-size * 2} ); + object-fit: contain; + } + + &.is-transient img { + opacity: 0.3; + } + + .components-spinner { + position: absolute; + top: 50%; + right: 50%; + margin-top: -9px; + margin-right: -9px; + } + } + + .jetpack-external-media-browser__media__folder { + float: left; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + align-content: flex-start; + margin-bottom: 36px; + } + + .jetpack-external-media-browser__media__info { + font-size: 12px; + font-weight: bold; + width: 100%; + display: flex; + justify-content: space-between; + padding: 3px; + } + + .jetpack-external-media-browser__media__count { + background-color: #ddd; + padding: 3px 4px; + border-radius: 8px; + margin-bottom: auto; + } + + // Resting, focus and selected. + $border-width: 8px; + + .jetpack-external-media-browser__media__item { + border: $border-width solid transparent; + + &:focus { + outline: none; + box-shadow: inset 0 0 0 2px $blue-medium-focus; + border-radius: 2px + $border-width; // Add the 4px from the transparent. + } + + &__selected { + box-shadow: inset 0 0 0 6px $blue-medium-focus; + border-radius: 2px + $border-width; // Add the 4px from the transparent. + } + + &__selected:focus { + box-shadow: inset 0 0 0 2px $blue-medium-focus, inset 0 0 0 3px white, + inset 0 0 0 6px $blue-medium-focus; + } + } + + // Transient placeholder when media are loading. + .jetpack-external-media-browser__media__placeholder { + width: 100px; + height: 100px; + margin: $grid-size * 2; + animation: loading-fade 1.6s ease-in-out infinite; + background-color: $light-gray-secondary; + border: 0; + } + + // Toolbar for "insert" and pagination button. + .jetpack-external-media-browser__media__toolbar { + position: fixed; + position: sticky; + bottom: 0; + left: 0; + width: 100%; + background: white; + padding: 20px 0; + display: flex; + justify-content: flex-end; + } + + .jetpack-external-media-browser__loadmore { + clear: both; + display: block; + margin: 24px auto 48px auto; + } +} + +// Show more thumbs beyond mobile. +@media only screen and ( min-width: 600px ) { + .jetpack-external-media-browser .jetpack-external-media-browser__media__item { + width: 20%; + padding-top: 20%; + } +} + +/** + * The specific wrappers for Google and Pexels. + */ + +.jetpack-external-media-header__view { + display: flex; + align-items: center; + justify-content: flex-start; + margin-bottom: 48px; + + select { + max-width: 200px !important; + } +} + +.jetpack-external-media-header__filter, +.jetpack-external-media-header__view { + label { + margin-right: 10px; + } + + .components-base-control { + padding-right: $grid-size; + margin-bottom: 0; + } + + .components-base-control__label { + margin-bottom: 0; + } + + .components-base-control__field { + display: flex; + align-items: center; + margin-bottom: 0; + } + + .components-base-control + .components-base-control { + padding-left: $grid-size * 2; + } +} + +.jetpack-external-media-header__filter { + display: flex; + flex-wrap: wrap; + align-items: center; + + .jetpack-external-media-googlephotos-filter { + display: flex; + align-items: center; + margin-right: 7px; + } +} + +.jetpack-external-media-header__pexels { + display: flex; + margin-bottom: 48px; + + .components-base-control { + flex: 1; + margin-right: 12px; + } + + .components-base-control__field { + margin-bottom: 0; + } + + .components-base-control__field, + .components-text-control__input { + height: 100%; + } +} + +/** + * Basic Responsiveness + */ + +.jetpack-external-media-placeholder__open-modal { + display: flex; + justify-content: center; + align-items: center; + padding: 0; + position: absolute; + right: 0; + margin-top: -48px; + z-index: 1; + + .components-button { + margin: 0; + padding: 12px; + background: none; + + &::before { + content: none; + } + + svg { + display: block; + fill: currentColor; + } + } +} + +.jetpack-external-media-browsing + > div.components-placeholder:not( .jetpack-external-media-replacedholder ) { + display: none; +} + +.jetpack-external-media-browser__empty { + width: 100%; + text-align: center; + padding-top: 2em; +} + +.jetpack-external-media-auth { + max-width: 340px; + margin: 0 auto; + text-align: center; + + p { + margin: 2em 0; + } +} + +.jetpack-external-media-filters { + display: flex; + justify-content: space-between; +} + +// Reset placeholder button margin. +.components-placeholder__fieldset .components-dropdown .components-button { + margin-right: 8px; +} diff --git a/extensions/shared/external-media/index.js b/extensions/shared/external-media/index.js new file mode 100644 index 0000000000000..b024502d7bfec --- /dev/null +++ b/extensions/shared/external-media/index.js @@ -0,0 +1,49 @@ +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import isCurrentUserConnected from '../is-current-user-connected'; +import MediaButton from './media-button'; +import { mediaSources } from './sources'; +import './editor.scss'; + +function insertExternalMediaBlocks( settings, name ) { + if ( name !== 'core/image' ) { + return settings; + } + + return { + ...settings, + keywords: [ ...settings.keywords, ...mediaSources.map( source => source.keyword ) ], + }; +} + +if ( isCurrentUserConnected() ) { + // Register the new 'browse media' button. + addFilter( + 'editor.MediaUpload', + 'external-media/replace-media-upload', + OriginalComponent => props => { + let { render } = props; + + // Only replace button for components that expect images. + if ( props.allowedTypes.indexOf( 'image' ) > -1 ) { + render = button => ; + } + + return ; + }, + 11 + ); + + // Register the individual external media blocks. + addFilter( + 'blocks.registerBlockType', + 'external-media/individual-blocks', + insertExternalMediaBlocks + ); +} diff --git a/extensions/shared/external-media/media-browser/index.js b/extensions/shared/external-media/media-browser/index.js new file mode 100644 index 0000000000000..76898ca93058c --- /dev/null +++ b/extensions/shared/external-media/media-browser/index.js @@ -0,0 +1,113 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { memo, useCallback, useState, useRef } from '@wordpress/element'; +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import MediaPlaceholder from './placeholder'; +import MediaItem from './media-item'; + +const MAX_SELECTED = 10; + +const EmptyResults = memo( () => ( +
+

{ __( 'Sorry, but nothing matched your search criteria.', 'jetpack' ) }

+
+) ); + +function MediaBrowser( props ) { + const { media, isLoading, pageHandle, className, multiple, setPath, nextPage, onCopy } = props; + const [ selected, setSelected ] = useState( [] ); + + const onSelectImage = useCallback( + newlySelected => { + let newSelected = [ newlySelected ]; + + if ( newlySelected.type === 'folder' ) { + setPath( newlySelected.ID ); + } else if ( multiple ) { + newSelected = selected.slice( 0, MAX_SELECTED - 1 ).concat( newlySelected ); + + if ( selected.find( item => newlySelected.ID === item.ID ) ) { + newSelected = selected.filter( item => item.ID !== newlySelected.ID ); + } + } else if ( selected.length === 1 && newlySelected.ID === selected[ 0 ].ID ) { + newSelected = []; + } + + setSelected( newSelected ); + }, + [ selected, multiple, setPath ] + ); + + const onCopyAndInsert = useCallback( () => { + onCopy( selected ); + }, [ selected, onCopy ] ); + + const hasMediaItems = media.filter( item => item.type !== 'folder' ).length > 0; + const classes = classnames( { + 'jetpack-external-media-browser__media': true, + 'jetpack-external-media-browser__media__loading': isLoading, + } ); + const wrapper = classnames( { + 'jetpack-external-media-browser': true, + [ className ]: true, + } ); + + const prevMediaCount = useRef( 0 ); + + const onLoadMoreClick = () => { + prevMediaCount.current = media.length; + nextPage(); + }; + + return ( +
+
    + { media.map( ( item, index ) => ( + toFind.ID === item.ID ) } + /> + ) ) } + + { media.length === 0 && ! isLoading && } + { isLoading && } + + { pageHandle && ! isLoading && ( + + ) } +
+ + { hasMediaItems && ( +
+ +
+ ) } +
+ ); +} + +export default MediaBrowser; diff --git a/extensions/shared/external-media/media-browser/media-item.js b/extensions/shared/external-media/media-browser/media-item.js new file mode 100644 index 0000000000000..503dcf3969326 --- /dev/null +++ b/extensions/shared/external-media/media-browser/media-item.js @@ -0,0 +1,75 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { useRef, useEffect, useCallback } from '@wordpress/element'; +import { Spinner } from '@wordpress/components'; +import { ENTER, SPACE } from '@wordpress/keycodes'; + +function MediaItem( props ) { + const onClick = useCallback( () => { + if ( props.onClick ) { + props.onClick( props.item ); + } + }, [ props.onClick ] ); + + // Catch space and enter key presses. + const onKeyDown = event => { + if ( ENTER === event.keyCode || SPACE === event.keyCode ) { + // Prevent spacebar from scrolling the page down. + event.preventDefault(); + onClick( event ); + } + }; + + const { item, focusOnMount, isSelected, isCopying = false } = props; + const { thumbnails, caption, name, title, type, children = 0 } = item; + const { medium = null, fmt_hd = null } = thumbnails; + const alt = title || caption || name; + const classes = classnames( { + 'jetpack-external-media-browser__media__item': true, + 'jetpack-external-media-browser__media__item__selected': isSelected, + 'jetpack-external-media-browser__media__folder': type === 'folder', + 'is-transient': isSelected && isCopying, + } ); + + const itemEl = useRef( null ); + + useEffect( () => { + if ( focusOnMount ) { + itemEl.current.focus(); + } + // Passing empty dependency array to focus on mount only. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [] ); + + /* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role */ + return ( +
  • + { + + { type === 'folder' && ( +
    +
    { name }
    +
    { children }
    +
    + ) } + + { isSelected && isCopying && } +
  • + ); +} + +export default MediaItem; diff --git a/extensions/shared/external-media/media-browser/placeholder.js b/extensions/shared/external-media/media-browser/placeholder.js new file mode 100644 index 0000000000000..d7b5bbdcd5d34 --- /dev/null +++ b/extensions/shared/external-media/media-browser/placeholder.js @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +import { memo } from '@wordpress/element'; + +function MediaPlaceholder() { + const className = + 'jetpack-external-media-browser__media__item jetpack-external-media-browser__media__placeholder'; + return ( + <> +
    +
    +
    + + ); +} + +export default memo( MediaPlaceholder ); diff --git a/extensions/shared/external-media/media-button/index.js b/extensions/shared/external-media/media-button/index.js new file mode 100644 index 0000000000000..0fea14a70b7cb --- /dev/null +++ b/extensions/shared/external-media/media-button/index.js @@ -0,0 +1,49 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { getExternalLibrary } from '../sources'; +import MediaButtonMenu from './media-menu'; + +const isFeaturedImage = props => + props.unstableFeaturedImageFlow || + ( props.modalClass && props.modalClass.indexOf( 'featured-image' ) !== -1 ); +const isReplaceMenu = props => props.multiple === undefined && ! isFeaturedImage( props ); + +function MediaButton( props ) { + const { mediaProps } = props; + const [ selectedSource, setSelectedSource ] = useState( null ); + const ExternalLibrary = getExternalLibrary( selectedSource ); + + const closeLibrary = event => { + if ( event ) { + event.stopPropagation(); + + // The DateTime picker is triggering a modal close when selected. We don't want this to close the modal + if ( event.target.closest( '.jetpack-external-media-header__dropdown' ) ) { + return; + } + } + + setSelectedSource( null ); + }; + + return ( + <> + + + { ExternalLibrary && } + + ); +} + +export default MediaButton; diff --git a/extensions/shared/external-media/media-button/media-menu.js b/extensions/shared/external-media/media-button/media-menu.js new file mode 100644 index 0000000000000..f40d8111e3723 --- /dev/null +++ b/extensions/shared/external-media/media-button/media-menu.js @@ -0,0 +1,80 @@ +/** + * WordPress dependencies + */ +import { Button, MenuItem, MenuGroup, Dropdown, NavigableMenu } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import MediaSources from './media-sources'; + +function MediaButtonMenu( props ) { + const { mediaProps, open, setSelectedSource, isFeatured, isReplace } = props; + const originalComponent = mediaProps.render; + + if ( isReplace ) { + return ( + + ); + } + + const dropdownOpen = onToggle => { + onToggle(); + open(); + }; + const changeSource = ( source, onToggle ) => { + setSelectedSource( source ); + onToggle(); + }; + const openLibrary = onToggle => { + onToggle(); + open(); + }; + + if ( isFeatured && mediaProps.value === undefined ) { + return originalComponent( { open } ); + } + + return ( + <> + { isFeatured && originalComponent( { open } ) } + + ( + + ) } + renderContent={ ( { onToggle } ) => ( + + + openLibrary( onToggle ) }> + { __( 'Media Library', 'jetpack' ) } + + + dropdownOpen( onToggle ) } + setSource={ source => changeSource( source, onToggle ) } + /> + + + ) } + /> + + ); +} + +export default MediaButtonMenu; diff --git a/extensions/shared/external-media/media-button/media-sources.js b/extensions/shared/external-media/media-button/media-sources.js new file mode 100644 index 0000000000000..5bc1737ecf7c7 --- /dev/null +++ b/extensions/shared/external-media/media-button/media-sources.js @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { MenuItem } from '@wordpress/components'; +import { Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { mediaSources } from '../sources'; + +function MediaSources( { originalButton = null, open, setSource } ) { + return ( + + { originalButton && originalButton( { open } ) } + + { mediaSources.map( ( { icon, id, label } ) => ( + setSource( id ) }> + { label } + + ) ) } + + ); +} + +export default MediaSources; diff --git a/extensions/shared/external-media/sources/api.js b/extensions/shared/external-media/sources/api.js new file mode 100644 index 0000000000000..76197d6d2b549 --- /dev/null +++ b/extensions/shared/external-media/sources/api.js @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +import { addQueryArgs } from '@wordpress/url'; + +const ENDPOINTS = { + list: '/wpcom/v2/external-media/list/', + copy: '/wpcom/v2/external-media/copy/', + connection: '/wpcom/v2/external-media/connection/', +}; + +export function getApiUrl( command, source, args = {} ) { + if ( ENDPOINTS[ command ] ) { + return addQueryArgs( ENDPOINTS[ command ] + source, args ); + } + + return null; +} diff --git a/extensions/shared/external-media/sources/google-photos/auth-instructions.js b/extensions/shared/external-media/sources/google-photos/auth-instructions.js new file mode 100644 index 0000000000000..c7ef5a8e8d53f --- /dev/null +++ b/extensions/shared/external-media/sources/google-photos/auth-instructions.js @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Fragment, memo } from '@wordpress/element'; + +import { GooglePhotosLogo } from '../../../icons'; + +function AuthInstructions() { + return ( + + +

    + { __( + 'To show your Google Photos library you need to connect your Google account.', + 'jetpack' + ) } +

    +

    { __( 'You can remove the connection in either of these places:', 'jetpack' ) }

    + +
    + ); +} + +export default memo( AuthInstructions ); diff --git a/extensions/shared/external-media/sources/google-photos/auth-progress.js b/extensions/shared/external-media/sources/google-photos/auth-progress.js new file mode 100644 index 0000000000000..0b0077db21fa8 --- /dev/null +++ b/extensions/shared/external-media/sources/google-photos/auth-progress.js @@ -0,0 +1,11 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { memo } from '@wordpress/element'; + +function AuthProgress() { + return

    { __( 'Awaiting authorization', 'jetpack' ) }

    ; +} + +export default memo( AuthProgress ); diff --git a/extensions/shared/external-media/sources/google-photos/breadcrumbs.js b/extensions/shared/external-media/sources/google-photos/breadcrumbs.js new file mode 100644 index 0000000000000..1e3044b3acea4 --- /dev/null +++ b/extensions/shared/external-media/sources/google-photos/breadcrumbs.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Fragment, memo } from '@wordpress/element'; +import { Button } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { PATH_ROOT } from '../../constants'; + +function Breadcrumbs( { path, setPath } ) { + return ( + + + →   { path.name } + + ); +} + +export default memo( Breadcrumbs ); diff --git a/extensions/shared/external-media/sources/google-photos/date-formatting.js b/extensions/shared/external-media/sources/google-photos/date-formatting.js new file mode 100644 index 0000000000000..139ed45971890 --- /dev/null +++ b/extensions/shared/external-media/sources/google-photos/date-formatting.js @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +export function getDateValue( name, value ) { + if ( name === 'startDate' ) { + /* translators: %s is formatted date */ + return sprintf( __( 'After %s', 'jetpack' ), value ); + } + + /* translators: %s is formatted date */ + return sprintf( __( 'Before %s', 'jetpack' ), value ); +} + +export function getDateName( name ) { + if ( name === 'startDate' ) { + return __( 'After Date', 'jetpack' ); + } + + return __( 'Before Date', 'jetpack' ); +} diff --git a/extensions/shared/external-media/sources/google-photos/filter-option.js b/extensions/shared/external-media/sources/google-photos/filter-option.js new file mode 100644 index 0000000000000..d10cd8cc74718 --- /dev/null +++ b/extensions/shared/external-media/sources/google-photos/filter-option.js @@ -0,0 +1,142 @@ +/** + * External dependencies + */ +import { dateI18n, __experimentalGetSettings } from '@wordpress/date'; +import { __ } from '@wordpress/i18n'; +import { SelectControl, Button, DateTimePicker, Dropdown } from '@wordpress/components'; +import { omit } from 'lodash'; + +/** + * Internal dependencies + */ +import { GOOGLE_PHOTOS_CATEGORIES } from '../../constants'; +import { getDateValue, getDateName } from './date-formatting'; + +function CategoryOption( { value, updateFilter } ) { + return ( + + ); +} + +function DateOption( { value, name, updateFilter } ) { + const settings = __experimentalGetSettings(); + const update = ( selected, onToggle ) => { + onToggle(); + updateFilter( selected ); + }; + + return ( + ( + + ) } + renderContent={ ( { onToggle } ) => ( +
    + update( selected, onToggle ) } + currentDate={ value } + /> +
    + ) } + /> + ); +} + +function FavoriteOption() { + return { __( 'Favorites', 'jetpack' ) }; +} + +function MediaTypeOption( { value, updateFilter } ) { + const options = [ + { label: __( 'All', 'jetpack' ), value: '' }, + { label: __( 'Images', 'jetpack' ), value: 'photo' }, + { label: __( 'Videos', 'jetpack' ), value: 'video' }, + ]; + + return ( + + ); +} + +function getFilterOption( optionName, optionValue, updateFilter ) { + if ( optionName === 'category' ) { + return ; + } + + if ( optionName === 'startDate' || optionName === 'endDate' ) { + return ; + } + + if ( optionName === 'favorite' ) { + return ; + } + + if ( optionName === 'mediaType' ) { + return ; + } + + return null; +} + +function FilterOption( { children, removeFilter } ) { + return ( +
    + { children } + + +
    + ); +} + +function getUpdatedFilters( existing, key, value ) { + const copy = { + ...existing, + [ key ]: value, + }; + + // Some special exceptions + if ( key === 'mediaType' && value === 'video' ) { + delete copy.category; + } else if ( key === 'category' && copy.mediaType === 'video' ) { + delete copy.mediaType; + } + + return copy; +} + +function GoogleFilterOption( { filters, setFilters, canChangeMedia } ) { + const options = Object.keys( filters ) + .filter( item => canChangeMedia || item !== 'mediaType' ) + .map( key => ( + setFilters( omit( filters, key ) ) }> + { getFilterOption( key, filters[ key ], value => + setFilters( getUpdatedFilters( filters, key, value ) ) + ) } + + ) ); + + if ( options.length === 0 ) { + return null; + } + + return options; +} + +export default GoogleFilterOption; diff --git a/extensions/shared/external-media/sources/google-photos/filter-request.js b/extensions/shared/external-media/sources/google-photos/filter-request.js new file mode 100644 index 0000000000000..1cccdf84ca883 --- /dev/null +++ b/extensions/shared/external-media/sources/google-photos/filter-request.js @@ -0,0 +1,25 @@ +export default function getFilterRequest( filters ) { + const { mediaType, category, favorite, startDate, endDate } = filters; + const query = []; + + if ( mediaType ) { + query.push( 'mediaType=' + mediaType ); + } + + if ( category && mediaType !== 'video' ) { + query.push( 'categoryInclude=' + category ); + } + + if ( favorite !== undefined ) { + query.push( 'feature=favorite' ); + } + + if ( startDate || endDate ) { + const start = startDate ? startDate.substr( 0, 10 ) : '0000-00-00'; + const end = endDate ? endDate.substr( 0, 10 ) : '0000-00-00'; + + query.push( `dateRange=${ start }:${ end }` ); + } + + return query.length > 0 ? query : null; +} diff --git a/extensions/shared/external-media/sources/google-photos/filter-view.js b/extensions/shared/external-media/sources/google-photos/filter-view.js new file mode 100644 index 0000000000000..0bf78a369afec --- /dev/null +++ b/extensions/shared/external-media/sources/google-photos/filter-view.js @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Fragment, useState } from '@wordpress/element'; +import { SelectControl, Button } from '@wordpress/components'; + +const FILTERS = [ + { label: __( 'Category', 'jetpack' ), value: 'category' }, + { label: __( 'After date', 'jetpack' ), value: 'startDate' }, + { label: __( 'Before date', 'jetpack' ), value: 'endDate' }, + { label: __( 'Favorites', 'jetpack' ), value: 'favorite' }, + { label: __( 'Media Type', 'jetpack' ), value: 'mediaType' }, +]; + +function getFilterOptions( filters ) { + return FILTERS.filter( item => filters[ item.value ] === undefined ); +} + +function removeMediaType( filters, canUseMedia ) { + if ( canUseMedia ) { + return filters; + } + + return filters.filter( item => item.value !== 'mediaType' ); +} + +function getFirstFilter( filters ) { + const filtered = getFilterOptions( filters ); + + if ( filtered.length > 0 ) { + return filtered[ 0 ].value; + } + + return ''; +} + +function addFilter( existing, newFilter ) { + return { + ...existing, + [ newFilter ]: newFilter === 'favorite' ? true : '', + }; +} + +function GoogleFilterView( props ) { + const [ currentFilter, setCurrentFilter ] = useState( getFirstFilter( [] ) ); + const { isLoading, filters, canChangeMedia } = props; + const remainingFilters = removeMediaType( getFilterOptions( filters ), canChangeMedia ); + const setFilter = () => { + const newFilters = addFilter( filters, currentFilter ); + + props.setFilters( newFilters ); + setCurrentFilter( getFirstFilter( newFilters ) ); + }; + + if ( remainingFilters.length === 0 ) { + return null; + } + + return ( + + + + + + ); +} + +export default GoogleFilterView; diff --git a/extensions/shared/external-media/sources/google-photos/google-photos-auth.js b/extensions/shared/external-media/sources/google-photos/google-photos-auth.js new file mode 100644 index 0000000000000..a5830852ee083 --- /dev/null +++ b/extensions/shared/external-media/sources/google-photos/google-photos-auth.js @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import requestExternalAccess from '@automattic/request-external-access'; + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { __ } from '@wordpress/i18n'; +import { useState, useCallback } from '@wordpress/element'; +import { Button } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { SOURCE_GOOGLE_PHOTOS } from '../../constants'; +import { getApiUrl } from '../api'; +import AuthInstructions from './auth-instructions'; +import AuthProgress from './auth-progress'; + +function GooglePhotosAuth( props ) { + const { getMedia } = props; + + const [ isAuthing, setIsAuthing ] = useState( false ); + + const onAuthorize = useCallback( () => { + setIsAuthing( true ); + + // Get connection details + apiFetch( { + path: getApiUrl( 'connection', SOURCE_GOOGLE_PHOTOS ), + } ) + .then( service => { + if ( service.error ) { + throw service.message; + } + + // Open authorize URL in a window and let it play out + requestExternalAccess( service.connect_URL, () => { + setIsAuthing( false ); + const url = getApiUrl( 'list', SOURCE_GOOGLE_PHOTOS ); + getMedia( url, true ); + } ); + } ) + .catch( () => { + // Not much we can tell the user at this point so let them try and auth again + setIsAuthing( false ); + } ); + }, [ getMedia ] ); + + return ( +
    + { isAuthing ? : } + + +
    + ); +} + +export default GooglePhotosAuth; diff --git a/extensions/shared/external-media/sources/google-photos/google-photos-media.js b/extensions/shared/external-media/sources/google-photos/google-photos-media.js new file mode 100644 index 0000000000000..b033675f99acc --- /dev/null +++ b/extensions/shared/external-media/sources/google-photos/google-photos-media.js @@ -0,0 +1,132 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useRef, useState, useCallback, useEffect } from '@wordpress/element'; +import { SelectControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { SOURCE_GOOGLE_PHOTOS, PATH_RECENT, PATH_ROOT, PATH_OPTIONS } from '../../constants'; +import MediaBrowser from '../../media-browser'; +import { getApiUrl } from '../api'; +import GoogleFilterOption from './filter-option'; +import GoogleFilterView from './filter-view'; +import Breadcrumbs from './breadcrumbs'; +import getFilterRequest from './filter-request'; + +const isImageOnly = allowed => allowed && allowed.length === 1 && allowed[ 0 ] === 'image'; + +function GooglePhotosMedia( props ) { + const { + media, + isLoading, + pageHandle, + multiple, + onChangePath, + getMedia, + allowedTypes, + path, + copyMedia, + } = props; + + const imageOnly = isImageOnly( allowedTypes ); + const [ filters, setFilters ] = useState( imageOnly ? { mediaType: 'photo' } : {} ); + + const lastQuery = useRef( '' ); + const lastPath = useRef( '' ); + const filterQuery = path.ID === PATH_RECENT ? getFilterRequest( filters ) : null; + const params = { + number: 20, + path: path.ID, + }; + if ( filterQuery ) { + params.filter = filterQuery; + } + + const listUrl = getApiUrl( 'list', SOURCE_GOOGLE_PHOTOS, params ); + + const getNextPage = useCallback( + ( event, reset = false ) => { + getMedia( listUrl, reset ); + }, + [ getMedia, listUrl ] + ); + + const setPath = useCallback( + nextPath => { + const album = media.find( item => item.ID === nextPath ); + lastPath.current = path; + onChangePath( album ? album : { ID: nextPath } ); + }, + [ media, onChangePath, lastPath, path ] + ); + + const onCopy = useCallback( + items => { + copyMedia( items, getApiUrl( 'copy', SOURCE_GOOGLE_PHOTOS ) ); + }, + [ copyMedia ] + ); + + // Load media when the query changes. + useEffect( () => { + if ( lastQuery !== listUrl ) { + lastQuery.current = listUrl; + getNextPage( {}, path !== lastPath.current ); + } + }, [ lastQuery, listUrl, getNextPage, path ] ); + + return ( +
    +
    + + + { path.ID === PATH_RECENT && ( + + ) } +
    + +
    + { path.ID === PATH_RECENT && ( + + ) } + { path.ID !== PATH_RECENT && path.ID !== PATH_ROOT && ( + + ) } +
    + + +
    + ); +} + +export default GooglePhotosMedia; diff --git a/extensions/shared/external-media/sources/google-photos/index.js b/extensions/shared/external-media/sources/google-photos/index.js new file mode 100644 index 0000000000000..b387cfd7147a1 --- /dev/null +++ b/extensions/shared/external-media/sources/google-photos/index.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import withMedia from '../with-media'; +import GooglePhotosAuth from './google-photos-auth'; +import GooglePhotosMedia from './google-photos-media'; + +function GooglePhotos( props ) { + if ( props.requiresAuth ) { + return ; + } + + return ; +} + +export default withMedia()( GooglePhotos ); diff --git a/extensions/shared/external-media/sources/index.js b/extensions/shared/external-media/sources/index.js new file mode 100644 index 0000000000000..292a66d7ed06e --- /dev/null +++ b/extensions/shared/external-media/sources/index.js @@ -0,0 +1,67 @@ +/** + * WordPress dependencies + */ + +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { GooglePhotosIcon, PexelsIcon } from '../../icons'; +import GooglePhotosMedia from './google-photos'; +import PexelsMedia from './pexels'; +import { SOURCE_WORDPRESS, SOURCE_GOOGLE_PHOTOS, SOURCE_PEXELS } from '../constants'; + +export const mediaSources = [ + { + id: SOURCE_GOOGLE_PHOTOS, + label: __( 'Google Photos', 'jetpack' ), + icon: , + keyword: 'google photos', + }, + { + id: SOURCE_PEXELS, + label: __( 'Pexels Free Photos', 'jetpack' ), + icon: , + keyword: 'pexels', + }, +]; + +export function canDisplayPlaceholder( props ) { + const { disableMediaButtons, dropZoneUIOnly } = props; + + // Deprecated. May still be used somewhere + if ( dropZoneUIOnly === true ) { + return false; + } + + /** + * This is a new prop that is false when editing an image (and the placeholder + * should be shown), and contains a URL when not editing (and the placeholder + * shouldnt be shown). The docs say it should be strictly boolean, hence the + * inverse logic. + */ + if ( disableMediaButtons !== undefined && disableMediaButtons !== false ) { + return false; + } + + if ( props.source === SOURCE_WORDPRESS ) { + return false; + } + + return true; +} + +export function getExternalLibrary( type ) { + if ( type === SOURCE_PEXELS ) { + return PexelsMedia; + } else if ( type === SOURCE_GOOGLE_PHOTOS ) { + return GooglePhotosMedia; + } + + return null; +} + +export function getExternalSource( type ) { + return mediaSources.find( item => item.id === type ); +} diff --git a/extensions/shared/external-media/sources/pexels.js b/extensions/shared/external-media/sources/pexels.js new file mode 100644 index 0000000000000..0e0c1c8c8d192 --- /dev/null +++ b/extensions/shared/external-media/sources/pexels.js @@ -0,0 +1,116 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useRef, useCallback, useState, useEffect } from '@wordpress/element'; +import { TextControl, Button } from '@wordpress/components'; +import { sample } from 'lodash'; + +/** + * Internal dependencies + */ +import { SOURCE_PEXELS, PEXELS_EXAMPLE_QUERIES } from '../constants'; +import withMedia from './with-media'; +import MediaBrowser from '../media-browser'; +import { getApiUrl } from './api'; + +function PexelsMedia( props ) { + const { media, isLoading, pageHandle, multiple, copyMedia, getMedia } = props; + + const [ searchQuery, setSearchQuery ] = useState( sample( PEXELS_EXAMPLE_QUERIES ) ); + const [ lastSearchQuery, setLastSearchQuery ] = useState( '' ); + + const onCopy = useCallback( + items => { + copyMedia( items, getApiUrl( 'copy', SOURCE_PEXELS ) ); + }, + [ copyMedia ] + ); + + const getNextPage = useCallback( + ( event, reset = false ) => { + if ( searchQuery ) { + getMedia( + getApiUrl( 'list', SOURCE_PEXELS, { + number: 20, + path: 'recent', + search: searchQuery, + } ), + reset + ); + } + }, + [ getMedia, searchQuery ] + ); + + const previousSearchQueryValue = useRef(); + const onSearch = useCallback( + event => { + event.preventDefault(); + setLastSearchQuery( searchQuery ); + getNextPage( event, true ); + previousSearchQueryValue.current = searchQuery; + }, + [ getNextPage, searchQuery ] + ); + + // Load initial results for the random example query. + useEffect( getNextPage, [] ); + + const searchFormEl = useRef( null ); + + const focusSearchInput = () => { + if ( ! searchFormEl.current ) { + return; + } + + const formElements = Array.from( searchFormEl.current.elements ); + // TextControl does not support ref forwarding, so we need to find the input: + const searchInputEl = formElements.find( element => element.type === 'search' ); + + if ( searchInputEl ) { + searchInputEl.focus(); + searchInputEl.select(); + } + }; + + useEffect( focusSearchInput, [] ); + + return ( +
    +
    + + + + + +
    + ); +} + +export default withMedia()( PexelsMedia ); diff --git a/extensions/shared/external-media/sources/with-media.js b/extensions/shared/external-media/sources/with-media.js new file mode 100644 index 0000000000000..cf1bebc87499e --- /dev/null +++ b/extensions/shared/external-media/sources/with-media.js @@ -0,0 +1,224 @@ +/** + * External dependencies + */ +import { uniqBy } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { Component } from '@wordpress/element'; +import { withNotices, Modal } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { PATH_RECENT } from '../constants'; +import MediaItem from '../media-browser/media-item'; + +const CopyingMedia = ( { items } ) => { + const classname = + items.length === 1 + ? 'jetpack-external-media-browser__single' + : 'jetpack-external-media-browser'; + + return ( +
    +
    + { items.map( item => ( + + ) ) } +
    +
    + ); +}; + +export default function withMedia() { + return createHigherOrderComponent( OriginalComponent => { + // Grandfathered class as it was ported from an older codebase. + class WithMediaComponent extends Component { + constructor( props ) { + super( props ); + + this.state = { + media: [], + nextHandle: false, + isLoading: false, + isCopying: null, + requiresAuth: false, + path: { ID: PATH_RECENT }, + }; + } + + mergeMedia( initial, media ) { + return uniqBy( initial.concat( media ), 'ID' ); + } + + getRequestUrl( base ) { + const { nextHandle } = this.state; + + if ( nextHandle ) { + return base + '&page_handle=' + encodeURIComponent( nextHandle ); + } + + return base; + } + + getMedia = ( url, resetMedia = false ) => { + if ( this.state.isLoading ) { + return; + } + + if ( resetMedia ) { + this.props.noticeOperations.removeAllNotices(); + } + + this.setState( + { + isLoading: true, + media: resetMedia ? [] : this.state.media, + nextHandle: resetMedia ? false : this.state.nextHandle, + }, + () => this.getMediaRequest( url ) + ); + }; + + handleApiError = error => { + if ( error.code === 'authorization_required' ) { + this.setState( { requiresAuth: true, isLoading: false, isCopying: false } ); + return; + } + + const { noticeOperations } = this.props; + + noticeOperations.createErrorNotice( + error.code === 'internal_server_error' ? 'Internal server error' : error.message + ); + + this.setState( { isLoading: false, isCopying: false } ); + }; + + getMediaRequest = url => { + const { nextHandle, media } = this.state; + + if ( nextHandle === false && media.length > 0 ) { + /** + * Tried to make a request with no nextHandle. This can happen because + * InfiniteScroll sometimes triggers a request when the number of + * items is less than the scroll area. It should really be fixed + * there, but until that time... + */ + this.setState( { + isLoading: false, + } ); + + return; + } + + const path = this.getRequestUrl( url ); + const method = 'GET'; + + this.setState( { requiresAuth: false } ); + + apiFetch( { + path, + method, + parse: window.wpcomFetch === undefined, + } ) + .then( result => { + this.setState( { + media: this.mergeMedia( media, result.media ), + nextHandle: result.meta.next_page, + isLoading: false, + } ); + } ) + .catch( this.handleApiError ); + }; + + copyMedia = ( items, apiUrl ) => { + this.setState( { isCopying: items } ); + this.props.noticeOperations.removeAllNotices(); + + apiFetch( { + path: apiUrl, + method: 'POST', + data: { + media: items.map( item => ( { + guid: item.guid, + caption: item.caption, + title: item.title, + } ) ), + }, + } ) + .then( result => { + const { value, addToGallery, multiple } = this.props; + const media = multiple ? result : result[ 0 ]; + + this.props.onClose(); + + // Select the image(s). This will close the modal + this.props.onSelect( addToGallery ? value.concat( result ) : media ); + } ) + .catch( this.handleApiError ); + }; + + onChangePath = ( path, cb ) => { + this.setState( { path }, cb ); + }; + + stopPropagation( event ) { + event.stopPropagation(); + } + + renderContent() { + const { media, isLoading, nextHandle, requiresAuth, path } = this.state; + const { noticeUI, allowedTypes, multiple = false } = this.props; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
    + { noticeUI } + + +
    + ); + } + + render() { + const { isCopying } = this.state; + const { onClose } = this.props; + + const classes = classnames( { + 'jetpack-external-media-browser': true, + 'jetpack-external-media-browser__is-copying': isCopying, + } ); + + return ( + + { isCopying ? : this.renderContent() } + + ); + } + } + + return withNotices( WithMediaComponent ); + } ); +} diff --git a/extensions/shared/icons.js b/extensions/shared/icons.js index 4925faf6258a0..5bc2fc1442781 100644 --- a/extensions/shared/icons.js +++ b/extensions/shared/icons.js @@ -4,6 +4,76 @@ import { Path, Polygon, SVG } from '@wordpress/components'; import classNames from 'classnames'; +export const MediaLibraryIcon = () => ( + + + + +); + +export const GooglePhotosIcon = () => ( + + + +); + +export const PexelsIcon = () => ( + + + + +); + +export const GooglePhotosLogo = () => ( + + + + + + + + + + +); + export const JetpackLogo = ( { size = 24, className } ) => (