├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── cfn ├── configs │ ├── customembed │ │ └── us-east-1 │ │ │ └── config.yml │ └── tags │ │ ├── global.yml │ │ └── prod.yml ├── manifest.yml └── templates │ └── static-site.yml ├── deployspec.yml ├── docs ├── getting-started.md ├── reference.md └── testing.md ├── package-lock.json ├── package.json ├── public ├── audio │ ├── audio0.mp3 │ ├── audio1.mp3 │ ├── audio2.mp3 │ ├── audio3.mp3 │ ├── cover0.png │ ├── cover1.jpg │ ├── cover2.jpg │ └── cover3.jpg ├── audioEditApi.html ├── audioSearchApi.html ├── audioViewApi.html ├── blank │ ├── edit.html │ ├── search.html │ └── view.html ├── css │ ├── all.min.css │ └── app.css ├── editApi.html ├── favicon.ico ├── img │ ├── list-play-hover.png │ ├── list-play-light.png │ ├── mute.svg │ ├── now-playing.svg │ ├── pause.svg │ ├── play.svg │ └── volume.svg ├── index.html ├── searchApi.html ├── viewApi.html └── webfonts │ ├── fa-brands-400.eot │ ├── fa-brands-400.svg │ ├── fa-brands-400.ttf │ ├── fa-brands-400.woff │ ├── fa-brands-400.woff2 │ ├── fa-regular-400.eot │ ├── fa-regular-400.svg │ ├── fa-regular-400.ttf │ ├── fa-regular-400.woff │ ├── fa-regular-400.woff2 │ ├── fa-solid-900.eot │ ├── fa-solid-900.svg │ ├── fa-solid-900.ttf │ ├── fa-solid-900.woff │ └── fa-solid-900.woff2 ├── src ├── App.css ├── App.test.js ├── App.tsx ├── EditPanel.tsx ├── Header.tsx ├── IframeHost.tsx ├── SearchPanel.tsx ├── SettingsInput.tsx ├── ViewPanel.tsx ├── index.css ├── index.tsx ├── logo.svg └── react-app-env.d.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | sourceType: 'module', 5 | // https://github.com/typescript-eslint/typescript-eslint/issues/251#issuecomment-463943250 6 | tsconfigRootDir: __dirname, 7 | project: 'tsconfig.json', 8 | ecmaFeatures: { 9 | jsx: true 10 | } 11 | }, 12 | extends: [ 13 | 'react-app', 14 | 'plugin:@typescript-eslint/recommended', 15 | 'plugin:react/recommended', 16 | 'plugin:jsx-a11y/recommended', 17 | // prettier configs should come last to avoid rule conflicts 18 | // https://github.com/prettier/eslint-config-prettier#installation 19 | 'plugin:prettier/recommended', 20 | 'prettier/@typescript-eslint', 21 | 'prettier/standard', 22 | 'prettier/react' 23 | ], 24 | plugins: [ 25 | 'react-hooks', 26 | '@typescript-eslint', 27 | 'prettier', 28 | 'react', 29 | 'standard' 30 | ], 31 | env: { 32 | browser: true, 33 | jest: true 34 | }, 35 | rules: { 36 | 'react-hooks/rules-of-hooks': 'error', 37 | 'react-hooks/exhaustive-deps': 'warn', 38 | '@typescript-eslint/interface-name-prefix': 'off', 39 | '@typescript-eslint/explicit-function-return-type': 'off' 40 | }, 41 | overrides: [ 42 | { 43 | files: ['*locale.js'], 44 | rules: { 45 | camelcase: 'off', 46 | '@typescript-eslint/camelcase': 'off' 47 | } 48 | } 49 | ], 50 | settings: { 51 | react: { 52 | version: 'detect' 53 | } 54 | }, 55 | globals: { 56 | $: 'readonly' 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 63 | 64 | # dependencies 65 | /node_modules 66 | /.pnp 67 | .pnp.js 68 | 69 | # testing 70 | /coverage 71 | 72 | # production 73 | /build 74 | 75 | # misc 76 | .DS_Store 77 | .env.local 78 | .env.development.local 79 | .env.test.local 80 | .env.production.local 81 | 82 | npm-debug.log* 83 | yarn-debug.log* 84 | yarn-error.log* 85 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Custom Embed Reference Implementation and Testing Tool 2 | 3 | ## Overview 4 | 5 | This project contains: 6 | * several reference implementations of Arc Custom Embeds 7 | * a "blank" custom embed implementation for quick-start 8 | * a host application for testing without using Composer 9 | 10 | ## Documentation 11 | 12 | For Arc users with PageBuilder Fusion running, we recommend walking through the process of building an embed first. 13 | 14 | [Getting Started with Custom Embeds](./docs/getting-started.md) 15 | 16 | Whether you're using Fusion or not, it may help to look at our starter code or reference implementations. 17 | 18 | [Starter Code](./public/blank) 19 | 20 | [Reference Implementations](./public/) 21 | 22 | For all users, we suggest running the testing tool: 23 | 24 | [Testing Tool Docs](./docs/testing.md) 25 | 26 | For more information, see the complete embed specification: 27 | 28 | [Custom Embed Specification](./docs/reference.md) 29 | 30 | ## Bugfixes 31 | 32 | A previous version of this example contained a bug that would result in spaces being decoded as plus (+) symbols. This 33 | was corrected on Sept 26th, 2019, but any work built off of an earlier version of this repo should be reviewed and 34 | modified if necessary. See [the following pull request](https://github.com/washingtonpost/arc-custom-embed/pull/6) for 35 | the changes applied. 36 | -------------------------------------------------------------------------------- /cfn/configs/customembed/us-east-1/config.yml: -------------------------------------------------------------------------------- 1 | template: cfn/templates/static-site.yml 2 | stack_name: static-site-customembed-prod 3 | tag_file: 4 | - cfn/configs/tags/global.yml 5 | - cfn/configs/tags/prod.yml 6 | region: us-east-1 7 | context: 8 | name: customembed 9 | environment: prod 10 | 11 | site: 12 | # Required, Root domain name for the website.) 13 | domain: customembed.ellipsis.aws.arc.pub 14 | 15 | # Required, Domain name to lookup Route53 Hosted Zone. (can be equal to domain) 16 | zone_apex: ellipsis.aws.arc.pub 17 | 18 | # Not required, Subdomain name of the website, usually includes environment name 19 | #subdomain: prod 20 | 21 | # Required, don't deploy static sites without https 22 | certificate: arn:aws:acm:us-east-1:192577119545:certificate/e8ffe5a3-46f9-4720-9e92-c17af4d7342a 23 | -------------------------------------------------------------------------------- /cfn/configs/tags/global.yml: -------------------------------------------------------------------------------- 1 | App: arc-custom-embed -------------------------------------------------------------------------------- /cfn/configs/tags/prod.yml: -------------------------------------------------------------------------------- 1 | Env: Prod -------------------------------------------------------------------------------- /cfn/manifest.yml: -------------------------------------------------------------------------------- 1 | templates: 2 | shared: 3 | source: v1/cfn/shared 4 | version: 1a61d8026fb21b105382c7dc24529c431434e8d3 5 | plugins: 6 | static-site: 7 | cfn/templates/static-site.yml: 1a61d8026fb21b105382c7dc24529c431434e8d3 8 | deployspec.yml: 1a61d8026fb21b105382c7dc24529c431434e8d3 9 | cfn/configs/customembed/us-east-1/config.yml: 1a61d8026fb21b105382c7dc24529c431434e8d3 10 | cfn/configs/customembed/config.yml: 1a61d8026fb21b105382c7dc24529c431434e8d3 11 | cfn/templates/pipeline.template.yml: 1a61d8026fb21b105382c7dc24529c431434e8d3 12 | -------------------------------------------------------------------------------- /cfn/templates/static-site.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Metadata: 3 | Stackjack: 4 | CanonicalTemplate: v1/cfn/shared/static-site/static-site.template.yml 5 | AllowDestroy: 6 | - CloudFrontDistribution 7 | 8 | Parameters: 9 | RootDomainName: 10 | Description: Root domain name for the website. 11 | Type: String 12 | Default: {{ site.domain }} 13 | ZoneApex: 14 | Description: Domain name to lookup Route53 Hosted Zone. 15 | Type: String 16 | Default: {{ site.zone_apex }} 17 | SiteDomainName: 18 | Description: FQDN of the website. 19 | Type: String 20 | Default: {% if site.subdomain is defined %}{{ site.subdomain }}.{% endif %}{{ site.domain }} 21 | CertificateARN: 22 | Type: String 23 | Description: the Amazon Resource Name (ARN) of an AWS Certificate Manager (ACM) certificate. 24 | Default: {{ site.certificate }} 25 | AllowedPattern: "arn:aws:acm:.*" 26 | 27 | Resources: 28 | # Common resources for all environments 29 | OriginAccessIdentity: 30 | Type: "AWS::CloudFront::CloudFrontOriginAccessIdentity" 31 | Properties: 32 | CloudFrontOriginAccessIdentityConfig: 33 | Comment: This configuration blocks direct access to the S3 bucket so that all traffic goes through CloudFront 34 | 35 | SiteBucket: 36 | Type: AWS::S3::Bucket 37 | Properties: 38 | BucketName: !Ref SiteDomainName 39 | AccessControl: PublicRead 40 | WebsiteConfiguration: 41 | IndexDocument: "index.html" 42 | ErrorDocument: "404.html" 43 | Tags: 44 | - Key: Name 45 | Value: !Ref SiteDomainName 46 | 47 | BucketPolicy: 48 | Type: AWS::S3::BucketPolicy 49 | Properties: 50 | PolicyDocument: 51 | Id: SiteBucketPolicy 52 | Version: 2012-10-17 53 | Statement: 54 | - Sid: PublicReadForGetBucketObjects 55 | Effect: Allow 56 | Principal: 57 | CanonicalUser: !GetAtt OriginAccessIdentity.S3CanonicalUserId 58 | Action: 's3:GetObject' 59 | Resource: !Join ['', [!GetAtt SiteBucket.Arn, '/*']] 60 | Bucket: !Ref SiteBucket 61 | 62 | CloudFrontDistribution: 63 | Type: AWS::CloudFront::Distribution 64 | DependsOn: 65 | - SiteBucket 66 | - OriginAccessIdentity 67 | Properties: 68 | DistributionConfig: 69 | Origins: 70 | - DomainName: !GetAtt SiteBucket.DomainName 71 | Id: S3Origin 72 | S3OriginConfig: 73 | OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${OriginAccessIdentity} 74 | Enabled: true 75 | HttpVersion: 'http2' 76 | DefaultRootObject: index.html 77 | Aliases: 78 | - !Ref SiteDomainName 79 | DefaultCacheBehavior: 80 | AllowedMethods: 81 | - GET 82 | - HEAD 83 | Compress: true 84 | TargetOriginId: S3Origin 85 | ForwardedValues: 86 | QueryString: true 87 | Cookies: 88 | Forward: none 89 | ViewerProtocolPolicy: redirect-to-https 90 | PriceClass: {{ site.price_class|default("PriceClass_All") }} 91 | ViewerCertificate: 92 | AcmCertificateArn: !Ref CertificateARN 93 | SslSupportMethod: sni-only 94 | {% if site.custom_error_responses is defined %} 95 | CustomErrorResponses: 96 | {% for error_response in site.custom_error_responses %} 97 | - ErrorCachingMinTTL: {{ error_response.caching_min_ttl|default(300) }} 98 | ErrorCode: {{ error_response.error_code }} 99 | ResponseCode: {{ error_response.response_code }} 100 | ResponsePagePath: "{{ error_response.page_path }}" 101 | {% endfor %} 102 | {% endif %} 103 | 104 | CloudFrontDomainRecord: 105 | Type: AWS::Route53::RecordSetGroup 106 | Properties: 107 | HostedZoneName: !Sub ${ZoneApex}. 108 | Comment: Zone apex alias. 109 | RecordSets: 110 | - Name: !Ref SiteDomainName 111 | Type: A 112 | AliasTarget: 113 | # Hard-coded per https://docs.aws.amazon.com/Route53/latest/APIReference/API_AliasTarget.html 114 | HostedZoneId: Z2FDTNDATAQYW2 115 | DNSName: !GetAtt CloudFrontDistribution.DomainName 116 | 117 | Outputs: 118 | 119 | CloudFrontDistributionId: 120 | Description: Cloudfront distribution id 121 | Value: !Ref CloudFrontDistribution 122 | Export: 123 | Name: !Sub ${AWS::StackName}-cloudfront-distribution-id 124 | -------------------------------------------------------------------------------- /deployspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | env: 3 | variables: 4 | ENVIRONMENT: 'prod' 5 | phases: 6 | build: 7 | commands: 8 | - npm install && npm run build 9 | - echo Deployment started on `date` 10 | - aws s3 sync build/ s3://customembed.ellipsis.aws.arc.pub --exclude index.html --cache-control max-age=31536000 11 | - aws s3 cp build/index.html s3://customembed.ellipsis.aws.arc.pub/index.html --metadata-directive REPLACE --cache-control "max-age=60,s-maxage=31536000" 12 | post_build: 13 | commands: 14 | - DIST_ID=ETKFQ9SXRF4F9 15 | # $(aws cloudformation list-exports --region us-east-1 --output text --query 'Exports[?Name==`static-site-customembed-${ENVIRONMENT}-cloudfront-distribution-id`]' | cut -d$'\t' -f3) 16 | - echo DIST_ID=$DIST_ID 17 | - aws cloudfront create-invalidation --distribution-id $DIST_ID --paths "/*" 18 | - echo Deployment completed on `date` 19 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Composer Custom Embed: Getting Started 2 | 3 | ## Use Cases 4 | 5 | Custom Embeds is a feature for Arc Composer that allows developers to build content elements in stories that store an embedded link to a piece of content that lives outside Arc. 6 | 7 | ## Requirements 8 | 9 | To follow along with this guide, you will need: 10 | 11 | * Basic knowledge of HTML, Javascript, and the web 12 | * Basic knowledge of Arc Composer and [ANS](https://www.github.com/washingtonpost/ans-schema) 13 | * Basic experience with React components and content sources in PageBuilder Fusion 14 | * Permissions to administer and configure both Composer and PageBuilder Fusion in your Arc environment. 15 | * A working Fusion environment, complete with origin and public internet domain name. (I.e., you can see your fusion code running at www.mysite.com) 16 | 17 | ## Goal 18 | 19 | This guide will demonstrate how to build a "Movie" custom embed using [OMDB](https://www.omdbapi.com/). 20 | 21 | At the end of this guide, you will know how to: 22 | * Create a content source in Fusion that proxies to their external content 23 | * Create a search panel in Composer for users to find external content 24 | * Create a view panel in Composer for users to see external content embedded in their story 25 | * Create an edit panel in Composer for users to configure presentation options for the embed 26 | * Render a custom embed in Fusion so that readers can see the content inline 27 | 28 | ## Limitations and Warnings 29 | 30 | The Custom Embeds workflow is made up of "panels." Currently, these panels must reside on the *public internet*. This means that they are accessible by anyone in the world with internet access. Therefore we ask that you adhere to a few key restrictions: 31 | 32 | * The content exposed in these panels should only include published, public content. You should not search against unpublished content, data that is private to an organization or an individual, or content with legal restrictions placed on it. 33 | * None of these panels should write or modify the data they expose in the data's original source. The panels function only by sending and receiving data from Composer via the user's browser. All edited data should be saved within the Composer ANS document, and nowhere else. 34 | 35 | ## Steps 36 | 37 | ### 1. Identify or create endpoints for your external data. 38 | 39 | Your external data source should provide public GET endpoints as specified above for fetching and searching for content. 40 | These may already exist, or in some cases you may be able to create them yourself. 41 | 42 | This guide will use the [OMDB API](https://www.omdbapi.com/) as an example data source. 43 | 44 | Here's an example endpoint for fetching content by ID: 45 | 46 | * https://www.omdbapi.com/?apikey=YOUR_API_KEY_HERE&plot=full&i=tt3969158 47 | 48 | Here's an example endpoint for search for content by name: 49 | 50 | * https://www.omdbapi.com/?apikey=YOUR_API_KEY_HERE&s=Jurassic 51 | 52 | > **What about *my* content?** 53 | > 54 | > To implement an Arc Composer Custom Embed for your external content, your data source will need to: 55 | > 56 | > * ...expose an HTTP GET endpoint for fetching content metadata by id 57 | > * ...expose an HTTP GET endpoint for querying for content in your content source as a Composer user would 58 | > * ...**without** exposing confidential, unpublished or otherwise private content or data 59 | > * ...and **without** submitting new contnet or modifying content in the external data source 60 | > 61 | > These last two restrictions are in place because your endpoints will be indirectly exposed on the **public internet**, 62 | > so it important that they do not expose anything that is not already discoverable on the web. 63 | 64 | 65 | 66 | ### 2. Build a content source in PageBuilder Fusion 67 | 68 | We'll need two content sources set up in Fusion. 69 | 70 | The first content source will be used for rendering a single embed on the website as well as the view and edit panels. It will need to fetch a piece of content by id and return its metadata. 71 | 72 | Let's modify the existing movie-find content source from the Fusion recipes to accept an `id` parameter in addition to the `title` parameter. 73 | 74 | ```javascript 75 | /* /content/sources/movie-find.js */ 76 | 77 | import { OMDB_API_KEY } from 'fusion:environment' 78 | 79 | const resolve = (query) => { 80 | const requestUri = `https://www.omdbapi.com/?apikey=${OMDB_API_KEY}&plot=full` 81 | 82 | if (query.hasOwnProperty('movieTitle')) { 83 | return `${requestUri}&t=${query.movieTitle}` 84 | } else if (query.hasOwnProperty('imdbID')) { 85 | return `${requestUri}&i=${query.imdbID}` 86 | } 87 | 88 | throw new Error('movie-find content source requires a movieTitle or imdbID') 89 | } 90 | 91 | export default { 92 | resolve, 93 | params: { 94 | movieTitle: 'text', 95 | imdbID: 'text' 96 | } 97 | } 98 | ``` 99 | 100 | This will allow us to make client-side calls like the following in our Composer Custom Embed: 101 | `/pf/api/v3/content/fetch/movie-find?query={"imdbID":"tt0107290"}` 102 | 103 | 104 | We'll need one additional content source for our search panel to find a list of movies that match a certain title. Let's create a new content source `movie-search`: 105 | 106 | ```javascript 107 | /* /content/sources/movie-search.js */ 108 | 109 | import { OMDB_API_KEY } from 'fusion:environment' 110 | 111 | const resolve = (query) => { 112 | const requestUri = `https://www.omdbapi.com/?apikey=${OMDB_API_KEY}&plot=full` 113 | 114 | let query_string = '' 115 | 116 | if (query.hasOwnProperty('text')) { 117 | query_string = `${requestUri}&s=${query.text}&type=movie` 118 | } 119 | else { 120 | throw new Error('movie-search content source requires text') 121 | } 122 | 123 | if (query.hasOwnProperty('year')) { 124 | query_string += `&y={query.year}` 125 | } 126 | 127 | return query_string 128 | } 129 | 130 | export default { 131 | resolve, 132 | params: { 133 | text: 'text', 134 | year: 'text' 135 | } 136 | } 137 | 138 | ``` 139 | 140 | 141 | 142 | 143 | > **Why do this? Why not use the public endpoints?** 144 | > 145 | > * It allows our external content to leverage Fusion's object caching, preventing your external content from getting overwhelmed with requests. 146 | > * It prevents your API key from being exposed client-side. 147 | > * It allows you to do your entire rendering of the external content server-side, or client-side, as you'll soon see. 148 | 149 | ### 3. Create a search panel for your custom embed. 150 | 151 | We can get started by copying the [starter search code](https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/master/public/starter/search.html). The best practice is to save static html files to /resources/plugins/composer/embeds/movie/search.html 152 | 153 | Note: It is required that all static html files to be saved in the `/resources/plugins/composer` directory. If saved outside of this folder, a deployment version parameter (d) will be requested, and will have to be updated with every deployment, making it unmaintainable. 154 | 155 | Unfortunately, this base embed doesn't do very much. We'll need to add functionality. 156 | 157 | First, let's define our search form. Our users only really want to find movies by name, so we'll stick to a single text field and a Search button. 158 | 159 | Edit the section under `` to be the following: 160 | 161 | ```html 162 | 163 |

