├── .gitignore ├── README.md ├── Roadmap.md ├── example-blog.jpg ├── example.jpg ├── integration.jpg ├── package-lock.json ├── package.json ├── src ├── attach.ts ├── fetch.ts ├── index.ts ├── init.ts ├── popup │ ├── fetch.ts │ ├── index.ts │ ├── renderAssets.ts │ ├── renderContentTypes.ts │ ├── renderEntries.ts │ └── renderEntriesByCT.ts ├── state.ts ├── types.ts └── utils │ ├── animate.ts │ ├── dom.ts │ ├── events.ts │ ├── index.ts │ ├── links.ts │ └── style.ts ├── tsconfig.commonjs.json ├── tsconfig.json ├── tslint.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | lib/ 4 | dist/ 5 | /.idea/ 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Contentful-wizard 2 | 3 | [![npm version](https://badge.fury.io/js/contentful-wizard.svg)](https://badge.fury.io/js/contentful-wizard) 4 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) 5 | 6 | This is a library to add an interactive content explorer to your [contentful-powered project](https://www.contentful.com/) – it allows you to mark parts of your application, and this library will highlight marked block, add automatic tooltips on hover with links to contentful application. 7 | 8 | It allows your editors quickly understand which element on the page is responsible for specific entry and content type (or an asset, if you want to mark it to this details). 9 | 10 | > The example app: [live demo](https://contentful-wizard-tea.bloomca.me) | [integration commit](https://github.com/Bloomca/the-example-app.nodejs/commit/f4932887b6a1cc7a91072e54589a81c48e06d1f1) 11 | 12 | Screenshot of the example app 13 | 14 | > Blog example: [live demo](https://dist-dohdcjzdoz.now.sh/) | [integration commit](https://github.com/Bloomca/blog-in-5-minutes/commit/2419bcf23e54e59ec130728432b986078bc11b88) 15 | 16 | ![Blog example](./example-blog.jpg "Blog example") 17 | ![Example](./example.jpg "Example") 18 | 19 | This is only client-side library, it assumes DOM is available. 20 | Right now it is in pre-alpha stage, but you can take a look [at the roadmap](./Roadmap.md) to get a feeling what is going to be implemented. 21 | 22 | > This library is in alpha stage – it is a proof of concept with (likely) a lot of bugs. Feedback is welcome! 23 | 24 | ## Contribution 25 | 26 | This project is part of [contentful-userland](https://github.com/contentful-userland) which means that we’re always open to contributions and pull requests. You can learn more about how contentful userland is organized by visiting [our about repository](https://github.com/contentful-userland/about). 27 | 28 | ## Getting started 29 | 30 | First, you need to include this library into your application – you can just include [umd build](https://unpkg.com/contentful-wizard@0.0.1-alpha-6/dist/contentful-wizard.min.js). It will add a global object, which you can call to instantiate a wizard – this will iterate over all DOM elements, picking those with `data-ctfl-entry` and `data-ctfl-asset` data attributes, highlight borders of these elements and add tooltips on hover with all links and info about other elements on the page. 31 | 32 | > You can customize a lot of things, but this is the bare minimum, which might be enough 33 | 34 | ```html 35 | 36 | 37 |
38 | 39 |
40 | 41 |
42 | 43 |
44 | 45 | 51 | ``` 52 | 53 | ## Entry titles 54 | 55 | In the tooltip's content we show: 56 | - content types on the page 57 | - entries on the page 58 | - assets on the page 59 | 60 | Content types and assets have special properties (`name` for content types and `fields.title` for assets), but entries have only specified fields, so there is no way to generalize it. In order to address it, and to list entries in human-readable form, you can pass an `entryTitle` property into `CTFLWizard.init` method configuration object. There are several strategies supported: 61 | 62 | ```js 63 | CTFLWizard.init({ 64 | spaceId: 's25qxvg', 65 | key: 'f78aw812mlswwasw', // your API key 66 | // if all your entries should show this field's value 67 | entryTitle: 'myTitle', 68 | 69 | // or, if you need to display different fields 70 | // for different content types 71 | entryTitle: { 72 | [contenTypeId]: 'mySpecialTitle' 73 | } 74 | }); 75 | ``` 76 | 77 | If you don't provide any (or fields by your strategy don't exist or don't have a value), the library will try to get `title` field first, and then `name`. However, it is still pretty magical, so it is better to provide your own field names. 78 | 79 | > There is no "smart" guessing strategy (like trying to list all fields with string value and get one with a short enough value), since it can easily introduce incosistency, and it will become confusing. 80 | 81 | 82 | ## Initialization 83 | 84 | > In default configuration there is pre-configured host for you published entries and assets. 85 | > If you need to work with preview items define a custom host as `init` parameter. 86 | 87 | ```js 88 | CTFLWizard.init({ 89 | spaceId: 's25qxvg', 90 | key: 'f78aw812mlswwasw', // your API key 91 | host: 'preview.contentful.com' // preview host 92 | }); 93 | ``` 94 | 95 | 96 | ## Styling 97 | 98 | Your use-case might be different – for example, you need wide tooltips, or default colours don't match your schema; you can customize all default styles, except positioning (`top`, `left`, `right`, `bottom`), it is calculated automatically and adjusted to the content. You provide your own styling during calling `CTFLWizard.init`, and you can omit any of these properties, and the whole property is optional – below you can find a commented breakdown on all style options: 99 | 100 | > Since it is in pre-alpha (read: super-early) and styles are subject to change, I don't want to share styles to avoid frequent updates. Once it stabilizies, all default styles will be shown in this snippet 101 | 102 | ```js 103 | CTFLWizard.init({ 104 | spaceId: 's25qxvg', 105 | key: 'f78aw812mlswwasw', // your API key 106 | 107 | // the whole property is optional 108 | style: { 109 | // style of the container to indicate 110 | // that it has hover capabilities 111 | highlight: { 112 | border: '2px dashed #ccc' 113 | }, 114 | // style during the hover 115 | highlightHover: { 116 | border: '2px solid #ccc' 117 | }, 118 | // styles of the tooltip itself 119 | tooltip: { 120 | background: '#ccc' 121 | }, 122 | // overlay – when we hover types in the tooltip 123 | // we add overlay to corresponding elements 124 | overlay: { 125 | background: 'green' 126 | } 127 | } 128 | }); 129 | ``` 130 | 131 | ## Environments 132 | Contentful-wizard now supports [environments](https://www.contentful.com/developers/docs/concepts/multiple-environments/) 133 | It is optional feature with default 'master' value 134 | ``` 135 | CTFLWizard.init({ 136 | spaceId: 's25qxvg', 137 | key: 'f78aw812mlswwasw', // your API key 138 | environment: 'development' //environment name 139 | }) 140 | ``` 141 | 142 | ## Update Tooltips 143 | 144 | Create wizard instance initializes the library, attaching listeners to all elements with corresponding data-attributes to show tooltip on hovering. However, sometimes you change content on your page, and you would like to add tooltips for new content. In order to do that, you can invoke `.update` method on returned instance: 145 | 146 | > You can provide an arg for `update` method – it is an object with the same parameters as for `init`, so you can override any parameters you had set up before 147 | 148 | ```js 149 | const wizard = CTFLWizard.init({ 150 | spaceId: 's25qxvg', 151 | key: 'f78aw812mlswwasw' 152 | }); 153 | 154 | // after some changes 155 | wizard.update({ 156 | // new entry title field resolution 157 | entryTitle: 'newTitle' 158 | }); 159 | ``` 160 | 161 | Another use case is to remove all listeners completely – for example, you re-rendered everything, or contentful content became irrelevant. In order to do that, you can call `.destroy` method: 162 | 163 | ```js 164 | const wizard = CTFLWizard.init({ 165 | spaceId: 's25qxvg', 166 | key: 'f78aw812mlswwasw' 167 | }); 168 | 169 | // if you want to remove all contentful-wizard functionality 170 | wizard.destroy(); 171 | ``` 172 | 173 | ## Single Page Applications 174 | 175 | In case you have rich web application, where content can change very often, and the whole page updates don't make a lot of sense, since they happen too often, there is a way to attach/deattach them manually: 176 | 177 | ```js 178 | import { attach, init } from 'contentful-wizard'; 179 | 180 | init({ 181 | spaceId: 'your_space', 182 | key: 'CDA_key' 183 | }); 184 | 185 | const cancel = attach({ 186 | node: document.getElementById('myEntry'), 187 | spaceId: 'your_space', 188 | 189 | // in case you have an entry 190 | contentType: '', 191 | entry: '', 192 | // optional, by default 'title' and 'name' will be tried 193 | entryTitle: 'myTitleField', 194 | 195 | // in case you have an asset 196 | asset: '', 197 | 198 | // in case you want to provide your content on top 199 | // of the tooltip 200 | description: 'This is our main story', 201 | 202 | // If you want to override styles for this specific 203 | // node. They will be merged with passed in `init` fn 204 | // for structure see https://github.com/Bloomca/contentful-wizard#styling 205 | style: { ... } 206 | }); 207 | ``` 208 | 209 | In practice, using modern frameworks, it makes sense to add them in lifecycle hooks. I'll show it using [react lifecycle hooks](https://reactjs.org/docs/state-and-lifecycle.html#adding-lifecycle-methods-to-a-class) as an example, but it works similarly across all popular frameworks. 210 | 211 | ```js 212 | // init during the bootstrap of your application 213 | import { init } from 'contentful-wizard'; 214 | 215 | // this is needed to save your key 216 | init({ 217 | spaceId: 'your_space', 218 | key: 'CDA_key' 219 | }); 220 | 221 | // wizard component to wrap contentful blocks 222 | import React, { Component } from 'react'; 223 | import RPT from 'prop-types'; 224 | import { attach } from 'contentful-wizard'; 225 | 226 | export default class CTFLWizard extends Component { 227 | static propTypes = { 228 | children: RPT.node, 229 | entity: RPT.object, 230 | description: RPT.string 231 | } 232 | 233 | componentDidMount() { 234 | const { entity, description } = this.props; 235 | const isEntry = entity.sys.type === 'Entry'; 236 | 237 | // mount tooltip on our container 238 | this.cleanup = attach({ 239 | node: this.node, 240 | // decide which type of contentful entity we have 241 | entry: isEntry ? entity.sys.id : undefined, 242 | contentType: isEntry ? entity.sys.contentType.sys.id : undefined, 243 | asset: isEntry ? undefined : entity.sys.id, 244 | spaceId: entity.sys.space.sys.id, 245 | // description for the tooltip 246 | description 247 | }); 248 | } 249 | 250 | componentWillMount() { 251 | this.cleanup && this.cleanup(); 252 | } 253 | 254 | render() { 255 | const { children } = this.props; 256 | 257 | return ( 258 |
this.node => node}> 259 | {children} 260 |
261 | ); 262 | } 263 | } 264 | 265 | // later, in another component: 266 | import Wizard from '../components/wizard'; 267 | 268 | // ... 269 | const markup = ( 270 | 271 | {course.fields.description} 272 | 273 | ); 274 | ``` 275 | 276 | As you can see, you just have to initialize to save CDA key, and then write one component, which will attach and cleanup tooltips based on the lifecycle hooks. 277 | 278 | ## Roles and Permissions 279 | 280 | Contentful can have pretty intrinsic set of rules and permissions, and it is possible that all presented options can be confusing, since some roles don't have access to some parts of the application (for example, to content types). Unfortunately, right now there is no way to differentiate between roles – we are using CDA (or CPA) key, which is valid to get content of the whole space. 281 | 282 | However, there are couple of things in the roadmap: 283 | - configuration of the tooltip (so you can configure it to omit some parts of the tooltip) 284 | - control panel to show only some parts 285 | 286 | Please, create an issue in case you feel this is important (and if you have another idea, you are welcome to share it!). 287 | 288 | ## Contributing 289 | 290 | All contributions are more than welcome! If you want to add some new feature, I'd kindly ask you to create an issue first, so we can discuss the solution first – since the goal of this library is pretty abstract, it makes sense to discuss problem first, and only afterwards implement it, right? 291 | 292 | You can take a look [at the Roadmap](./Roadmap.md), in order to feel what is going to be implemented. If you feel something does not make sense, or can be done better, please submit an issue as well! 293 | 294 | ## License 295 | 296 | MIT 297 | -------------------------------------------------------------------------------- /Roadmap.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | This project is in a pre-alpha version, so this roadmap is just a list of points, which would be nice – don't treat it as written in stone points. 4 | There are no explicit priorities here, and some items can be thrown away, and some can be added later. 5 | 6 | - add configuration, so you can choose what (and in which order) to show in the tooltip 7 | - add control panel, which will allow to configure content on the fly 8 | - add possibility to style: 9 | - specific styles for content-types 10 | - improve tooltip: 11 | - add "nose", so it looks better 12 | - list of CTs/entries/assets: 13 | - fold more than 5 elements 14 | - add locale support 15 | 16 | - add field link to the popup 17 | 18 | ## Done 19 | 20 | - ~~add description data-attribute~~ 21 | - ~~add querying against preview option~~ 22 | - ~~add assets rendering~~ 23 | 24 | - add possibility to style: 25 | - ~~border/overlay for elements with data-attributes~~ 26 | 27 | - improve tooltip: 28 | - ~~automatic rearrangement according to the screen's position~~ 29 | - ~~automatically detect at which side it is better to render tooltip~~ 30 | -------------------------------------------------------------------------------- /example-blog.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contentful-userland/contentful-wizard/f9b8db590908a2fc3060e7b05c501840bfb3592b/example-blog.jpg -------------------------------------------------------------------------------- /example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contentful-userland/contentful-wizard/f9b8db590908a2fc3060e7b05c501840bfb3592b/example.jpg -------------------------------------------------------------------------------- /integration.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contentful-userland/contentful-wizard/f9b8db590908a2fc3060e7b05c501840bfb3592b/integration.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contentful-wizard", 3 | "version": "0.0.4", 4 | "description": "This is a library to add interactive content explorer to your contentful-powered project.", 5 | "jsnext:main": "lib/es2015/index.js", 6 | "module": "lib/es2015/index.js", 7 | "typings": "lib/es2015/index.d.ts", 8 | "main": "lib/commonjs/index.js", 9 | "files": [ 10 | "lib", 11 | "dist", 12 | "src", 13 | "README.md", 14 | "package.json" 15 | ], 16 | "scripts": { 17 | "clean": "rimraf lib dist coverage", 18 | "precommit": "lint-staged", 19 | "prepublish": "npm run clean && npm run test && npm run build", 20 | "build:es2015": "tsc -P tsconfig.json", 21 | "build:es2015:watch": "tsc --watch -P tsconfig.json", 22 | "build:commonjs": "tsc -P tsconfig.commonjs.json", 23 | "build:umd": "cross-env NODE_ENV=development webpack", 24 | "build:umd:min": "cross-env NODE_ENV=production webpack", 25 | "build:dist": "npm run build:umd && npm run build:umd:min", 26 | "build": "npm run build:es2015 && npm run build:commonjs && npm run build:dist", 27 | "lint": "tslint src/**/*.ts", 28 | "test": "npm run lint" 29 | }, 30 | "lint-staged": { 31 | "*.ts": [ 32 | "prettier --write", 33 | "git add" 34 | ] 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/Bloomca/contentful-wizard.git" 39 | }, 40 | "keywords": [ 41 | "contentful", 42 | "wizard", 43 | "explore" 44 | ], 45 | "author": "Seva Zaikov ", 46 | "license": "MIT", 47 | "bugs": { 48 | "url": "https://github.com/Bloomca/contentful-wizard/issues" 49 | }, 50 | "homepage": "https://github.com/Bloomca/contentful-wizard#readme", 51 | "dependencies": { 52 | "webpack": "^3.10.0", 53 | "contentful": "^8.3.7" 54 | }, 55 | "devDependencies": { 56 | "babel-core": "^6.26.0", 57 | "babel-preset-es2015-rollup": "^3.0.0", 58 | "cross-env": "^5.1.1", 59 | "husky": "^0.14.3", 60 | "lint-staged": "^6.0.0", 61 | "prettier": "1.9.1", 62 | "rimraf": "^2.6.2", 63 | "tslint": "^5.8.0", 64 | "tslint-config-prettier": "^1.6.0", 65 | "typescript": "^2.6.2", 66 | "uglifyjs-webpack-plugin": "^1.1.4" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/attach.ts: -------------------------------------------------------------------------------- 1 | import { showPopup } from "./popup"; 2 | import { 3 | getStyle, 4 | removeAssetNode, 5 | removeContentTypeNode, 6 | removeEntryNode, 7 | setAssetNode, 8 | setContentTypeNode, 9 | setEntryNode 10 | } from "./state"; 11 | import { IEntryTitle, IStyles } from "./types"; 12 | import { 13 | applyStyle, 14 | containsNode, 15 | isBrowser, 16 | mergeStyle, 17 | onHover 18 | } from "./utils"; 19 | 20 | export interface IAttachConfig { 21 | node: HTMLElement; 22 | contentType: string | null; 23 | entry: string | null; 24 | spaceId: string; 25 | asset: string | null; 26 | entryTitle?: IEntryTitle; 27 | description: string | null; 28 | style: IStyles; 29 | } 30 | 31 | export function attach({ 32 | node, 33 | contentType, 34 | entry, 35 | spaceId, 36 | entryTitle, 37 | description, 38 | style: rawStyle = getStyle({ spaceId }), 39 | asset 40 | }: IAttachConfig) { 41 | if (!isBrowser()) { 42 | // tslint:disable-next-line no-empty 43 | return () => {}; 44 | } 45 | 46 | if (contentType) { 47 | setContentTypeNode({ contentType, node }); 48 | } 49 | 50 | if (contentType && entry) { 51 | setEntryNode({ 52 | contentType, 53 | entry, 54 | node 55 | }); 56 | } 57 | 58 | if (asset) { 59 | setAssetNode({ 60 | asset, 61 | node 62 | }); 63 | } 64 | 65 | const style = 66 | rawStyle === getStyle({ spaceId }) ? rawStyle : mergeStyle(rawStyle); 67 | 68 | let destroyPopup: Function | null; 69 | let popupNode: HTMLElement; 70 | 71 | applyStyle({ 72 | node, 73 | style: style.highlight 74 | }); 75 | // 1. add style to indicate that you can hover 76 | // 2. show tooltip on hover 77 | const cleanHover = onHover({ 78 | node, 79 | onMouseEnter, 80 | onMouseLeave 81 | }); 82 | 83 | function onMouseEnter() { 84 | applyStyle({ 85 | node, 86 | style: style.highlightHover 87 | }); 88 | destroyPopup && destroyPopup(); 89 | const popupData = showPopup({ 90 | node, 91 | spaceId, 92 | entry, 93 | contentType, 94 | asset, 95 | cleanup: internalMouseLeave, 96 | entryTitle, 97 | description, 98 | style 99 | }); 100 | destroyPopup = popupData.destroy; 101 | popupNode = popupData.node; 102 | } 103 | 104 | function onMouseLeave(e: MouseEvent) { 105 | const checkingNode = e.relatedTarget as HTMLElement; 106 | 107 | if (!containsNode({ node: popupNode, checkingNode })) { 108 | internalMouseLeave(); 109 | } 110 | } 111 | 112 | return cleanup; 113 | 114 | function internalMouseLeave() { 115 | destroyPopup && destroyPopup(); 116 | destroyPopup = null; 117 | applyStyle({ 118 | node, 119 | style: style.highlight 120 | }); 121 | } 122 | 123 | function cleanup() { 124 | if (contentType) { 125 | removeContentTypeNode({ contentType, node }); 126 | } 127 | 128 | if (contentType && entry) { 129 | removeEntryNode({ contentType, node, entry }); 130 | } 131 | 132 | if (asset) { 133 | removeAssetNode({ asset, node }); 134 | } 135 | destroyPopup && destroyPopup(); 136 | cleanHover && cleanHover(); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/fetch.ts: -------------------------------------------------------------------------------- 1 | import { ContentfulClientApi } from "contentful"; 2 | 3 | export interface ISysEntity { 4 | sys: { 5 | id: string; 6 | }; 7 | } 8 | 9 | export interface IEntity { 10 | sys: { 11 | id: string; 12 | environment?: ISysEntity; 13 | }; 14 | name?: string; 15 | } 16 | 17 | const contentTypesData: { [key: string]: IEntity } = {}; 18 | const entriesData: { [key: string]: IEntity } = {}; 19 | const assetsData: { [key: string]: IEntity } = {}; 20 | 21 | let contentTypesPromise: Promise; 22 | let entriesPromise: Promise; 23 | let assetsPromise: Promise; 24 | 25 | export function fetchAssets({ client }: { client: ContentfulClientApi }) { 26 | if (!assetsPromise) { 27 | assetsPromise = client 28 | .getAssets({ 29 | limit: 1000 30 | }) 31 | .then(({ items }) => { 32 | items.forEach((item: IEntity) => { 33 | assetsData[item.sys.id] = item; 34 | }); 35 | }) 36 | .then(); 37 | } 38 | 39 | return assetsPromise; 40 | } 41 | 42 | export function fetch({ 43 | client, 44 | contentType, 45 | entry, 46 | asset 47 | }: { 48 | client: ContentfulClientApi; 49 | contentType: string | null; 50 | entry: string | null; 51 | asset: string | null; 52 | }) { 53 | if (!contentTypesPromise) { 54 | contentTypesPromise = client 55 | .getContentTypes({ 56 | // maximum – we should try to download everything 57 | limit: 1000 58 | }) 59 | .then(({ items }: { items: IEntity[] }) => { 60 | // ignore that we can skip some data 61 | items.forEach((item: IEntity) => { 62 | contentTypesData[item.sys.id] = item; 63 | }); 64 | }); 65 | } 66 | 67 | if (!entriesPromise) { 68 | entriesPromise = client 69 | .getEntries({ 70 | // maximum – we should try to download everything 71 | limit: 1000 72 | }) 73 | .then(({ items }: { items: IEntity[] }) => { 74 | // ignore that we can skip some data 75 | items.forEach(item => { 76 | entriesData[item.sys.id] = item; 77 | }); 78 | }); 79 | } 80 | 81 | if (!assetsPromise) { 82 | assetsPromise = client 83 | .getAssets({ 84 | limit: 1000 85 | }) 86 | .then(({ items }) => { 87 | items.forEach((item: IEntity) => { 88 | assetsData[item.sys.id] = item; 89 | }); 90 | }); 91 | } 92 | 93 | return Promise.all([contentTypesPromise, entriesPromise, assetsPromise]) 94 | .then(() => { 95 | if (contentType) { 96 | // check content type 97 | const contentTypeFetchedData = contentTypesData[contentType]; 98 | 99 | if (!contentTypeFetchedData) { 100 | return client.getContentType(contentType).then(item => { 101 | contentTypesData[item.sys.id] = item; 102 | }); 103 | } 104 | } 105 | }) 106 | .then(() => { 107 | if (entry) { 108 | // check entry 109 | const entryFetchedData = entriesData[entry]; 110 | 111 | if (!entryFetchedData) { 112 | return client.getEntry(entry).then(item => { 113 | entriesData[item.sys.id] = item; 114 | }); 115 | } 116 | } 117 | }) 118 | .then(() => { 119 | if (asset) { 120 | const assetFetchedData = assetsData[asset]; 121 | 122 | if (!assetFetchedData) { 123 | return client.getAsset(asset).then(item => { 124 | assetsData[item.sys.id] = item; 125 | }); 126 | } 127 | } 128 | }) 129 | .then(() => ({ contentTypesData, entriesData, assetsData })); 130 | } 131 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { attach } from "./attach"; 2 | import { init } from "./init"; 3 | 4 | export { init, attach }; 5 | -------------------------------------------------------------------------------- /src/init.ts: -------------------------------------------------------------------------------- 1 | import { ContentfulClientApi, createClient } from "contentful"; 2 | import { attach, IAttachConfig } from "./attach"; 3 | import { setStyle } from "./state"; 4 | import { IEntryTitle, IStyles } from "./types"; 5 | import { isBrowser, mergeStyle } from "./utils"; 6 | 7 | export const clients: { [key: string]: ContentfulClientApi } = {}; 8 | 9 | export function init({ 10 | key, 11 | spaceId, 12 | host, 13 | basePath, 14 | environment, 15 | entryTitle, 16 | style 17 | }: { 18 | key: string; 19 | spaceId: string; 20 | host?: string; 21 | environment?: string; 22 | basePath?: string; 23 | entryTitle?: IEntryTitle; 24 | style?: IStyles; 25 | }) { 26 | if (!isBrowser()) { 27 | return { 28 | // tslint:disable-next-line no-empty 29 | update: () => {}, 30 | // tslint:disable-next-line no-empty 31 | destroy: () => {} 32 | }; 33 | } 34 | 35 | clients[spaceId] = createClient({ 36 | // This is the space ID. A space is like a project folder in Contentful terms 37 | space: spaceId, 38 | // This is the access token for this space. Normally you get both ID and the token in the Contentful web app 39 | accessToken: key, 40 | // no additional requests 41 | resolveLinks: false, 42 | environment: environment ? environment : "master", 43 | basePath, 44 | host: host ? host : "cdn.contentful.com" 45 | }); 46 | 47 | const mergedStyle = mergeStyle(style); 48 | 49 | setStyle({ spaceId, style: mergedStyle }); 50 | 51 | let cleanup: Function | null = attachHandlers({ 52 | spaceId, 53 | entryTitle, 54 | style: mergedStyle 55 | }); 56 | 57 | return { 58 | // cleanup old tooltips and reattach everything once again 59 | update: () => { 60 | cleanup && cleanup(); 61 | cleanup = attachHandlers({ 62 | spaceId, 63 | entryTitle, 64 | style: mergedStyle 65 | }); 66 | }, 67 | destroy: () => { 68 | cleanup && cleanup(); 69 | cleanup = null; 70 | } 71 | }; 72 | } 73 | 74 | function attachHandlers({ 75 | spaceId, 76 | entryTitle, 77 | style 78 | }: { 79 | spaceId: string; 80 | entryTitle?: IEntryTitle; 81 | style: IStyles; 82 | }) { 83 | const entryElements = document.querySelectorAll("[data-ctfl-entry]"); 84 | const assetElements = document.querySelectorAll("[data-ctfl-asset]"); 85 | 86 | const allElements = [ 87 | ...Array.from(entryElements), 88 | ...Array.from(assetElements) 89 | ]; 90 | 91 | const cleanupFns: Function[] = []; 92 | 93 | Array.prototype.forEach.call(allElements, (el: HTMLElement) => { 94 | const contentType = el.getAttribute("data-ctfl-content-type"); 95 | const entry = el.getAttribute("data-ctfl-entry"); 96 | const description = el.getAttribute("data-ctfl-description"); 97 | const asset = el.getAttribute("data-ctfl-asset"); 98 | const params: IAttachConfig = { 99 | entryTitle, 100 | node: el, 101 | spaceId, 102 | contentType, 103 | entry, 104 | description, 105 | style, 106 | asset 107 | }; 108 | const cleanup = attach(params); 109 | 110 | cleanupFns.push(cleanup); 111 | }); 112 | 113 | return () => cleanupFns.forEach(fn => fn()); 114 | } 115 | -------------------------------------------------------------------------------- /src/popup/fetch.ts: -------------------------------------------------------------------------------- 1 | import { fetch } from "../fetch"; 2 | import { clients } from "../init"; 3 | import { IEntryTitle, IStyles } from "../types"; 4 | import { constructAssetURL, createElement } from "../utils"; 5 | import { 6 | constructContentTypeURL, 7 | constructEntryURL, 8 | constructSpaceURL 9 | } from "../utils"; 10 | import { renderAssets } from "./renderAssets"; 11 | import { renderContentTypes } from "./renderContentTypes"; 12 | import { renderEntriesByCt } from "./renderEntriesByCt"; 13 | 14 | export function fetchContent({ 15 | spaceId, 16 | contentType, 17 | entry, 18 | asset, 19 | entryTitle, 20 | description, 21 | style 22 | }: { 23 | spaceId: string; 24 | contentType: string | null; 25 | entry: string | null; 26 | asset: string | null; 27 | entryTitle?: IEntryTitle; 28 | description: string | null; 29 | style: IStyles; 30 | }) { 31 | let closed = false; 32 | const client = clients[spaceId]; 33 | const cleanupFns: Function[] = []; 34 | const promise = fetch({ client, contentType, entry, asset }).then( 35 | ({ contentTypesData, entriesData, assetsData }) => { 36 | if (!closed) { 37 | const container = document.createElement("div"); 38 | const { node: ctsContainer, cleanup: ctsCleanup } = renderContentTypes({ 39 | contentType, 40 | contentTypesData, 41 | spaceId, 42 | style 43 | }); 44 | const { 45 | node: entriesContainer, 46 | cleanup: entriesCleanup 47 | } = renderEntriesByCt({ 48 | contentType, 49 | contentTypesData, 50 | entriesData, 51 | entryTitle, 52 | spaceId, 53 | style 54 | }); 55 | 56 | const { node: assetsContainer, cleanup: assetsCleanup } = renderAssets({ 57 | assetsData, 58 | spaceId, 59 | style, 60 | asset 61 | }); 62 | cleanupFns.push(ctsCleanup, entriesCleanup, assetsCleanup); 63 | 64 | const spaceURL = constructSpaceURL({ spaceId }); 65 | const descriptionNode = createElement({ 66 | text: description 67 | }); 68 | 69 | const spaceLink = renderLink({ href: spaceURL, text: "Link to space" }); 70 | 71 | container.appendChild(descriptionNode); 72 | container.appendChild(spaceLink); 73 | 74 | const contentTypeData = contentType && contentTypesData[contentType]; 75 | const contentTypeEnvironment = contentTypeData 76 | ? contentTypeData.sys.environment && 77 | contentTypeData.sys.environment.sys.id 78 | : undefined; 79 | 80 | if (contentType) { 81 | const contentTypeURL = constructContentTypeURL({ 82 | contentType, 83 | spaceId, 84 | environment: contentTypeEnvironment 85 | }); 86 | const ctLink = renderLink({ 87 | href: contentTypeURL, 88 | text: "Link to content type" 89 | }); 90 | container.appendChild(ctLink); 91 | } 92 | 93 | const entryData = entry && entriesData[entry]; 94 | const entryEnvironment = entryData 95 | ? entryData.sys.environment && entryData.sys.environment.sys.id 96 | : undefined; 97 | 98 | if (entry) { 99 | const entryURL = constructEntryURL({ 100 | spaceId, 101 | entry, 102 | environment: entryEnvironment 103 | }); 104 | const entryLink = renderLink({ 105 | href: entryURL, 106 | text: "Link to entry" 107 | }); 108 | container.appendChild(entryLink); 109 | } 110 | 111 | if (asset) { 112 | const assetURL = constructAssetURL({ spaceId, asset }); 113 | const assetLink = renderLink({ 114 | href: assetURL, 115 | text: "Link to asset" 116 | }); 117 | container.appendChild(assetLink); 118 | } 119 | 120 | container.appendChild(ctsContainer); 121 | container.appendChild(entriesContainer); 122 | container.appendChild(assetsContainer); 123 | 124 | return container; 125 | } 126 | } 127 | ); 128 | 129 | return { 130 | promise, 131 | cleanup: () => { 132 | closed = true; 133 | cleanupFns.forEach(fn => fn()); 134 | } 135 | }; 136 | } 137 | 138 | function renderLink({ text, href }: { text: string; href: string }) { 139 | return createElement({ 140 | tag: "a", 141 | attrs: { 142 | href, 143 | target: "_blank" 144 | }, 145 | text, 146 | style: { 147 | textDecoration: "underline", 148 | color: "blue", 149 | display: "block" 150 | } 151 | }); 152 | } 153 | -------------------------------------------------------------------------------- /src/popup/index.ts: -------------------------------------------------------------------------------- 1 | import { IEntryTitle, IStyles } from "../types"; 2 | import { 3 | animate, 4 | applyStyle, 5 | containsNode, 6 | createElement, 7 | measureHeight, 8 | onHover 9 | } from "../utils"; 10 | import { fetchContent } from "./fetch"; 11 | 12 | export function showPopup({ 13 | node, 14 | spaceId, 15 | contentType, 16 | entry, 17 | cleanup, 18 | asset, 19 | entryTitle, 20 | description, 21 | style 22 | }: { 23 | node: HTMLElement; 24 | spaceId: string; 25 | environment?: string; 26 | contentType: string | null; 27 | entry: string | null; 28 | asset: string | null; 29 | cleanup: Function; 30 | entryTitle?: IEntryTitle; 31 | description: string | null; 32 | style: IStyles; 33 | }) { 34 | const loadingContent = ` 35 |
36 | ${description || ""} 37 |
38 | loading...`; 39 | const positionStyles = getCoords({ node, content: loadingContent }); 40 | const tooltip: HTMLElement = createElement({ 41 | style: Object.assign({}, style.tooltip, positionStyles), 42 | text: loadingContent 43 | }); 44 | 45 | const { promise, cleanup: cleanupContent } = fetchContent({ 46 | spaceId, 47 | contentType, 48 | entry, 49 | asset, 50 | entryTitle, 51 | description, 52 | style 53 | }); 54 | 55 | promise.then(content => { 56 | if (content) { 57 | tooltip.innerHTML = ""; 58 | const newPositionStyles = getCoords({ node, content }); 59 | applyStyle({ 60 | node: tooltip, 61 | style: newPositionStyles 62 | }); 63 | // we need to calculate height somehow 64 | tooltip.appendChild(content); 65 | } 66 | }); 67 | 68 | document.body.appendChild(tooltip); 69 | 70 | animate({ 71 | node: tooltip, 72 | start: 0, 73 | stop: 1 74 | }); 75 | 76 | const cleanupHover = onHover({ 77 | node: tooltip, 78 | onMouseLeave: (e: MouseEvent) => { 79 | const checkingNode = e.relatedTarget as HTMLElement; 80 | if (!containsNode({ node, checkingNode })) { 81 | cleanup(); 82 | } 83 | } 84 | }); 85 | 86 | return { 87 | node: tooltip, 88 | destroy: async () => { 89 | try { 90 | await animate({ 91 | node: tooltip, 92 | start: 1, 93 | stop: 0 94 | }); 95 | cleanupContent(); 96 | cleanupHover(); 97 | document.body.removeChild(tooltip); 98 | } catch (e) { 99 | // tslint:disable-next-line no-console 100 | console.log("error during removing tooltip::", e); 101 | } 102 | } 103 | }; 104 | } 105 | 106 | function getCoords({ 107 | node, 108 | content 109 | }: { 110 | node: HTMLElement; 111 | content: HTMLElement | string; 112 | }) { 113 | const horizontalStyles = positionHorizontally({ node }); 114 | const verticalStyles = positionVertically({ node, content }); 115 | return Object.assign({}, horizontalStyles, verticalStyles); 116 | } 117 | 118 | function positionHorizontally({ node }: { node: HTMLElement }) { 119 | const { right, left } = node.getBoundingClientRect(); 120 | const offsetX = window.pageXOffset; 121 | 122 | const pageWidth = document.documentElement.clientWidth; 123 | 124 | const rightSpace = pageWidth - right; 125 | const leftSpace = left; 126 | 127 | const positionStyles: { [key: string]: string } = {}; 128 | 129 | // by default we try to show it from the right side 130 | if (rightSpace > 350) { 131 | positionStyles.left = `${offsetX + right - 5}px`; 132 | positionStyles.right = "auto"; 133 | } else if (leftSpace > 350) { 134 | positionStyles.right = `${offsetX + pageWidth - left - 5}px`; 135 | positionStyles.left = "auto"; 136 | } else { 137 | // we have to render it inside, and we do it on the right side 138 | positionStyles.right = `${offsetX + rightSpace + 5}px`; 139 | positionStyles.left = "auto"; 140 | } 141 | 142 | return positionStyles; 143 | } 144 | 145 | function positionVertically({ 146 | node, 147 | content 148 | }: { 149 | node: HTMLElement; 150 | content: HTMLElement | string; 151 | }) { 152 | const { top, height } = node.getBoundingClientRect(); 153 | const offsetY = window.pageYOffset; 154 | const pageHeight = document.documentElement.clientHeight; 155 | const mediumOffset = top + height / 2; 156 | 157 | const maxSpace = Math.min(mediumOffset, pageHeight - mediumOffset) * 2; 158 | // consider padding of the tooltip itself. In case of 159 | // overriding styles it will become incorrect 160 | // TODO consider padding property 161 | const contentHeight = measureHeight(content) + 30; 162 | 163 | const positionStyles: { [key: string]: string } = {}; 164 | if (contentHeight >= pageHeight) { 165 | positionStyles.top = `${offsetY}px`; 166 | positionStyles.bottom = `auto`; 167 | } else if (contentHeight <= maxSpace) { 168 | positionStyles.top = `${offsetY + mediumOffset - contentHeight / 2}px`; 169 | positionStyles.bottom = `auto`; 170 | } else { 171 | const topOffset = 172 | mediumOffset < pageHeight - mediumOffset 173 | ? offsetY 174 | : offsetY + pageHeight - contentHeight; 175 | 176 | positionStyles.top = `${topOffset}px`; 177 | positionStyles.bottom = `auto`; 178 | } 179 | 180 | return positionStyles; 181 | } 182 | -------------------------------------------------------------------------------- /src/popup/renderAssets.ts: -------------------------------------------------------------------------------- 1 | import { getAssetsNodes } from "../state"; 2 | import { IStyles } from "../types"; 3 | import { 4 | constructAssetURL, 5 | createElement, 6 | onHover, 7 | renderOverlay 8 | } from "../utils"; 9 | 10 | export function renderAssets({ 11 | assetsData, 12 | spaceId, 13 | style, 14 | asset 15 | }: { 16 | assetsData: { [key: string]: any }; 17 | spaceId: string; 18 | style: IStyles; 19 | environment?: string; 20 | asset: string | null; 21 | }) { 22 | const assetsContainer = document.createElement("div"); 23 | const line = createElement({ 24 | style: { 25 | height: "1px", 26 | margin: "10px 0", 27 | background: "#ccc" 28 | } 29 | }); 30 | const header = createElement({ 31 | tag: "h3", 32 | text: "Assets on the page:", 33 | style: { 34 | lineHeight: "1.31", 35 | fontSize: "1.17em", 36 | marginTop: "0", 37 | marginBottom: "10px" 38 | } 39 | }); 40 | 41 | const cleanupFns: Function[] = []; 42 | const assetNodes = getAssetsNodes(); 43 | const filteredAssets = Object.keys(assetNodes).filter( 44 | assetAtPage => assetAtPage !== asset 45 | ); 46 | const assetsOnPage = [asset] 47 | .concat(filteredAssets) 48 | .filter(Boolean) 49 | .map((key: string) => ({ nodes: assetNodes[key], data: assetsData[key] })) 50 | .filter(({ nodes }) => nodes && nodes.length > 0); 51 | 52 | if (assetsOnPage.length > 0) { 53 | assetsContainer.appendChild(line); 54 | assetsContainer.appendChild(header); 55 | } 56 | 57 | assetsOnPage.forEach(({ nodes = [], data }: { data: any; nodes: any[] }) => { 58 | const element = document.createElement("div"); 59 | const link = constructAssetURL({ 60 | spaceId, 61 | asset: data.sys.id, 62 | environment: data.sys.environment && data.sys.environment.sys.id 63 | }); 64 | 65 | const linkNode = createElement({ 66 | tag: "a", 67 | attrs: { 68 | href: link, 69 | target: "_blank" 70 | }, 71 | text: data.fields.title || data.sys.id, 72 | style: { 73 | display: "inline-block", 74 | borderBottom: "1px dashed #ccc", 75 | paddingBottom: "2px", 76 | textDecoration: "none", 77 | marginBottom: "5px" 78 | } 79 | }); 80 | 81 | let overlays: Function[] = []; 82 | 83 | const cleanup = onHover({ 84 | node: linkNode, 85 | onMouseEnter: () => { 86 | nodes.forEach(node => { 87 | overlays.push(renderOverlay({ node, style: style.overlay })); 88 | }); 89 | }, 90 | onMouseLeave: cleanOverlays 91 | }); 92 | 93 | cleanupFns.push(() => { 94 | cleanOverlays(); 95 | cleanup(); 96 | }); 97 | 98 | element.appendChild(linkNode); 99 | assetsContainer.appendChild(element); 100 | 101 | function cleanOverlays() { 102 | overlays.forEach(fn => fn()); 103 | overlays = []; 104 | } 105 | }); 106 | 107 | return { 108 | node: assetsContainer, 109 | cleanup: () => { 110 | cleanupFns.forEach(fn => fn()); 111 | } 112 | }; 113 | } 114 | -------------------------------------------------------------------------------- /src/popup/renderContentTypes.ts: -------------------------------------------------------------------------------- 1 | import { IEntity } from "../fetch"; 2 | import { getContentTypeNodes } from "../state"; 3 | import { IStyles } from "../types"; 4 | import { 5 | constructContentTypeURL, 6 | createElement, 7 | onHover, 8 | renderOverlay 9 | } from "../utils"; 10 | 11 | export function renderContentTypes({ 12 | contentTypesData, 13 | spaceId, 14 | style, 15 | contentType 16 | }: { 17 | contentTypesData: { [key: string]: any }; 18 | spaceId: string; 19 | style: IStyles; 20 | contentType: string | null; 21 | }) { 22 | const ctsContainer = document.createElement("div"); 23 | const line = createElement({ 24 | style: { 25 | height: "1px", 26 | margin: "10px 0", 27 | background: "#ccc" 28 | } 29 | }); 30 | const header = createElement({ 31 | tag: "h3", 32 | text: "Content types on the page:", 33 | style: { 34 | lineHeight: "1.31", 35 | fontSize: "1.17em", 36 | marginTop: "0", 37 | marginBottom: "10px" 38 | } 39 | }); 40 | 41 | const cleanupFns: Function[] = []; 42 | const contentTypeNodes = getContentTypeNodes(); 43 | const filteredCTNodes = Object.keys(contentTypeNodes).filter( 44 | contentTypeAtPage => contentTypeAtPage !== contentType 45 | ); 46 | const ctsOnPage = [contentType] 47 | .concat(filteredCTNodes) 48 | .filter(Boolean) 49 | .map((key: string) => ({ 50 | nodes: contentTypeNodes[key], 51 | data: contentTypesData[key] 52 | })) 53 | .filter(({ nodes }) => nodes && nodes.length > 0); 54 | 55 | if (ctsOnPage.length > 0) { 56 | ctsContainer.appendChild(line); 57 | ctsContainer.appendChild(header); 58 | } 59 | ctsOnPage.forEach(({ nodes = [], data }: { data: IEntity; nodes: any[] }) => { 60 | const element = document.createElement("div"); 61 | const link = constructContentTypeURL({ 62 | spaceId, 63 | contentType: data.sys.id, 64 | environment: data.sys.environment && data.sys.environment.sys.id 65 | }); 66 | 67 | const linkNode = createElement({ 68 | tag: "a", 69 | attrs: { 70 | href: link, 71 | target: "_blank" 72 | }, 73 | text: data.name || "No name property!", 74 | style: { 75 | display: "inline-block", 76 | borderBottom: "1px dashed #ccc", 77 | textDecoration: "none", 78 | paddingBottom: "2px", 79 | marginBottom: "5px" 80 | } 81 | }); 82 | 83 | let overlays: Function[] = []; 84 | 85 | const cleanup = onHover({ 86 | node: linkNode, 87 | onMouseEnter: () => { 88 | nodes.forEach(node => { 89 | overlays.push(renderOverlay({ node, style: style.overlay })); 90 | }); 91 | }, 92 | onMouseLeave: cleanOverlays 93 | }); 94 | 95 | cleanupFns.push(() => { 96 | cleanOverlays(); 97 | cleanup(); 98 | }); 99 | 100 | element.appendChild(linkNode); 101 | ctsContainer.appendChild(element); 102 | 103 | function cleanOverlays() { 104 | overlays.forEach(fn => fn()); 105 | overlays = []; 106 | } 107 | }); 108 | 109 | return { 110 | node: ctsContainer, 111 | cleanup: () => { 112 | cleanupFns.forEach(fn => fn()); 113 | } 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /src/popup/renderEntries.ts: -------------------------------------------------------------------------------- 1 | import { getCTEntryNodes } from "../state"; 2 | import { IEntryTitle, IStyles } from "../types"; 3 | import { 4 | constructEntryURL, 5 | createElement, 6 | getEntryTitle, 7 | onHover, 8 | renderOverlay 9 | } from "../utils"; 10 | 11 | export function renderEntries({ 12 | contentTypesData, 13 | entriesData, 14 | spaceId, 15 | contentType, 16 | entryTitle, 17 | style 18 | }: { 19 | contentTypesData: { [key: string]: any }; 20 | entriesData: { [key: string]: any }; 21 | spaceId: string; 22 | contentType: string; 23 | entryTitle?: IEntryTitle; 24 | style: IStyles; 25 | }) { 26 | const contentTypeData = contentTypesData[contentType]; 27 | const ctsContainer = document.createElement("div"); 28 | const line = createElement({ 29 | style: { 30 | height: "1px", 31 | margin: "10px 0", 32 | background: "#ccc" 33 | } 34 | }); 35 | const header = createElement({ 36 | tag: "h3", 37 | text: `${contentTypeData.name} entries:`, 38 | style: { 39 | lineHeight: "1.31", 40 | fontSize: "1.17em", 41 | marginTop: "0", 42 | marginBottom: "10px" 43 | } 44 | }); 45 | 46 | const cleanupFns: Function[] = []; 47 | 48 | const entries = getCTEntryNodes({ contentType }); 49 | const entriesKeys = Object.keys(entries) 50 | .map(entryId => ({ 51 | entry: entryId, 52 | nodes: entries[entryId], 53 | data: entriesData[entryId] 54 | })) 55 | .filter(({ nodes }) => nodes && nodes.length > 0); 56 | 57 | if (entriesKeys.length > 0) { 58 | ctsContainer.appendChild(line); 59 | ctsContainer.appendChild(header); 60 | } 61 | 62 | entriesKeys.forEach(({ entry, nodes, data }) => { 63 | const element = document.createElement("div"); 64 | const link = constructEntryURL({ 65 | spaceId, 66 | entry, 67 | environment: data.sys.environment && data.sys.environment.sys.id 68 | }); 69 | 70 | const linkNode = createElement({ 71 | tag: "a", 72 | attrs: { 73 | href: link, 74 | target: "_blank" 75 | }, 76 | text: getEntryTitle({ entry: data, entryTitle }), 77 | style: { 78 | display: "inline-block", 79 | borderBottom: "1px dashed #ccc", 80 | textDecoration: "none", 81 | paddingBottom: "2px", 82 | marginBottom: "5px" 83 | } 84 | }); 85 | 86 | let overlays: Function[] = []; 87 | 88 | const cleanup = onHover({ 89 | node: linkNode, 90 | onMouseEnter: () => { 91 | nodes.forEach(node => { 92 | overlays.push(renderOverlay({ node, style: style.overlay })); 93 | }); 94 | }, 95 | onMouseLeave: cleanOverlays 96 | }); 97 | 98 | cleanupFns.push(() => { 99 | cleanOverlays(); 100 | cleanup(); 101 | }); 102 | 103 | element.appendChild(linkNode); 104 | ctsContainer.appendChild(element); 105 | 106 | function cleanOverlays() { 107 | overlays.forEach(fn => fn()); 108 | overlays = []; 109 | } 110 | }); 111 | 112 | return { 113 | node: ctsContainer, 114 | cleanup: () => { 115 | cleanupFns.forEach(fn => fn()); 116 | } 117 | }; 118 | } 119 | -------------------------------------------------------------------------------- /src/popup/renderEntriesByCT.ts: -------------------------------------------------------------------------------- 1 | import { getContentTypeNodes } from "../state"; 2 | import { IEntryTitle, IStyles } from "../types"; 3 | import { createElement } from "../utils"; 4 | import { renderEntries } from "./renderEntries"; 5 | 6 | export function renderEntriesByCt({ 7 | contentType, 8 | contentTypesData, 9 | entriesData, 10 | spaceId, 11 | entryTitle, 12 | style 13 | }: { 14 | contentType: string | null; 15 | contentTypesData: { [key: string]: any }; 16 | entriesData: { [key: string]: any }; 17 | spaceId: string; 18 | entryTitle?: IEntryTitle; 19 | style: IStyles; 20 | }) { 21 | const container = createElement(); 22 | const cleanupFns: Function[] = []; 23 | const filteredCTs = Object.keys(getContentTypeNodes()).filter( 24 | contentTypeAtPage => contentTypeAtPage !== contentType 25 | ); 26 | [contentType] 27 | .concat(filteredCTs) 28 | .filter(Boolean) 29 | .forEach((contentTypeAtPage: string) => { 30 | const { node, cleanup } = renderEntries({ 31 | contentType: contentTypeAtPage, 32 | contentTypesData, 33 | entriesData, 34 | spaceId, 35 | entryTitle, 36 | style 37 | }); 38 | 39 | container.appendChild(node); 40 | cleanupFns.push(cleanup); 41 | }); 42 | 43 | return { 44 | node: container, 45 | cleanup: () => { 46 | cleanupFns.forEach(fn => fn()); 47 | } 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import { IStyles } from "./types"; 2 | 3 | export interface IContentTypeNodes { 4 | [key: string]: HTMLElement[]; 5 | } 6 | 7 | export interface IEntryNodes { 8 | [key: string]: { 9 | [key: string]: HTMLElement[]; 10 | }; 11 | } 12 | 13 | export interface IAssetNodes { 14 | [key: string]: HTMLElement[]; 15 | } 16 | 17 | const contentTypeNodes: IContentTypeNodes = {}; 18 | const entryNodes: IEntryNodes = {}; 19 | const assetNodes: IAssetNodes = {}; 20 | 21 | const styles: { [key: string]: IStyles } = {}; 22 | 23 | export function setStyle({ 24 | spaceId, 25 | style 26 | }: { 27 | spaceId: string; 28 | style: IStyles; 29 | }) { 30 | styles[spaceId] = style; 31 | } 32 | 33 | export function getStyle({ spaceId }: { spaceId: string }) { 34 | return styles[spaceId]; 35 | } 36 | 37 | export function getAssetsNodes(): IAssetNodes { 38 | return assetNodes; 39 | } 40 | 41 | export function getAssetNodes({ asset }: { asset: string }): HTMLElement[] { 42 | return assetNodes[asset] || []; 43 | } 44 | 45 | export function removeAssetNode({ 46 | asset, 47 | node 48 | }: { 49 | asset: string; 50 | node: HTMLElement; 51 | }): IAssetNodes { 52 | if (assetNodes[asset]) { 53 | assetNodes[asset] = assetNodes[asset].filter( 54 | assetNode => assetNode !== node 55 | ); 56 | } 57 | 58 | return assetNodes; 59 | } 60 | 61 | export function setAssetNode({ 62 | asset, 63 | node 64 | }: { 65 | asset: string; 66 | node: HTMLElement; 67 | }): IAssetNodes { 68 | if (!assetNodes[asset]) { 69 | assetNodes[asset] = []; 70 | } 71 | 72 | assetNodes[asset].push(node); 73 | 74 | return assetNodes; 75 | } 76 | 77 | export function getContentTypeNodes(): IContentTypeNodes { 78 | return contentTypeNodes; 79 | } 80 | 81 | export function getContentTypeNode({ 82 | contentType 83 | }: { 84 | contentType: string; 85 | }): HTMLElement[] { 86 | return contentTypeNodes[contentType] || []; 87 | } 88 | 89 | export function setContentTypeNode({ 90 | contentType, 91 | node 92 | }: { 93 | contentType: string; 94 | node: HTMLElement; 95 | }): IContentTypeNodes { 96 | if (!contentTypeNodes[contentType]) { 97 | contentTypeNodes[contentType] = []; 98 | } 99 | 100 | contentTypeNodes[contentType].push(node); 101 | 102 | return contentTypeNodes; 103 | } 104 | 105 | export function removeContentTypeNode({ 106 | contentType, 107 | node: removeNode 108 | }: { 109 | contentType: string; 110 | node: HTMLElement; 111 | }): IContentTypeNodes { 112 | if (contentTypeNodes[contentType]) { 113 | contentTypeNodes[contentType] = contentTypeNodes[contentType].filter( 114 | node => node !== removeNode 115 | ); 116 | } 117 | 118 | return contentTypeNodes; 119 | } 120 | 121 | export function getCTEntryNodes({ 122 | contentType 123 | }: { 124 | contentType: string; 125 | }): { [key: string]: HTMLElement[] } { 126 | return entryNodes[contentType] || []; 127 | } 128 | 129 | export function getEntryNodes({ 130 | contentType, 131 | entry 132 | }: { 133 | contentType: string; 134 | entry: string; 135 | }): HTMLElement[] { 136 | if (entryNodes[contentType] && entryNodes[contentType][entry]) { 137 | return entryNodes[contentType][entry]; 138 | } 139 | 140 | return []; 141 | } 142 | 143 | export function setEntryNode({ 144 | contentType, 145 | entry, 146 | node 147 | }: { 148 | contentType: string; 149 | entry: string; 150 | node: HTMLElement; 151 | }): IEntryNodes { 152 | if (!entryNodes[contentType]) { 153 | entryNodes[contentType] = {}; 154 | } 155 | 156 | if (!entryNodes[contentType][entry]) { 157 | entryNodes[contentType][entry] = []; 158 | } 159 | 160 | entryNodes[contentType][entry].push(node); 161 | 162 | return entryNodes; 163 | } 164 | 165 | export function removeEntryNode({ 166 | contentType, 167 | node: entryNode, 168 | entry 169 | }: { 170 | contentType: string; 171 | node: HTMLElement; 172 | entry: string; 173 | }): IEntryNodes { 174 | if (entryNodes[contentType] && entryNodes[contentType][entry]) { 175 | entryNodes[contentType][entry] = entryNodes[contentType][entry].filter( 176 | node => node !== entryNode 177 | ); 178 | } 179 | 180 | return entryNodes; 181 | } 182 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export declare type IEntryTitle = 2 | | { 3 | [key: string]: string; 4 | } 5 | | string; 6 | 7 | export interface IStyle { 8 | [key: string]: string; 9 | } 10 | 11 | export interface IStyles { 12 | highlight?: IStyle; 13 | highlightHover?: IStyle; 14 | tooltip?: IStyle; 15 | overlay?: IStyle; 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/animate.ts: -------------------------------------------------------------------------------- 1 | function sleep(amount: number): Promise { 2 | return new Promise(resolve => { 3 | setTimeout(resolve, amount); 4 | }); 5 | } 6 | 7 | export function animate({ 8 | node, 9 | time = 200, 10 | start, 11 | stop, 12 | property = "opacity" 13 | }: { 14 | node: HTMLElement; 15 | time?: number; 16 | start: number; 17 | stop: number; 18 | property?: "opacity"; 19 | }): Promise { 20 | // no need to re-render more often than 16ms – 1 frame 21 | const steps = Math.ceil(time / 16); 22 | return new Promise(async resolve => { 23 | const period = (stop - start) / steps; 24 | 25 | for (let i = 1; i <= steps; i++) { 26 | node.style[property] = String(start + period * i); 27 | await sleep(time / steps); 28 | } 29 | 30 | resolve(); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/dom.ts: -------------------------------------------------------------------------------- 1 | import { IStyle } from "../types"; 2 | import { animate } from "./animate"; 3 | 4 | export function renderOverlay({ 5 | node, 6 | style 7 | }: { 8 | node: HTMLElement; 9 | style?: IStyle; 10 | }) { 11 | const { bottom, top, left, right } = node.getBoundingClientRect(); 12 | 13 | const offsetY = window.pageYOffset; 14 | const offsetX = window.pageXOffset; 15 | 16 | const overlay = createElement({ 17 | style: Object.assign({}, style, { 18 | top: `${offsetY + top}px`, 19 | height: `${bottom - top}px`, 20 | left: `${offsetX + left}px`, 21 | width: `${right - left}px` 22 | }) 23 | }); 24 | 25 | document.body.appendChild(overlay); 26 | 27 | animate({ 28 | node: overlay, 29 | start: 0, 30 | stop: 0.25 31 | }); 32 | 33 | return async () => { 34 | try { 35 | await animate({ 36 | node: overlay, 37 | start: 0.25, 38 | stop: 0 39 | }); 40 | document.body.removeChild(overlay); 41 | } catch (e) { 42 | // tslint:disable-next-line no-console 43 | console.log("error during removing overlay::", e); 44 | } 45 | }; 46 | } 47 | 48 | export function applyStyle({ 49 | node, 50 | style 51 | }: { 52 | node: HTMLElement; 53 | style?: { [key: string]: string } | IStyle; 54 | }): void { 55 | Object.assign(node.style, style); 56 | } 57 | 58 | export function createElement( 59 | { 60 | tag = "div", 61 | text, 62 | style, 63 | attrs 64 | }: { 65 | tag?: 66 | | "div" 67 | | "img" 68 | | "a" 69 | | "h1" 70 | | "h2" 71 | | "h3" 72 | | "h4" 73 | | "h5" 74 | | "h6" 75 | | "span"; 76 | text?: HTMLElement | string | null; 77 | style?: { [key: string]: string }; 78 | attrs?: { [key: string]: string }; 79 | } = {} 80 | ): HTMLElement { 81 | const element = document.createElement(tag); 82 | if (text && typeof text === "string") { 83 | element.innerHTML = text; 84 | } else if (text && typeof text === "object") { 85 | element.appendChild(text); 86 | } 87 | 88 | if (style) { 89 | applyStyle({ 90 | node: element, 91 | style 92 | }); 93 | } 94 | 95 | if (attrs) { 96 | Object.keys(attrs).forEach(attr => { 97 | const value = attrs[attr]; 98 | 99 | element.setAttribute(attr, value); 100 | }); 101 | } 102 | 103 | return element; 104 | } 105 | 106 | export function measureHeight(content: HTMLElement | string): number { 107 | const container = createElement({ 108 | style: { 109 | position: "absolute", 110 | visibility: "hidden" 111 | }, 112 | text: content 113 | }); 114 | 115 | document.body.appendChild(container); 116 | 117 | const { height } = container.getBoundingClientRect(); 118 | 119 | document.body.removeChild(container); 120 | 121 | return height; 122 | } 123 | 124 | export function containsNode({ 125 | node, 126 | checkingNode 127 | }: { 128 | node: HTMLElement; 129 | checkingNode: HTMLElement; 130 | }) { 131 | let inspectingNode: HTMLElement | null = checkingNode; 132 | while (inspectingNode !== null) { 133 | if (inspectingNode === node) { 134 | return true; 135 | } 136 | 137 | inspectingNode = inspectingNode.parentElement; 138 | } 139 | 140 | return false; 141 | } 142 | 143 | export function isBrowser() { 144 | return typeof window !== "undefined" && typeof document !== "undefined"; 145 | } 146 | -------------------------------------------------------------------------------- /src/utils/events.ts: -------------------------------------------------------------------------------- 1 | export function onHover({ 2 | node, 3 | onMouseEnter, 4 | onMouseLeave 5 | }: { 6 | node: HTMLElement; 7 | onMouseEnter?: Function; 8 | onMouseLeave?: Function; 9 | }) { 10 | node.addEventListener("mouseenter", internalMouseEnter); 11 | 12 | function internalMouseEnter(e: MouseEvent) { 13 | onMouseEnter && onMouseEnter(e); 14 | 15 | node.addEventListener("mouseleave", internalMouseLeave); 16 | } 17 | 18 | function internalMouseLeave(e: MouseEvent) { 19 | onMouseLeave && onMouseLeave(e); 20 | node.removeEventListener("mouseleave", internalMouseLeave); 21 | } 22 | 23 | return () => { 24 | node.removeEventListener("mouseenter", internalMouseEnter); 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { animate } from "./animate"; 2 | import { 3 | applyStyle, 4 | containsNode, 5 | createElement, 6 | isBrowser, 7 | measureHeight, 8 | renderOverlay 9 | } from "./dom"; 10 | import { onHover } from "./events"; 11 | import { 12 | constructAssetURL, 13 | constructContentTypeURL, 14 | constructEntryURL, 15 | constructSpaceURL, 16 | getEntryTitle 17 | } from "./links"; 18 | import { mergeStyle } from "./style"; 19 | 20 | export { 21 | onHover, 22 | renderOverlay, 23 | createElement, 24 | applyStyle, 25 | animate, 26 | containsNode, 27 | constructAssetURL, 28 | constructSpaceURL, 29 | constructContentTypeURL, 30 | constructEntryURL, 31 | getEntryTitle, 32 | isBrowser, 33 | mergeStyle, 34 | measureHeight 35 | }; 36 | -------------------------------------------------------------------------------- /src/utils/links.ts: -------------------------------------------------------------------------------- 1 | import { IEntryTitle } from "../types"; 2 | 3 | const appPrefix = "https://app.contentful.com"; 4 | 5 | export function constructSpaceURL({ spaceId }: { spaceId: string }) { 6 | return `${appPrefix}/spaces/${spaceId}`; 7 | } 8 | 9 | export function constructContentTypeURL({ 10 | spaceId, 11 | contentType, 12 | environment 13 | }: { 14 | spaceId: string; 15 | contentType: string; 16 | environment?: string; 17 | }) { 18 | return environment 19 | ? `${appPrefix}/spaces/${spaceId}/environments/${environment}/content_types/${contentType}/fields` 20 | : `${appPrefix}/spaces/${spaceId}/content_types/${contentType}/fields`; 21 | } 22 | 23 | export function constructEntryURL({ 24 | spaceId, 25 | entry, 26 | environment 27 | }: { 28 | spaceId: string; 29 | entry: string; 30 | environment?: string; 31 | }) { 32 | return environment 33 | ? `${appPrefix}/spaces/${spaceId}/environments/${environment}/entries/${entry}` 34 | : `${appPrefix}/spaces/${spaceId}/entries/${entry}`; 35 | } 36 | 37 | export function constructMediaURL({ 38 | spaceId, 39 | environment 40 | }: { 41 | spaceId: string; 42 | environment: string | undefined; 43 | }) { 44 | return environment 45 | ? `${appPrefix}/spaces/${spaceId}/environments/${environment}/assets` 46 | : `${appPrefix}/spaces/${spaceId}/assets`; 47 | } 48 | 49 | export function constructAssetURL({ 50 | spaceId, 51 | asset, 52 | environment 53 | }: { 54 | spaceId: string; 55 | asset: string; 56 | environment?: string; 57 | }) { 58 | return environment 59 | ? `${appPrefix}/spaces/${spaceId}/environments/${environment}/assets/${asset}` 60 | : `${appPrefix}/spaces/${spaceId}/assets/${asset}`; 61 | } 62 | 63 | export function getEntryTitle({ 64 | entry, 65 | entryTitle 66 | }: { 67 | entry: any; 68 | entryTitle?: IEntryTitle; 69 | }) { 70 | const field = getEntryTitleField({ entry, entryTitle }); 71 | let value; 72 | 73 | [field, "title", "name"].filter(Boolean).some((property: string) => { 74 | value = entry.fields[property]; 75 | 76 | return Boolean(value); 77 | }); 78 | 79 | return value || entry.sys.id; 80 | } 81 | 82 | function getEntryTitleField({ 83 | entry, 84 | entryTitle 85 | }: { 86 | entry: any; 87 | entryTitle?: IEntryTitle; 88 | }) { 89 | if (typeof entryTitle === "string") { 90 | return entryTitle; 91 | } else if (typeof entryTitle === "object") { 92 | return entryTitle[entry.sys.contentType.id]; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/utils/style.ts: -------------------------------------------------------------------------------- 1 | import { IStyles } from "../types"; 2 | 3 | const highlightStyles = { 4 | border: "2px dashed red", 5 | transition: "0.3s border" 6 | }; 7 | 8 | const highlightHoverStyles = { 9 | border: "2px solid red", 10 | transition: "0.3s border" 11 | }; 12 | 13 | const tooltipStyles = { 14 | position: "absolute", 15 | background: "#fff", 16 | zIndex: "999", 17 | minWidth: "150px", 18 | maxWidth: "350px", 19 | maxHeight: "100vh", 20 | overflowY: "scroll", 21 | padding: "15px", 22 | border: "2px solid #ccc", 23 | borderRadius: "5px", 24 | opacity: "0" 25 | }; 26 | 27 | const overlayStyles = { 28 | position: "absolute", 29 | opacity: "0", 30 | boxSizing: "border-box", 31 | // let's hope we override everything, except the tooltip 32 | zIndex: "998", 33 | background: "red" 34 | }; 35 | 36 | export function mergeStyle(passedStyle: IStyles = {}) { 37 | const newStyle: IStyles = {}; 38 | 39 | newStyle.highlight = Object.assign( 40 | {}, 41 | highlightStyles, 42 | passedStyle.highlight 43 | ); 44 | newStyle.highlightHover = Object.assign( 45 | {}, 46 | highlightHoverStyles, 47 | passedStyle.highlightHover 48 | ); 49 | 50 | newStyle.tooltip = Object.assign({}, tooltipStyles, passedStyle.tooltip); 51 | newStyle.overlay = Object.assign({}, overlayStyles, passedStyle.overlay); 52 | 53 | return newStyle; 54 | } 55 | -------------------------------------------------------------------------------- /tsconfig.commonjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "moduleResolution": "node", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "lib": [ 8 | "es5", 9 | "es2015", 10 | "dom" 11 | ], 12 | "noImplicitAny": true, 13 | "sourceMap": true, 14 | "noUnusedParameters": true, 15 | "strictNullChecks": true, 16 | "outDir": "lib/commonjs", 17 | "types": [ 18 | ] 19 | }, 20 | "files": [ 21 | "src/index.ts" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noUnusedLocals": true, 4 | "noUnusedParameters": true, 5 | "declaration": true, 6 | "moduleResolution": "node", 7 | "module": "es2015", 8 | "target": "es5", 9 | "lib": [ 10 | "es5", 11 | "es2015", 12 | "dom" 13 | ], 14 | "noImplicitAny": true, 15 | "sourceMap": true, 16 | "strictNullChecks": true, 17 | "outDir": "lib/es2015" 18 | }, 19 | "files": [ 20 | "src/index.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-config-prettier" 6 | ], 7 | "jsRules": {}, 8 | "rules": { 9 | "object-literal-sort-keys": false, 10 | "ban-types": false, 11 | "no-unused-expression": false 12 | }, 13 | "rulesDirectory": [] 14 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const UglifyJsPlugin = require("uglifyjs-webpack-plugin"); 4 | 5 | const isProduction = process.env.NODE_ENV === "production"; 6 | 7 | const plugins = isProduction 8 | ? [ 9 | new UglifyJsPlugin(), 10 | new webpack.DefinePlugin({ 11 | "process.env.NODE_ENV": JSON.stringify("production") 12 | }) 13 | ] 14 | : []; 15 | 16 | module.exports = { 17 | entry: "./lib/commonjs/index.js", 18 | output: { 19 | library: "CTFLWizard", 20 | libraryTarget: "umd", 21 | path: path.resolve(__dirname, "dist"), 22 | filename: `contentful-wizard.${isProduction ? "min." : ""}js` 23 | }, 24 | plugins 25 | }; 26 | --------------------------------------------------------------------------------