164 | Movie Finder 165 |

166 |

167 | Let's go to the movies 168 |

169 |

170 |

171 | 174 | 175 | ``` 176 | 177 | That's straightforward. But it still doesn't do very much. So let's go implement handleSearch(). 178 | 179 | Here's where things get interesting. We can now call the content source we built in Step 2 to retrieve data for our search results. We can use the Fusion HTTP endpoint for fetching data from a content source. Let's check the data first to make sure we know what the data from our content source looks like. Hit this URL in your browser: 180 | 181 | http://localhost/pf/api/v3/content/fetch/movie-search?query={%22text%22:%22Jurassic%22} 182 | 183 | This gives us back data like: 184 | 185 | ```json 186 | {"Search":[{"Title":"Jurassic Park","Year":"1993","imdbID":"tt0107290","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BMjM2MDgxMDg0Nl5BMl5BanBnXkFtZTgwNTM2OTM5NDE@._V1_SX300.jpg"},{"Title":"Jurassic World","Year":"2015","imdbID":"tt0369610","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BNzQ3OTY4NjAtNzM5OS00N2ZhLWJlOWUtYzYwZjNmOWRiMzcyXkEyXkFqcGdeQXVyMTMxODk2OTU@._V1_SX300.jpg"},{"Title":"The Lost World: Jurassic Park","Year":"1997","imdbID":"tt0119567","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BMDFlMmM4Y2QtNDg1ZS00MWVlLTlmODgtZDdhYjY5YjdhN2M0XkEyXkFqcGdeQXVyNTI4MjkwNjA@._V1_SX300.jpg"},{"Title":"Jurassic Park III","Year":"2001","imdbID":"tt0163025","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BZDMyZGJjOGItYjJkZC00MDVlLWE0Y2YtZGIwMDExYWE3MGQ3XkEyXkFqcGdeQXVyNDYyMDk5MTU@._V1_SX300.jpg"},{"Title":"Jurassic World: Fallen Kingdom","Year":"2018","imdbID":"tt4881806","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BNzIxMjYwNDEwN15BMl5BanBnXkFtZTgwMzk5MDI3NTM@._V1_SX300.jpg"},{"Title":"Jurassic Shark","Year":"2012","imdbID":"tt2071491","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BODI1ODAyODgtZDYzZS00ZTM2LTg5MzMtZjNjMDFjMzlkZGQ2XkEyXkFqcGdeQXVyMTg0MTI3Mg@@._V1_SX300.jpg"},{"Title":"Jurassic City","Year":"2015","imdbID":"tt2905674","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BMjM1MzUyMTk5MV5BMl5BanBnXkFtZTgwOTc2NzA0NDE@._V1_SX300.jpg"},{"Title":"The Jurassic Games","Year":"2018","imdbID":"tt6710826","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BZWJkMzE4ZTAtOTY1ZS00YmZjLWI2MzQtZTg4MzdiN2U4NmUyXkEyXkFqcGdeQXVyMTUwMzY1MDM@._V1_SX300.jpg"},{"Title":"Jurassic Prey","Year":"2015","imdbID":"tt3469284","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BMTQ4MDg1NDkyNl5BMl5BanBnXkFtZTgwNTY3MTM2NDE@._V1_SX300.jpg"},{"Title":"The Making of 'Jurassic Park'","Year":"1995","imdbID":"tt0256908","Type":"movie","Poster":"https://m.media-amazon.com/images/M/MV5BMjlhY2Y5NGYtZDdlMS00YzhhLWJhNzQtNWYzNTQzZDJjNGU2XkEyXkFqcGdeQXVyODY0NzcxNw@@._V1_SX300.jpg"}],"totalResults":"98","Response":"True","_id":"8e7343a11b4e2286a9ec0b93495aa3f3c618deeba43fbc45f0f92919f3853ade"} 187 | ``` 188 | 189 | Alright, so we know what the data looks like and how to retrieve it. It's straightforward from there to write a client-side call to this endpoint. 190 | 191 | Edit the handleSearch function to be: 192 | 193 | ```javascript 194 | 195 | const handleSearch = () => ({ 196 | // 1. Make an Ajax call to content source 197 | // 2. Set data based on response 198 | // 3. Re-render search results 199 | const searchTerm = document.getElementById('searchTitle').value; 200 | 201 | superagent 202 | .get('/pf/api/v3/content/fetch/movie-search') 203 | .query({ query: JSON.stringify({"text":searchTerm})}) 204 | .set('Accept', 'application/json') 205 | .then(res => { 206 | data = res.body.Search 207 | render() 208 | }); 209 | }) 210 | 211 | ``` 212 | 213 | This will make the Ajax call back to Fusion to retrieve our movie data. But we still need to show the results! So let's fill out that template and render function to include a movie image, id, title and year. (the .Poster, .imdbID, .Title and .Year fields, respectively, from our data source.) 214 | 215 | Set the search result template to be: 216 | 217 | ```html 218 | 236 | ``` 237 | 238 | And we'll tweak the `render()` function just a bit to insert the data into this template: 239 | 240 | ```javascript 241 | const render = () => { 242 | // Show search results to user 243 | const template = document.getElementById('content_template').innerHTML 244 | document.getElementById('search_content').innerHTML = ''; 245 | 246 | for (i = 0; i < data.length; i++) { 247 | const html = template 248 | .replace('%item_id%', 'row-' + data[i].imdbID) 249 | .replace('%image_id%', data[i].Poster) 250 | .replace('%text%', data[i].Title) 251 | .replace('%year%', data[i].Year) 252 | const element = document.createElement('div') 253 | document.getElementById('search_content').appendChild(element) 254 | element.outerHTML = html 255 | document 256 | .getElementById('row-' + data[i].imdbID) 257 | .addEventListener('click', handleClick(i)) 258 | } 259 | } 260 | ``` 261 | 262 | The search button shows results now. All that's left to do is let the user select one and send the data back to Composer. 263 | 264 | Change handleClick to be: 265 | 266 | ```javascript 267 | const handleClick = index => event => { 268 | // Send message back to Composer about selected item 269 | // message must contain: 270 | // { 271 | // "id": (content item id - string) 272 | // "url": (content source identifier - string) 273 | // "config": (contextual metadata - object) 274 | // } 275 | 276 | const ansCustomEmbed = { 277 | id: data[index]['imdbID'], 278 | url: 'https://www.imdb.com/title/', 279 | config: { 280 | "show_poster": true, 281 | "caption": "No caption specified" 282 | } 283 | } 284 | 285 | sendMessage('data', ansCustomEmbed) 286 | } 287 | ``` 288 | 289 | The data format we are returning here is based part of the ans [custom embed element](https://github.com/washingtonpost/ans-schema/blob/master/src/main/resources/schema/ans/0.10.3/story_elements/custom_embed.json) schema. 290 | 291 | Finally, we can test our whole flow by using the online custom embed testing tool. Visit the testing tool, select the config options, and set the search page to be http://localhost/pf/resources/plugins/composer/embeds/movie/search.html. 292 | 293 | 294 | ### 4. Create an edit panel for your custom embed. 295 | 296 | The search panel is working, but we still need to let users attach some contextual metadata to an embed. For that, we need to define an edit panel. 297 | 298 | Copy [starter edit code](https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/master/public/starter/edit.html) to /resources/plugins/composer/embeds/movie/edit.html 299 | 300 | Note: It is required that all static html files to be saved in the `/resources/plugins/composer` directory. If saved outside of this folder, a deployment version parameter (d) will be requested, and will have to be updated with every deployment, making it unmaintainable. 301 | 302 | For our movie embeds, we'll let users control two things: whether or not to display the movie poster image, and to set an optional caption or tagline about the movie. 303 | 304 | We can start by building a form to set those options. 305 | 306 | ```html 307 | 308 |
309 |
310 | 313 |
314 |
315 |
316 | 320 | 324 |
325 |
326 | 329 | 332 |
333 |
334 | ``` 335 | 336 | We'll also need to show a rendering of the content with the configuration options factored in. This requires fetching the data and rendering it alongside the configuration options. 337 | 338 | We can update `fetchData` to retrieve the content based on the content id passed in via query parameters. Note that this time we're pulling from the movie-find content source instead of movie-search. 339 | 340 | ```javascript 341 | // Retrieve the content data based on parameters 342 | const fetchData = (ansCustomEmbed) => { 343 | superagent 344 | .get('/pf/api/v3/content/fetch/movie-find') 345 | .query({ query: JSON.stringify({"imdbID":ansCustomEmbed.id})}) 346 | .set('Accept', 'application/json') 347 | .then(res => { 348 | content = res.body 349 | render(content, data.config) 350 | }); 351 | } 352 | ``` 353 | 354 | That render function doesn't do anything, so let's make a template and use it. 355 | 356 | Edit the content template to be: 357 | 358 | ```html 359 | 360 |
361 |
363 | Card image cap 370 |
371 |

372 | %text% 373 |

374 |

375 | %year% 376 |

377 |

378 | "%caption%" 379 |

380 |
381 |
382 |
383 |
384 |
JSON Response to Composer
385 |
%data%
386 |
387 | 388 | ``` 389 | 390 | And pull it all together in `render`: 391 | 392 | ```javascript 393 | // Render the content data and contextual configuration together 394 | const render = (content, config) => { 395 | 396 | // Setup Element Preview 397 | const template = document.getElementById('content_template').innerHTML 398 | const html = template 399 | .replace('%item_id%', 'row-' + content.imdbID) 400 | .replace('%image_id%', content.Poster) 401 | .replace('%text%', content.Title) 402 | .replace('%year%', content.Year) 403 | .replace('%caption%', (config.caption ? config.caption : "")) 404 | .replace('%data%', JSON.stringify(data, null, 2)) 405 | 406 | const element = document.createElement('div') 407 | document.getElementById('content_holder').innerHTML = '' 408 | document.getElementById('content_holder').appendChild(element) 409 | element.outerHTML = html 410 | 411 | if (config.show_poster && config.show_poster === false) { 412 | document.getElementById('content_card').removeChild( 413 | document.getElementById('content_image') 414 | ) 415 | } 416 | 417 | // Update form state 418 | document.getElementById('poster_yes').checked = (!!config.show_poster) 419 | document.getElementById('poster_no').checked = (!config.show_poster) 420 | 421 | document.getElementById('caption').value = (config.caption ? config.caption : "") 422 | } 423 | 424 | ``` 425 | 426 | Finally, we need to make the Apply Changes button send the data back to Composer. It should read the form state, update the config object and send the whole data package back. 427 | 428 | 429 | ```javascript 430 | // Update config based on form changes, submit back to Composer and re-render 431 | const applyChanges = () => { 432 | data.config.show_poster = document.getElementById('poster_yes').checked 433 | data.config.caption = document.getElementById('caption').value 434 | 435 | // Update Composer and re-render form 436 | sendMessage('data', data) 437 | render(content, data.config) 438 | } 439 | 440 | ``` 441 | 442 | 443 | That's it for editing! We can test it using the same test tool we used for search. 444 | 445 | 446 | ### 5. Create a view panel for your custom embed. 447 | 448 | Phew! The hardest part is over. The search and edit panels are working. But we still need to tell Composer how to display the embed to writers, editors and content producers when it's embedded in a document. The view panel controls how the embed displays when it's at rest. 449 | 450 | Copy the [starter view code](https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/master/public/starter/view.html) to /resources/plugins/composer/embeds/movie/view.html 451 | 452 | Note: It is required that all static html files to be saved in the `/resources/plugins/composer` directory. If saved outside of this folder, a deployment version parameter (d) will be requested, and will have to be updated with every deployment, making it unmaintainable. 453 | 454 | This one's a little easier -- a lot can be borrowed from the edit panel. Instead of a form, we just need to tell our view panel how to fetch and render content by id. We'll use the movie-find content source again, along with the config object passed in. 455 | 456 | Fetch data looks the same as in edit panel: 457 | 458 | ```javascript 459 | const fetchData = (ansCustomEmbed) => { 460 | superagent 461 | .get('/pf/api/v3/content/fetch/movie-find') 462 | .query({ query: JSON.stringify({"imdbID":ansCustomEmbed.id})}) 463 | .set('Accept', 'application/json') 464 | .then(res => { 465 | data = res.body 466 | render(data, ansCustomEmbed.config) 467 | }); 468 | } 469 | ``` 470 | 471 | And the only remaining steps are adding the template and render function. 472 | 473 | ```html 474 |
475 |
478 | Card image cap 485 |
486 |

487 | %text% 488 |

489 |

490 | %year% 491 |

492 |

493 | "%caption%" 494 |

495 |
496 |
497 | ``` 498 | 499 | ```javascript 500 | const render = (data, config) => { 501 | const template = document.getElementById('content_template').innerHTML 502 | const html = template 503 | .replace('%item_id%', 'row-' + data.imdbID) 504 | .replace('%image_id%', data.Poster) 505 | .replace('%text%', data.Title) 506 | .replace('%data%', JSON.stringify(data, null, 2)) 507 | .replace('%year%', data.Year) 508 | .replace('%caption%', (config.caption ? config.caption : "")) 509 | 510 | const element = document.createElement('div') 511 | document.getElementById('search_content').appendChild(element) 512 | element.outerHTML = html 513 | 514 | if (config && (config.show_poster === false)) { 515 | document.getElementById('content_card').removeChild( 516 | document.getElementById('content_image') 517 | ) 518 | } 519 | } 520 | 521 | ``` 522 | 523 | Once again, we can test using the test tool. 524 | 525 | 526 | ### 6. Deploy code to a development environment. 527 | 528 | To wire these panels up in Composer, you'll need to host them on a public domain. Just zip up a bundle like you usually would and deploy to a test environment. 529 | 530 | Once they're uploaded, you can wire up Composer to use the panels in the settings page. See [these instructions](https://redirector.arcpublishing.com/alc/arc-products/composer/user-docs/composer-custom-embed-power-ups-for-rich-third-party-content/#Configuring-a-Custom-Embed-Integration). 531 | 532 | Save a few documents with the embed in them, then go look at those documents published on the web. 533 | 534 | ### 7. Create an appropriate feature component in PageBuilder Fusion. 535 | 536 | Wait...there's nothing there? Ah, geeze, we forgot to write a Fusion feature for actually rendering these things! 537 | 538 | Fortunately it's not too hard to add. We already have the right content sources configured, after all. 539 | 540 | Let's update our movie-detail component (from the Fusion recipes) to utilize our content source and configuration options. 541 | 542 | ```javascript 543 | @Consumer 544 | class MovieDetail extends Component { 545 | constructor (props) { 546 | super(props) 547 | this.state = { 548 | movie: {} 549 | } 550 | this.fetch = this.fetch.bind(this) 551 | this.fetch() 552 | } 553 | 554 | fetch() { 555 | const { movie } = this.state 556 | const { imdbID, caption, show_poster } = this.props 557 | this.fetchContent({ 558 | movie: { 559 | source: 'movie-find', 560 | query: { imdbID: imdbID }, 561 | transform: (data) => { 562 | return Object.assign( 563 | {}, 564 | data, 565 | { 566 | Poster: show_poster ? data.Poster : null, 567 | caption: caption 568 | } 569 | ) 570 | } 571 | } 572 | }) 573 | this.render() 574 | } 575 | 576 | render () { 577 | 578 | const { Actors, Director, Plot, Poster, Rated, Title, Writer, Year, caption } = this.state.movie || {} 579 | 580 | return ( 581 |
582 |
583 | {Poster && Title &&
{`Poster
} 584 | 585 |
589 | {Title &&

{Title}

} 590 | {Year &&

Year: {Year}

} 591 | {caption &&
"{caption}"
} 592 | 593 |
594 |
595 |
596 | ) 597 | } 598 | } 599 | 600 | MovieDetail.label = 'Movie Detail' 601 | 602 | export default MovieDetail 603 | 604 | ``` 605 | 606 | A few things have changed here. Our component now takes `imdbID`, `caption` and `show_poster` as props. It no longer uses the global content source. The `caption` and `show_poster` fields are factored into the render function. But most importantly, `imdbID` is used as an argument to our content source. This is pretty cool! 607 | 608 | 609 | We're not quite done, though. We'll need to tell our feature pack that custom embeds are a valid content element to appear in an article body. So let's find our article body feature (the location and code varies by project) and add this to our content element switch statement: 610 | 611 | ```javascript 612 | case 'custom_embed': 613 | return 614 | ``` 615 | 616 | We'll also need to import the component at the top, perhaps like: 617 | 618 | ``` 619 | import CustomEmbedBody from './_children/custom-embed' 620 | ``` 621 | 622 | And we'll need to implement the custom embed component as well. 623 | 624 | ```javascript 625 | 'use strict' 626 | 627 | /* Third party libs */ 628 | import React, { Component } from 'react' 629 | 630 | /* Components */ 631 | import MovieDetail from '../../../movies/movie-detail' 632 | 633 | /* Other JS */ 634 | 635 | /* Non-JS resources */ 636 | 637 | 638 | class CustomEmbedBody extends Component { 639 | 640 | render() { 641 | 642 | return ( 643 |
644 | 649 | 650 |
651 | ) 652 | } 653 | } 654 | 655 | 656 | export default CustomEmbedBody 657 | ``` 658 | 659 | This component is pretty basic for now, but it would be a good place in the future to `switch` between multiple custom embed types, once we have them. In the meantime, it extracts the relevant fields from our custom embed and passes them into the updated movie detail component. 660 | 661 | And voila! Our movies appear inline in our articles! 662 | 663 | 664 | ### 9. Deploy the whole thing to sandbox and production, and enable the configs. 665 | 666 | One benefit of implementing the custom embed panels in Fusion is that they become part of our deployment bundle! So you can change the Composer config options, the content source, and the reader rendering all at once, without any gap time. 667 | 668 | Just zip up the bundle and deploy to each environment as you normally would. Remember that the first time you deploy to each environment, you'll also need to enable the appropriate Ellipsis configs. 669 | 670 | That's it for now. We can't wait to see what you do with this! 671 | -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- 1 | # Custom Embed Specification 2 | 3 | All communication goes through browser postMessage API. There are 3 types of plugin: 4 | 5 | - Search Plugin 6 | - View Plugin 7 | - Edit Plugin 8 | 9 | ## Search integration with Composer 10 | 11 | When user add a Custom Embed element into Composer, Search Modal should appear. Depending on which Custom Embed subtype being added, a correspondent searchApi should be used to load search user experience. 12 | Search integration may receive any arbitrary data through URL. However, Custom Embed subtype is the only piece of information required. 13 | 14 | ## View integration with Composer 15 | 16 | Composer may require display Custom Embed type from the editor itself, related content or featured media panel. In order to do that Composer will create an iframe and provide iframe src with the link to the preconfigured viewApi endpoint. Composer will make any necessary substitution to the viewApi URL from ANS to make proper URL. 17 | note: View integration should not have Edit controls. Composer should initiate editing. 18 | 19 | ## Edit integration with Composer 20 | 21 | Pretty much the same as view integration, except Composer will be waiting for the submit or cancel message from the iframe content. Submit message should have well-formed Custom Embed ANS in it with all the updates. Edit integration may have a cancel button along with Submit button for the better UI. However, Composer may cancel editing at any time just by removing that iframe. No message will be sent to the Edit integration. 22 | note: Integration developers encouraged not to save any data in the underlying systems at all, since all information should be carried over by Custom Embed ANS. If Edit integration designed to save some of the data to the underlying system, it should not assume that Composer will inform integration in any way if user cancelled editing. 23 | 24 | ## Iframe communication protocol outline 25 | 26 | Search, view and edit integration should send a handshake postMessage to the parent window as soon as it is loaded and ready to receive commands or interact with the user. If Composer does not receive initial handshake message, Composer will display an error information with the Retry button. 27 | 28 | Search integration expected to return a configuration JSON on success search. Configuration JSON is a subject of validation, see below. 29 | 30 | View integration is expected to send only handshake message. View integration receives configuration in a form of a query string. (base64/encoded, tbd) 31 | 32 | Edit integration receives configuration in a form of a query string. (base64/encoded, tbd). Edit integration is expected to send handshake message and the configuration JSON on changes submit. If user discard changes, a cancel message should be send. Configuration JSON is a subject of validation, see below. 33 | 34 | Each integration should be supplied with a `k` query string argument. This key is suppose to be used as simple cookie value and returned back with any message in the `key` field. This helps Composer to identify which integration responds. 35 | 36 | # Validation 37 | 38 | Custom Embed configuration has the following limitations: 39 | 40 | - Configuration should be a valid JSON 41 | - JSON size should be no more than 2048 bytes length (TBD) 42 | - JSON should not have type, version or referent fields at any level. Composer will strip that out 43 | 44 | Iframe communication protocol specification 45 | 46 | Composer communicate with plugins though query string data. This data is arbitrary and servers a purpose of plugin configuration. Custom embed data is passed as URL encoded data in a p parameter in a form of JSON. 47 | 48 | Example: 49 | 50 | editApi.html?k=j0a&p=%7B%22id%22%3A%2217b3224337d2d3%22%2C%22url%22%3A%22https%3A%2F%2Fmy.content.com%2Fdata%2F5%22%2C%22config%22%3A%7B%22id%22%3A5%2C%22text%22%3A%22Brunch%20raclette%20vexillologist%20post-ironic%20glossier%20ennui%20XOXO%20mlkshk%20godard%20pour-over%20blog%20tumblr%20humblebrag.%22%2C%22image_id%22%3A30%7D%7D 51 | 52 | Plugins should response back through browser postMessage mechanism. Each message should be a JSON object and contain the following fields: 53 | 54 | - source - always equal to custom_embed 55 | - action - can be ready, data, cancel 56 | - key - a value from `k` query string parameter. Should be passed as-is 57 | - data - contain custom embed data or content height of the iframe 58 | 59 | ## Ready message 60 | 61 | Ready message should be send by a plugins as soon as it renders their content and content height is known. 62 | 63 | {"source":"custom_embed","action":"ready","key":"j0a","data":{"height":908}} 64 | 65 | If ready message will not be sent within ~10 seconds, Composer will render a timeout error and plugin content will be discarded. 66 | 67 | ### Request additional Story ANS 68 | 69 | Customers can obtain addition ANS data from Composer via `postMessage`. It can be done with **Search**, **View**, and **Edit** integration. Composer will send back the data with the additional fields of: 70 | 71 | 1. Headlines 72 | 2. Subheadlines 73 | 3. Taxonomy 74 | 75 | To do this, add an additional flag `isAnsRequired` set to `true` in your integration where `postMessage()` is being called and when `action` is `'ready'`: 76 | ```javascript 77 | const sendMessage = function(action, data) { 78 | const messagePayload = { 79 | ..., 80 | action, 81 | data, 82 | } 83 | // Composer only accepts `isAnsRequired` flag when `action = 'ready'` 84 | if (action === 'ready') { 85 | messagePayload.isAnsRequired = false // set to `true` 86 | } 87 | window.parent.postMessage(JSON.stringify(messagePayload), '*') 88 | } 89 | 90 | sendMessage('action', data) 91 | ``` 92 | Which will send out a message to Composer: 93 | ```javascript 94 | {source: "custom_embed", action: "ready", data: {…}, key: "1234567890", isAnsRequired: true} 95 | ``` 96 | Composer will then `postMessage` back with additional Story ANS data: 97 | ```javascript 98 | MessageEvent  99 | { 100 | "message":"ans_data", 101 | "data":{ 102 | "headlines":{ 103 | "basic":"Test Headline", 104 | "mobile":"", 105 | "native":"", 106 | ... 107 | }, 108 | "subheadlines":{ "basic":"Test Sub Headline"}, 109 | "taxonomy":{ 110 | "sites":[{"_id":"/about-us", "type":"site", ...}], 111 | "tags":[], 112 | "sections":[], 113 | ... 114 | } 115 | } 116 | } 117 | ``` 118 | To test if the message is received properly, use the following example: 119 | ```javascript 120 | window.addEventListener('message', (e) => { 121 | console.log(e) 122 | }, false) 123 | ``` 124 | 125 | For reference, there is an example already set up in [audioSearchApi](../public/audioSearchApi.html#L234-L245). 126 | 127 | ## Data message 128 | 129 | Data message should be send by search plugin when an item has been selected by user or edit plugin when editing is done. Data message should contain an embed data structure. Feel free to check the schema. 130 | 131 | { 132 | "source":"custom_embed", 133 | "action":"data", 134 | "key":"j0a", 135 | "data":{ 136 | "id":"b0bc95dc11919", 137 | "url":"https://my.content.com/data/2", 138 | "config":{ 139 | "id":2, 140 | "text":"Some Text", 141 | "image_id":1 142 | } 143 | } 144 | } 145 | 146 | A data field should contain id, url and config fields. Those are required. Config object might have any arbitrary data structure. The only requirement is that it should not have referent, type and version fields. Config object should have as few fields as possible to properly configure the custom embed object. Please do not put large objects here. Instead, use data.id to identify internal resource and data.config to store configuration properties only. 147 | 148 | ## Cancel message 149 | 150 | Cancel message is used to notify Composer that user wants to cancel search or discard any editing changes. 151 | 152 | {"source":"custom_embed","action":"cancel","key":"j0a"} 153 | 154 | Cancel message notify Composer to close the UI. It does nothing for the view integration and should not be used there. 155 | 156 | Composer can close search or edit iframe by itself without notifying iframe content. Please consider this behavior and do not persist any changes in the system. The only proper way to persist changes is to send data through data message back to the ellipsis. 157 | 158 | ## Message communication example 159 | 160 | | Composer | Plugin | 161 | | ---------------------------------- | --------------------------------------------------------------------------------------------- | 162 | | Composer loads Search Integration | | 163 | | | Integration loads and send back Ready message | 164 | | | ← {"source":"custom_embed","action":"ready","key":"j0a","data":{"height":908}} | 165 | | | User select necessary media | 166 | | | ← {"source":"custom_embed","action":"data","key":"j0a","data":{…}} | 167 | | Composer loads View Integration | | 168 | | viewApi.html?p=…. | View integration renders it’s content and send back ready
message with the content height. | 169 | | | ← {"source":"custom_embed","action":"ready","key":"j0a","data":{"height":480}} | 170 | | Composer loads Edit Integration | | 171 | | editApi.html?p=…. | Edit integration renders it’s content and send back ready
message with the content height. | 172 | | | ← {"source":"custom_embed","action":"ready","key":"j0a","data":{"height":890}} | 173 | | | User accepted changes | 174 | | | ← {"source":"custom_embed","action":"data","key":"j0a","data":{…}} | 175 | | | /or/ User cancelled changes | 176 | | | ← {"source":"custom_embed","action":"cancel","key":"j0a"} | 177 | | Composer Sends Story ANS | | 178 | | | User requesting additional Story ANS | 179 | | | ← {"source":"custom_embed","action":"ready",data: {…}, key: "j0a", isAnsRequired: true} | 180 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Custom Embed Test Application 2 | 3 | > This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.
15 | You will also see any lint errors in the console. 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "arc-custom-embed", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/jest": "^24.0.11", 7 | "@types/node": "^11.13.0", 8 | "@types/react": "^16.8.11", 9 | "@types/react-dom": "^16.8.3", 10 | "react": "^16.8.6", 11 | "react-dom": "^16.8.6", 12 | "react-scripts": "2.1.8", 13 | "typescript": "^3.4.1" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": "react-app" 23 | }, 24 | "browserslist": [ 25 | ">0.2%", 26 | "not dead", 27 | "not ie <= 11", 28 | "not op_mini all" 29 | ], 30 | "devDependencies": { 31 | "@typescript-eslint/eslint-plugin": "^1.6.0", 32 | "eslint-config-prettier": "^4.1.0", 33 | "eslint-plugin-prettier": "^3.0.1", 34 | "eslint-plugin-react": "^7.12.4", 35 | "eslint-plugin-react-hooks": "^1.6.0", 36 | "eslint-plugin-standard": "^4.0.0", 37 | "prettier": "^1.16.4" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /public/audio/audio0.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/audio/audio0.mp3 -------------------------------------------------------------------------------- /public/audio/audio1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/audio/audio1.mp3 -------------------------------------------------------------------------------- /public/audio/audio2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/audio/audio2.mp3 -------------------------------------------------------------------------------- /public/audio/audio3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/audio/audio3.mp3 -------------------------------------------------------------------------------- /public/audio/cover0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/audio/cover0.png -------------------------------------------------------------------------------- /public/audio/cover1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/audio/cover1.jpg -------------------------------------------------------------------------------- /public/audio/cover2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/audio/cover2.jpg -------------------------------------------------------------------------------- /public/audio/cover3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/audio/cover3.jpg -------------------------------------------------------------------------------- /public/audioEditApi.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Audio Edit API plugin 5 | 9 | 10 | 11 | 15 | 16 | 17 | 18 | 19 | 26 | 27 | 28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |

Audio ID:

36 | Title 37 | 42 |
43 |
44 |
45 | 49 |
50 |
51 | 52 | : 55 | 56 |
57 | 58 | 62 | 67 |
68 | 69 | : 72 | 73 |
74 | 75 |
76 |
77 |
78 |
82 |
83 |
84 | 85 |
86 |
87 |
88 | 89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 | 100 | 108 |
109 |
110 | 111 | 120 |
121 | 124 |
125 |
126 |
127 | 134 | 141 |
142 |
143 |
144 |
145 |
146 |
147 | 148 | 149 | 153 | 158 | 159 | 160 | 229 | 230 | -------------------------------------------------------------------------------- /public/audioSearchApi.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | Audio Search API plugin 11 | 15 | 16 | 61 | 62 | 63 | 92 |
93 |
94 |
95 |
96 |
97 |
98 | 101 |
102 | 107 |
108 |
109 | 110 |
111 |
112 |
113 |
114 |
115 | 118 | 128 |
129 |
130 | 137 | 140 |
141 |
142 | 149 | 152 |
153 |
154 | 161 | 164 |
165 |
166 | 173 | 176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 | 186 |
187 | 188 |
189 | 194 |
195 | 196 |
197 | 200 |
201 | 202 |
203 |
204 |
205 | 206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 | 217 | 222 | 227 | 232 | 233 | 362 | 363 | 364 | -------------------------------------------------------------------------------- /public/audioViewApi.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Audio View API plugin 5 | 9 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |

29 |
30 |
31 |
32 | 36 |
37 |
38 | 39 | : 42 | 43 |
44 | 45 | 49 | 54 |
55 | 56 | : 59 | 60 |
61 | 62 |
63 |
64 |
65 |
69 |
70 |
71 | 72 |
73 |
74 |
75 | 76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | Length: 88 | Released: 89 |
90 |

91 |
92 |
93 |
94 |
95 |
96 |
97 | 98 | 164 | 165 | -------------------------------------------------------------------------------- /public/blank/edit.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | Edit API plugin 11 | 15 | 16 | 17 | 26 | 27 |
28 |
29 |
30 |
31 | Loading... 32 |
33 | 34 | 35 |
36 |
37 | 40 |
41 |
42 | 45 | 48 |
49 |
50 |
51 |
52 |
53 | 54 | 55 | 60 | 65 | 70 | 75 | 180 | 181 | 182 | -------------------------------------------------------------------------------- /public/blank/search.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | Search API plugin 11 | 15 | 24 | 25 | 26 | 30 | 31 |
32 | 56 |
57 | 58 |
59 |
60 |
61 | 62 | 63 |
64 |
65 | 66 |
67 |
68 |
69 |
70 |
71 |
72 | 73 |
74 |
75 |

76 | Back to top 77 |

78 |

79 | This is a search plugin reference implementation for the Custom Embed 80 |

81 |
82 |
83 | 84 | 89 | 94 | 99 | 204 | 205 | 206 | -------------------------------------------------------------------------------- /public/blank/view.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | View API plugin 11 | 15 | 16 | 17 | 26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | 34 | 39 | 44 | 49 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /public/css/app.css: -------------------------------------------------------------------------------- 1 | /* 2 | 1. Base 3 | */ 4 | /* 5 | 2. Components 6 | */ 7 | div#amplitude-player { 8 | background: #FFFFFF; 9 | box-shadow: 0 2px 12px 8px rgba(0, 0, 0, 0.1); 10 | margin: auto; 11 | margin-top: 20px; 12 | margin-bottom: 20px; 13 | } 14 | 15 | div#amplitude-left { 16 | padding: 0px; 17 | border-right: 1px solid #CFD8DC; 18 | } 19 | div#amplitude-left div#player-left-bottom { 20 | background-color: #F1F1F1; 21 | padding: 20px 10px; 22 | } 23 | div#amplitude-left div#player-left-bottom div#volume-container:after { 24 | content: ""; 25 | display: table; 26 | clear: both; 27 | } 28 | 29 | /* Small only */ 30 | @media screen and (max-width: 39.9375em) { 31 | div#amplitude-player div#amplitude-left img[amplitude-song-info=cover_art_url] { 32 | width: auto; 33 | height: auto; 34 | } 35 | } 36 | div#amplitude-right { 37 | padding: 0px; 38 | overflow-y: scroll; 39 | } 40 | div#amplitude-right div.song { 41 | cursor: pointer; 42 | padding: 10px; 43 | } 44 | div#amplitude-right div.song div.song-now-playing-icon-container { 45 | float: left; 46 | width: 20px; 47 | height: 20px; 48 | margin-right: 10px; 49 | } 50 | div#amplitude-right div.song div.song-now-playing-icon-container img.now-playing { 51 | display: none; 52 | margin-top: 15px; 53 | } 54 | div#amplitude-right div.song div.play-button-container { 55 | display: none; 56 | background: url("../img/list-play-light.png") no-repeat; 57 | width: 22px; 58 | height: 22px; 59 | margin-top: 10px; 60 | } 61 | div#amplitude-right div.song div.play-button-container:hover { 62 | background: url("../img/list-play-hover.png") no-repeat; 63 | } 64 | div#amplitude-right div.song.amplitude-active-song-container div.song-now-playing-icon-container img.now-playing { 65 | display: block; 66 | } 67 | div#amplitude-right div.song.amplitude-active-song-container:hover div.play-button-container { 68 | display: none; 69 | } 70 | div#amplitude-right div.song div.song-meta-data { 71 | float: left; 72 | width: calc( 100% - 110px ); 73 | } 74 | div#amplitude-right div.song div.song-meta-data span.song-title { 75 | color: #272726; 76 | font-size: 16px; 77 | display: block; 78 | font-weight: 300; 79 | white-space: nowrap; 80 | overflow: hidden; 81 | text-overflow: ellipsis; 82 | } 83 | div#amplitude-right div.song div.song-meta-data span.song-artist { 84 | color: #607D8B; 85 | font-size: 14px; 86 | font-weight: bold; 87 | text-transform: uppercase; 88 | display: block; 89 | white-space: nowrap; 90 | overflow: hidden; 91 | text-overflow: ellipsis; 92 | } 93 | div#amplitude-right div.song img.bandcamp-grey { 94 | float: left; 95 | display: block; 96 | margin-top: 10px; 97 | } 98 | div#amplitude-right div.song img.bandcamp-white { 99 | float: left; 100 | display: none; 101 | margin-top: 10px; 102 | } 103 | div#amplitude-right div.song span.song-duration { 104 | float: left; 105 | width: 55px; 106 | text-align: center; 107 | line-height: 45px; 108 | color: #607D8B; 109 | font-size: 16px; 110 | font-weight: 500; 111 | } 112 | div#amplitude-right div.song:after { 113 | content: ""; 114 | display: table; 115 | clear: both; 116 | } 117 | 118 | div#progress-container { 119 | width: 70%; 120 | float: left; 121 | position: relative; 122 | height: 20px; 123 | cursor: pointer; 124 | /* 125 | IE 11 126 | */ 127 | } 128 | div#progress-container:hover input[type=range].amplitude-song-slider::-webkit-slider-thumb { 129 | display: block; 130 | } 131 | div#progress-container:hover input[type=range].amplitude-song-slider::-moz-range-thumb { 132 | visibility: visible; 133 | } 134 | div#progress-container progress#song-played-progress { 135 | width: 100%; 136 | position: absolute; 137 | left: 0; 138 | top: 8px; 139 | right: 0; 140 | width: 100%; 141 | z-index: 60; 142 | -webkit-appearance: none; 143 | -moz-appearance: none; 144 | appearance: none; 145 | height: 4px; 146 | border-radius: 5px; 147 | background: transparent; 148 | border: none; 149 | /* Needed for Firefox */ 150 | } 151 | @media all and (-ms-high-contrast: none) { 152 | div#progress-container *::-ms-backdrop, div#progress-container progress#song-played-progress { 153 | color: #00A0FF; 154 | border: none; 155 | background-color: #CFD8DC; 156 | } 157 | } 158 | @supports (-ms-ime-align: auto) { 159 | div#progress-container progress#song-played-progress { 160 | color: #00A0FF; 161 | border: none; 162 | } 163 | } 164 | div#progress-container progress#song-played-progress[value]::-webkit-progress-bar { 165 | background: none; 166 | border-radius: 5px; 167 | } 168 | div#progress-container progress#song-played-progress[value]::-webkit-progress-value { 169 | background-color: #00A0FF; 170 | border-radius: 5px; 171 | } 172 | div#progress-container progress#song-played-progress::-moz-progress-bar { 173 | background: none; 174 | border-radius: 5px; 175 | background-color: #00A0FF; 176 | height: 5px; 177 | margin-top: -2px; 178 | } 179 | div#progress-container progress#song-buffered-progress { 180 | position: absolute; 181 | left: 0; 182 | top: 8px; 183 | right: 0; 184 | width: 100%; 185 | z-index: 10; 186 | -webkit-appearance: none; 187 | -moz-appearance: none; 188 | appearance: none; 189 | height: 4px; 190 | border-radius: 5px; 191 | background: transparent; 192 | border: none; 193 | background-color: #D7DEE3; 194 | } 195 | div#progress-container progress#song-buffered-progress[value]::-webkit-progress-bar { 196 | background-color: #CFD8DC; 197 | border-radius: 5px; 198 | } 199 | div#progress-container progress#song-buffered-progress[value]::-webkit-progress-value { 200 | background-color: #78909C; 201 | border-radius: 5px; 202 | transition: width 0.1s ease; 203 | } 204 | div#progress-container progress#song-buffered-progress::-moz-progress-bar { 205 | background: none; 206 | border-radius: 5px; 207 | background-color: #78909C; 208 | height: 5px; 209 | margin-top: -2px; 210 | } 211 | div#progress-container progress::-ms-fill { 212 | border: none; 213 | } 214 | @-moz-document url-prefix() { 215 | div#progress-container progress#song-buffered-progress { 216 | top: 9px; 217 | border: none; 218 | } 219 | } 220 | @media all and (-ms-high-contrast: none) { 221 | div#progress-container *::-ms-backdrop, div#progress-container progress#song-buffered-progress { 222 | color: #78909C; 223 | border: none; 224 | } 225 | } 226 | @supports (-ms-ime-align: auto) { 227 | div#progress-container progress#song-buffered-progress { 228 | color: #78909C; 229 | border: none; 230 | } 231 | } 232 | div#progress-container input[type=range] { 233 | -webkit-appearance: none; 234 | width: 100%; 235 | margin: 7.5px 0; 236 | position: absolute; 237 | z-index: 9999; 238 | top: -7px; 239 | height: 20px; 240 | cursor: pointer; 241 | background-color: inherit; 242 | } 243 | div#progress-container input[type=range]:focus { 244 | outline: none; 245 | } 246 | div#progress-container input[type=range]::-webkit-slider-runnable-track { 247 | width: 100%; 248 | height: 0px; 249 | cursor: pointer; 250 | box-shadow: 0px 0px 0px rgba(0, 0, 0, 0), 0px 0px 0px rgba(13, 13, 13, 0); 251 | background: #0075a9; 252 | border-radius: 0px; 253 | border: 0px solid #010101; 254 | } 255 | div#progress-container input[type=range]::-webkit-slider-thumb { 256 | box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d; 257 | border: 1px solid #00a0ff; 258 | height: 15px; 259 | width: 15px; 260 | border-radius: 16px; 261 | background: #00a0ff; 262 | cursor: pointer; 263 | -webkit-appearance: none; 264 | margin-top: -7.5px; 265 | } 266 | div#progress-container input[type=range]:focus::-webkit-slider-runnable-track { 267 | background: #00adfb; 268 | } 269 | div#progress-container input[type=range]::-moz-range-track { 270 | width: 100%; 271 | height: 0px; 272 | cursor: pointer; 273 | box-shadow: 0px 0px 0px rgba(0, 0, 0, 0), 0px 0px 0px rgba(13, 13, 13, 0); 274 | background: #0075a9; 275 | border-radius: 0px; 276 | border: 0px solid #010101; 277 | } 278 | div#progress-container input[type=range]::-moz-range-thumb { 279 | box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d; 280 | border: 1px solid #00a0ff; 281 | height: 15px; 282 | width: 15px; 283 | border-radius: 16px; 284 | background: #00a0ff; 285 | cursor: pointer; 286 | } 287 | div#progress-container input[type=range]::-ms-track { 288 | width: 100%; 289 | height: 0px; 290 | cursor: pointer; 291 | background: transparent; 292 | border-color: transparent; 293 | color: transparent; 294 | } 295 | div#progress-container input[type=range]::-ms-fill-lower { 296 | background: #003d57; 297 | border: 0px solid #010101; 298 | border-radius: 0px; 299 | box-shadow: 0px 0px 0px rgba(0, 0, 0, 0), 0px 0px 0px rgba(13, 13, 13, 0); 300 | } 301 | div#progress-container input[type=range]::-ms-fill-upper { 302 | background: #0075a9; 303 | border: 0px solid #010101; 304 | border-radius: 0px; 305 | box-shadow: 0px 0px 0px rgba(0, 0, 0, 0), 0px 0px 0px rgba(13, 13, 13, 0); 306 | } 307 | div#progress-container input[type=range]::-ms-thumb { 308 | box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d; 309 | border: 1px solid #00a0ff; 310 | height: 15px; 311 | width: 15px; 312 | border-radius: 16px; 313 | background: #00a0ff; 314 | cursor: pointer; 315 | height: 0px; 316 | display: block; 317 | } 318 | @media all and (-ms-high-contrast: none) { 319 | div#progress-container *::-ms-backdrop, div#progress-container input[type=range].amplitude-song-slider { 320 | padding: 0px; 321 | } 322 | div#progress-container *::-ms-backdrop, div#progress-container input[type=range].amplitude-song-slider::-ms-thumb { 323 | height: 15px; 324 | width: 15px; 325 | border-radius: 10px; 326 | cursor: pointer; 327 | margin-top: -8px; 328 | } 329 | div#progress-container *::-ms-backdrop, div#progress-container input[type=range].amplitude-song-slider::-ms-track { 330 | border-width: 15px 0; 331 | border-color: transparent; 332 | } 333 | div#progress-container *::-ms-backdrop, div#progress-container input[type=range].amplitude-song-slider::-ms-fill-lower { 334 | background: #CFD8DC; 335 | border-radius: 10px; 336 | } 337 | div#progress-container *::-ms-backdrop, div#progress-container input[type=range].amplitude-song-slider::-ms-fill-upper { 338 | background: #CFD8DC; 339 | border-radius: 10px; 340 | } 341 | } 342 | @supports (-ms-ime-align: auto) { 343 | div#progress-container input[type=range].amplitude-song-slider::-ms-thumb { 344 | height: 15px; 345 | width: 15px; 346 | margin-top: 3px; 347 | } 348 | } 349 | div#progress-container input[type=range]:focus::-ms-fill-lower { 350 | background: #0075a9; 351 | } 352 | div#progress-container input[type=range]:focus::-ms-fill-upper { 353 | background: #00adfb; 354 | } 355 | 356 | div#control-container { 357 | margin-top: 25px; 358 | margin-top: 20px; 359 | } 360 | div#control-container div#repeat-container { 361 | width: 25%; 362 | float: left; 363 | padding-top: 20px; 364 | } 365 | div#control-container div#repeat-container div#repeat { 366 | width: 24px; 367 | height: 19px; 368 | cursor: pointer; 369 | } 370 | div#control-container div#repeat-container div#repeat.amplitude-repeat-off { 371 | background: url("../img/repeat-off.svg"); 372 | } 373 | div#control-container div#repeat-container div#repeat.amplitude-repeat-on { 374 | background: url("../img/repeat-on.svg"); 375 | } 376 | div#control-container div#repeat-container div#shuffle { 377 | width: 23px; 378 | height: 19px; 379 | cursor: pointer; 380 | float: right; 381 | } 382 | div#control-container div#repeat-container div#shuffle.amplitude-shuffle-off { 383 | background: url("../img/shuffle-off.svg"); 384 | } 385 | div#control-container div#repeat-container div#shuffle.amplitude-shuffle-on { 386 | background: url("../img/shuffle-on.svg"); 387 | } 388 | @media all and (-ms-high-contrast: none) { 389 | div#control-container *::-ms-backdrop, div#control-container div#control-container { 390 | margin-top: 40px; 391 | float: none; 392 | } 393 | } 394 | div#control-container div#central-control-container { 395 | width: 75%; 396 | float: left; 397 | } 398 | div#control-container div#central-control-container div#central-controls { 399 | float: left 400 | } 401 | div#control-container div#central-control-container div#central-controls div#previous { 402 | display: inline-block; 403 | width: 40px; 404 | height: 40px; 405 | cursor: pointer; 406 | background: url("../img/prev.svg"); 407 | background-repeat: no-repeat; 408 | float: left; 409 | margin-top: 10px; 410 | margin-right: 5px; 411 | } 412 | div#control-container div#central-control-container div#central-controls div#play-pause { 413 | display: inline-block; 414 | width: 60px; 415 | height: 60px; 416 | min-width: 60px; 417 | min-height: 60px; 418 | cursor: pointer; 419 | float: left; 420 | margin-right: 65px; 421 | } 422 | div#control-container div#central-control-container div#central-controls div#play-pause.amplitude-paused { 423 | background: url("../img/play.svg"); 424 | } 425 | div#control-container div#central-control-container div#central-controls div#play-pause.amplitude-playing { 426 | background: url("../img/pause.svg"); 427 | } 428 | div#control-container div#central-control-container div#central-controls div#next { 429 | display: inline-block; 430 | width: 40px; 431 | height: 40px; 432 | cursor: pointer; 433 | background: url("../img/next.svg"); 434 | background-repeat: no-repeat; 435 | float: left; 436 | margin-top: 10px; 437 | margin-left: 5px; 438 | } 439 | div#control-container div#volume-container { 440 | width: 25%; 441 | float: left; 442 | padding-top: 20px; 443 | } 444 | div#control-container div#volume-container div#shuffle-right { 445 | width: 23px; 446 | height: 19px; 447 | cursor: pointer; 448 | margin: auto; 449 | } 450 | div#control-container div#volume-container div#shuffle-right.amplitude-shuffle-off { 451 | background: url("../img/shuffle-off.svg"); 452 | } 453 | div#control-container div#volume-container div#shuffle-right.amplitude-shuffle-on { 454 | background: url("../img/shuffle-on.svg"); 455 | } 456 | div#control-container div.amplitude-mute { 457 | cursor: pointer; 458 | width: 25px; 459 | height: 19px; 460 | float: left; 461 | } 462 | div#control-container div.amplitude-mute.amplitude-not-muted { 463 | background: url("../img/volume.svg"); 464 | background-repeat: no-repeat; 465 | } 466 | div#control-container div.amplitude-mute.amplitude-muted { 467 | background: url("../img/mute.svg"); 468 | background-repeat: no-repeat; 469 | } 470 | 471 | div#control-container:after { 472 | content: ""; 473 | display: table; 474 | clear: both; 475 | } 476 | 477 | /* Small only */ 478 | @media screen and (max-width: 39.9375em) { 479 | div#amplitude-player div#repeat-container div#repeat { 480 | margin-left: auto; 481 | margin-right: auto; 482 | float: none; 483 | } 484 | div#amplitude-player div#repeat-container div#shuffle { 485 | display: none; 486 | } 487 | div#amplitude-player div#volume-container div.volume-controls { 488 | display: none; 489 | } 490 | div#amplitude-player div#volume-container div#shuffle-right { 491 | display: block; 492 | } 493 | } 494 | /* Medium only */ 495 | @media screen and (min-width: 40em) and (max-width: 63.9375em) { 496 | div#amplitude-player div#repeat-container div#repeat { 497 | margin-left: auto; 498 | margin-right: auto; 499 | float: none; 500 | } 501 | div#amplitude-player div#repeat-container div#shuffle { 502 | display: none; 503 | } 504 | div#amplitude-player div#volume-container div.volume-controls { 505 | display: none; 506 | } 507 | div#amplitude-player div#volume-container div#shuffle-right { 508 | display: block; 509 | } 510 | } 511 | /* Large and up */ 512 | @media screen and (min-width: 64em) { 513 | div#amplitude-player div#repeat-container div#repeat { 514 | margin-left: 10px; 515 | margin-right: 20px; 516 | float: left; 517 | } 518 | div#amplitude-player div#volume-container div#shuffle-right { 519 | display: none; 520 | } 521 | } 522 | input[type=range].amplitude-volume-slider { 523 | -webkit-appearance: none; 524 | width: calc( 100% - 30px); 525 | float: left; 526 | margin-top: 10px; 527 | margin-left: 5px; 528 | } 529 | 530 | @-moz-document url-prefix() { 531 | input[type=range].amplitude-volume-slider { 532 | margin-top: 0px; 533 | } 534 | } 535 | @supports (-ms-ime-align: auto) { 536 | input[type=range].amplitude-volume-slider { 537 | margin-top: 3px; 538 | height: 12px; 539 | background-color: rgba(255, 255, 255, 0) !important; 540 | z-index: 999; 541 | position: relative; 542 | } 543 | 544 | div.ms-range-fix { 545 | height: 1px; 546 | background-color: #A9A9A9; 547 | width: 67%; 548 | float: right; 549 | margin-top: -6px; 550 | z-index: 9; 551 | position: relative; 552 | } 553 | } 554 | @media all and (-ms-high-contrast: none) { 555 | *::-ms-backdrop, input[type=range].amplitude-volume-slider { 556 | margin-top: -24px; 557 | background-color: rgba(255, 255, 255, 0) !important; 558 | } 559 | } 560 | input[type=range].amplitude-volume-slider:focus { 561 | outline: none; 562 | } 563 | 564 | input[type=range].amplitude-volume-slider::-webkit-slider-runnable-track { 565 | width: 75%; 566 | height: 1px; 567 | cursor: pointer; 568 | animate: 0.2s; 569 | background: #CFD8DC; 570 | } 571 | 572 | input[type=range].amplitude-volume-slider::-webkit-slider-thumb { 573 | height: 10px; 574 | width: 10px; 575 | border-radius: 10px; 576 | background: #00A0FF; 577 | cursor: pointer; 578 | margin-top: -4px; 579 | -webkit-appearance: none; 580 | } 581 | 582 | input[type=range].amplitude-volume-slider:focus::-webkit-slider-runnable-track { 583 | background: #CFD8DC; 584 | } 585 | 586 | input[type=range].amplitude-volume-slider::-moz-range-track { 587 | width: 100%; 588 | height: 1px; 589 | cursor: pointer; 590 | animate: 0.2s; 591 | background: #CFD8DC; 592 | } 593 | 594 | input[type=range].amplitude-volume-slider::-moz-range-thumb { 595 | height: 10px; 596 | width: 10px; 597 | border-radius: 10px; 598 | background: #00A0FF; 599 | cursor: pointer; 600 | margin-top: -4px; 601 | } 602 | 603 | input[type=range].amplitude-volume-slider::-ms-track { 604 | width: 100%; 605 | height: 1px; 606 | cursor: pointer; 607 | animate: 0.2s; 608 | background: transparent; 609 | /*leave room for the larger thumb to overflow with a transparent border */ 610 | border-color: transparent; 611 | border-width: 15px 0; 612 | /*remove default tick marks*/ 613 | color: transparent; 614 | } 615 | 616 | input[type=range].amplitude-volume-slider::-ms-fill-lower { 617 | background: #CFD8DC; 618 | border-radius: 10px; 619 | } 620 | 621 | input[type=range].amplitude-volume-slider::-ms-fill-upper { 622 | background: #CFD8DC; 623 | border-radius: 10px; 624 | } 625 | 626 | input[type=range].amplitude-volume-slider::-ms-thumb { 627 | height: 10px; 628 | width: 10px; 629 | border-radius: 10px; 630 | background: #00A0FF; 631 | cursor: pointer; 632 | margin-top: 2px; 633 | } 634 | 635 | input[type=range].amplitude-volume-slider:focus::-ms-fill-lower { 636 | background: #CFD8DC; 637 | } 638 | 639 | input[type=range].amplitude-volume-slider:focus::-ms-fill-upper { 640 | background: #CFD8DC; 641 | } 642 | 643 | input[type=range].amplitude-volume-slider::-ms-tooltip { 644 | display: none; 645 | } 646 | 647 | div#time-container span.current-time { 648 | color: #607D8B; 649 | font-size: 14px; 650 | font-weight: 700; 651 | float: left; 652 | width: 15%; 653 | text-align: center; 654 | } 655 | div#time-container span.duration { 656 | color: #607D8B; 657 | font-size: 14px; 658 | font-weight: 700; 659 | float: left; 660 | width: 15%; 661 | text-align: center; 662 | } 663 | 664 | div#time-container:after { 665 | content: ""; 666 | display: table; 667 | clear: both; 668 | } 669 | 670 | div#meta-container { 671 | text-align: center; 672 | margin-top: 5px; 673 | } 674 | div#meta-container span.song-name { 675 | display: block; 676 | color: #272726; 677 | font-size: 20px; 678 | font-family: "Open Sans", sans-serif; 679 | white-space: nowrap; 680 | overflow: hidden; 681 | text-overflow: ellipsis; 682 | } 683 | div#meta-container div.song-artist-album { 684 | color: #607D8B; 685 | font-size: 14px; 686 | font-weight: 700; 687 | text-transform: uppercase; 688 | font-family: "Open Sans", sans-serif; 689 | white-space: nowrap; 690 | overflow: hidden; 691 | text-overflow: ellipsis; 692 | } 693 | div#meta-container div.song-artist-album span { 694 | display: block; 695 | } 696 | 697 | /* 698 | 3. Layout 699 | */ 700 | /* body { 701 | background-image: -webkit-linear-gradient(316deg, #3BD2AE 0%, #36BAC2 100%); 702 | background-image: linear-gradient(-226deg, #3BD2AE 0%, #36BAC2 100%); 703 | height: 100vh; 704 | } */ 705 | 706 | /* 707 | 4. Pages 708 | */ 709 | /* 710 | 5. Themes 711 | */ 712 | /* 713 | 6. Utils 714 | */ 715 | /* 716 | 7. Vendors 717 | */ 718 | 719 | /*# sourceMappingURL=examples/blue-playlist/css/app.css.map */ 720 | -------------------------------------------------------------------------------- /public/editApi.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | View API plugin 11 | 15 | 22 | 23 | 24 |
25 |
26 |
27 |
28 |
29 |
30 | Card image cap 37 |
38 |

39 |
40 |
41 |
42 |
43 | 46 | 49 |
50 |
51 |
52 | 53 |
54 |
55 |
56 |
57 |
58 | 63 | 68 | 73 | 78 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/favicon.ico -------------------------------------------------------------------------------- /public/img/list-play-hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/img/list-play-hover.png -------------------------------------------------------------------------------- /public/img/list-play-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/img/list-play-light.png -------------------------------------------------------------------------------- /public/img/mute.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | volume-x 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /public/img/now-playing.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Now Playing 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /public/img/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Oval 1 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /public/img/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Oval 1 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /public/img/volume.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | volume-2 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 20 | 21 | 30 | React App 31 | 32 | 33 | 34 |
35 | 45 | 50 | 55 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /public/searchApi.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | Search API plugin 11 | 15 | 24 | 25 | 26 | 42 |
43 | 67 |
68 | 69 |
70 |
71 |
72 |

73 | Search Reference Implementation 74 |

75 |

76 | Search implementation have to provide a UI for the user to search 77 | and select required media. This reference implementation does not 78 | have search logic, however, user might click on any tile below to 79 | emulate search result accepted. 80 |

81 |

82 | Click on any tile below to emulate selection has been accepted 83 |

84 |
85 |
86 | 87 |
88 |
89 |
90 |
91 |
92 |
93 | 94 |
95 |
96 |

97 | Back to top 98 |

99 |

100 | This is a search plugin reference implementation for the Custom Embed 101 |

102 |
103 |
104 | 109 | 114 | 119 | 208 | 209 | 210 | -------------------------------------------------------------------------------- /public/viewApi.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | View API plugin 11 | 15 | 16 | 17 | 37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | 49 | 54 | 59 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /public/webfonts/fa-brands-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/webfonts/fa-brands-400.eot -------------------------------------------------------------------------------- /public/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /public/webfonts/fa-brands-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/webfonts/fa-brands-400.woff -------------------------------------------------------------------------------- /public/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /public/webfonts/fa-regular-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/webfonts/fa-regular-400.eot -------------------------------------------------------------------------------- /public/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /public/webfonts/fa-regular-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/webfonts/fa-regular-400.woff -------------------------------------------------------------------------------- /public/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /public/webfonts/fa-solid-900.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/webfonts/fa-solid-900.eot -------------------------------------------------------------------------------- /public/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /public/webfonts/fa-solid-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/webfonts/fa-solid-900.woff -------------------------------------------------------------------------------- /public/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/washingtonpost/arc-custom-embed/f33df0813b194b0139098003d7453c37d69fb54f/public/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App-body { 2 | text-align: center; 3 | background-color: #eee; 4 | min-height: 100vh; 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | justify-content: center; 9 | font-size: calc(10px + 2vmin); 10 | color: white; 11 | padding: 5em 0; 12 | } 13 | 14 | .iframe-load-error { 15 | padding: 5em; 16 | background-color: #880000; 17 | } 18 | 19 | .iframe-container { 20 | width: 65%; 21 | } 22 | 23 | .iframe-loading { 24 | position: relative; 25 | top: 160px; 26 | margin: 0px 40px; 27 | height: 0px; 28 | } 29 | .iframe-loading span { 30 | padding: 40px 20vw; 31 | background-color: rgba(35, 35, 35, 0.95); 32 | } 33 | .App-divider { 34 | width: 98%; 35 | background: #f5f5c2; 36 | color: black; 37 | padding: 1em; 38 | margin: 1em; 39 | border: 2px solid yellow; 40 | } 41 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div') 7 | ReactDOM.render(, div) 8 | ReactDOM.unmountComponentAtNode(div) 9 | }) 10 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import './App.css' 3 | import SearchPanel from './SearchPanel' 4 | import Header from './Header' 5 | import ViewPanel from './ViewPanel' 6 | import EditPanel from './EditPanel' 7 | 8 | export default function App() { 9 | const [customEmbed, setCustomEmbed] = useState(null) 10 | const [isEditing, setIsEditing] = useState(false) 11 | return ( 12 |
13 |
14 |
15 | {customEmbed ? ( 16 | isEditing ? ( 17 | 22 | ) : ( 23 | { 27 | setCustomEmbed(null) 28 | }} 29 | /> 30 | ) 31 | ) : ( 32 | 33 | )} 34 |
35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/EditPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import IframeHost from './IframeHost' 3 | 4 | export interface EditPanelProps { 5 | setEditMode: (isEditing: boolean) => void 6 | setCustomEmbed: (customEmbed: Object | null) => void 7 | customEmbed: Object 8 | } 9 | 10 | const forbiddenKeys = new Set(['type', 'version', 'referent']) 11 | 12 | const configIsValid = (config: any): boolean => { 13 | const keys = Object.keys(config) 14 | const hasKey = keys.reduce( 15 | (result, key) => result || forbiddenKeys.has(key), 16 | false 17 | ) 18 | if (hasKey) { 19 | return false 20 | } 21 | return keys 22 | .filter( 23 | key => Object.prototype.toString.call(config[key]) === '[object Object]' 24 | ) 25 | .reduce((result, key) => { 26 | return result && configIsValid(config[key]) 27 | }, true) 28 | } 29 | 30 | // Passed object should be a valid `embed` object. see https://github.com/washingtonpost/ans-schema/blob/master/src/main/resources/schema/ans/0.10.0/story_elements/custom_embed.json#L30-L65 31 | const dataIsValid = (embed: any): boolean => { 32 | // See https://github.com/washingtonpost/ans-schema/blob/master/src/main/resources/schema/ans/0.10.0/story_elements/custom_embed.json#L37-L43 33 | if (!embed.id || embed.id.length > 128 || embed.id.length === 0) { 34 | return false 35 | } 36 | // See https://github.com/washingtonpost/ans-schema/blob/master/src/main/resources/schema/ans/0.10.0/story_elements/custom_embed.json#L46-L50 37 | if (!embed.url || embed.url.length > 512 || embed.url.length === 0) { 38 | return false 39 | } 40 | // See https://github.com/washingtonpost/ans-schema/blob/master/src/main/resources/schema/ans/0.10.0/story_elements/custom_embed.json#L54-L61 41 | if (!embed.config) { 42 | return false 43 | } 44 | return configIsValid(embed.config) 45 | } 46 | 47 | export default function EditPanel(props: EditPanelProps) { 48 | const editApi = localStorage.getItem('arc.custom_embed.editApi') 49 | const editApiTimeout = localStorage.getItem('arc.custom_embed.editApiTimeout') 50 | const hostLoadTimeout: string = 51 | localStorage.getItem('arc.custom_embed.hostLoadTimeout') || '0' 52 | 53 | const customEmbedEncoded = encodeURIComponent( 54 | JSON.stringify(props.customEmbed) 55 | ) 56 | 57 | useEffect(() => { 58 | const messageHandler = (event: MessageEvent) => { 59 | let messageData 60 | try { 61 | messageData = JSON.parse(event.data) 62 | } catch { 63 | return 64 | } 65 | // Data should be an object and source should match custom_embed 66 | if (!messageData || messageData.source !== 'custom_embed') { 67 | return 68 | } 69 | if (messageData.action === 'data') { 70 | if (!dataIsValid(messageData.data)) { 71 | alert( 72 | 'Custom embed config should not contain type, version or referent fields. It should have top level id and url fields.' 73 | ) 74 | } else { 75 | props.setCustomEmbed(messageData.data) 76 | props.setEditMode(false) 77 | } 78 | } 79 | if (messageData.action === 'cancel') { 80 | props.setEditMode(false) 81 | } 82 | } 83 | window.addEventListener('message', messageHandler) 84 | 85 | return function cleanup() { 86 | window.removeEventListener('message', messageHandler) 87 | } 88 | }, [props.setCustomEmbed]) 89 | 90 | return ( 91 | 92 |
93 |

94 | An Edit integration window should be loaded below. Edit integration is 95 | intended to edit Custom Embed content and send update information back 96 | to the host application. 97 |

98 |

99 | Edit integration has it's own controls to either apply changes or 100 | cancel. 101 |

102 |
103 | 107 |
108 | ) 109 | } 110 | -------------------------------------------------------------------------------- /src/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import SettingsInput from './SettingsInput' 3 | 4 | export default function Header() { 5 | return ( 6 |
7 | 91 |
92 | 108 |
109 |
110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /src/IframeHost.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useEffect, useCallback } from 'react' 2 | 3 | export interface IframeHostProps { 4 | source: string 5 | timeout: number 6 | } 7 | 8 | export default function IframeHost(props: IframeHostProps) { 9 | const iframeRef = useRef(null) 10 | const [height, setHeight] = useState(400) 11 | const [loadTimeout, setLoadTimeout] = useState(false) 12 | const [showLoading, setShowLoading] = useState(true) 13 | const [key] = useState(Date.now().toString(36)) 14 | 15 | const fullUrl = props.source.indexOf('?') === -1 ? 16 | `${props.source}?k=${key}` : 17 | `${props.source}&k=${key}` 18 | 19 | // Apply iframe height 20 | useEffect(() => { 21 | if (!iframeRef.current) { 22 | return 23 | } 24 | iframeRef.current.style.height = Math.max(height, 25) + 'px' 25 | }, [height]) 26 | 27 | useEffect(() => { 28 | const messageHandler = (event: MessageEvent) => { 29 | console.log('HOST:', event) 30 | let messageData 31 | try { 32 | messageData = JSON.parse(event.data) 33 | } catch { 34 | return 35 | } 36 | if (messageData && messageData.key !== key) { 37 | console.error('invalid key', messageData.key) 38 | return 39 | } 40 | 41 | // Data should be an object and source should match custom_embed 42 | if (!messageData || messageData.source !== 'custom_embed') { 43 | return 44 | } 45 | if (messageData.action === 'ready') { 46 | setShowLoading(false) 47 | clearTimer() 48 | if (messageData.data && messageData.data.height) { 49 | setHeight(Number.parseInt(messageData.data.height)) 50 | } 51 | } 52 | } 53 | const iframeLoadTimeout = () => { 54 | clearTimer() 55 | setLoadTimeout(true) 56 | } 57 | 58 | let timer = window.setTimeout(iframeLoadTimeout, props.timeout) 59 | const clearTimer = () => { 60 | timer && clearTimeout(timer) 61 | timer = 0 62 | } 63 | window.addEventListener('message', messageHandler) 64 | 65 | return function cleanup() { 66 | window.removeEventListener('message', messageHandler) 67 | clearTimer() 68 | } 69 | }, [props.source, props.timeout]) 70 | 71 | return ( 72 | 73 |
74 | The content below is a custom embed plugin page in a IFrame 75 |
76 | {loadTimeout ? ( 77 |
Integration Load Timeout
78 | ) : ( 79 |
80 | {showLoading ? ( 81 |
82 | Loading... 83 |
84 | ) : null} 85 |