diff --git a/.eslintrc b/.eslintrc index a1eb5ce4..239991f4 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,10 +6,7 @@ "plugin:import/warnings", "plugin:flowtype/recommended" ], - "plugins": [ - "prettier", - "flowtype" - ], + "plugins": ["prettier", "flowtype"], "rules": { "eqeqeq": 2, "no-unused-vars": 2, @@ -22,7 +19,7 @@ "import/no-unresolved": [ "error", { - "ignore": [ "slate-drop-or-paste-images" ] + "ignore": ["slate-drop-or-paste-images"] } ], // Flow @@ -33,14 +30,8 @@ "annotationStyle": "line" } ], - "flowtype/space-after-type-colon": [ - 2, - "always" - ], - "flowtype/space-before-type-colon": [ - 2, - "never" - ], + "flowtype/space-after-type-colon": [2, "always"], + "flowtype/space-before-type-colon": [2, "never"], // Enforce that code is formatted with prettier. "prettier/prettier": [ "error", @@ -65,7 +56,8 @@ "SLACK_REDIRECT_URI": true, "DEPLOYMENT": true, "BASE_URL": true, + "BUGSNAG_KEY": true, "afterAll": true, "Bugsnag": true } -} \ No newline at end of file +} diff --git a/README.md b/README.md index b4b11cf2..84757702 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ 1. Install dependencies with `yarn` 1. Register a Slack app at https://api.slack.com/apps 1. Copy the file `.env.sample` to `.env` and fill out the keys - 1. Run DB migrations `yarn run sequelize -- db:migrate` + 1. Run DB migrations `yarn sequelize -- db:migrate` 1. Start the development server `yarn start` @@ -16,12 +16,12 @@ Sequelize is used to create and run migrations, for example: ``` -yarn run sequelize migration:create -yarn run sequelize db:migrate +yarn sequelize migration:create +yarn sequelize db:migrate ``` Or to run migrations on test database: ``` -yarn run sequelize db:migrate -- --env test +yarn sequelize db:migrate --env test ``` diff --git a/app.json b/app.json index 557124d4..3f446c67 100644 --- a/app.json +++ b/app.json @@ -45,6 +45,9 @@ }, "URL": { "required": true + }, + "BUGSNAG_KEY": { + "required": true } }, "formation": {}, diff --git a/flow-typed/globals.js b/flow-typed/globals.js index c0951f95..37ccbbce 100644 --- a/flow-typed/globals.js +++ b/flow-typed/globals.js @@ -3,5 +3,6 @@ declare var __DEV__: string; declare var SLACK_REDIRECT_URI: string; declare var SLACK_KEY: string; declare var BASE_URL: string; +declare var BUGSNAG_KEY: ?string; declare var DEPLOYMENT: string; declare var Bugsnag: any; diff --git a/frontend/components/DocumentPreview/DocumentPreview.js b/frontend/components/DocumentPreview/DocumentPreview.js index 14ec4e2b..d9da9bd8 100644 --- a/frontend/components/DocumentPreview/DocumentPreview.js +++ b/frontend/components/DocumentPreview/DocumentPreview.js @@ -5,7 +5,7 @@ import { Link } from 'react-router-dom'; import Document from 'models/Document'; import styled from 'styled-components'; import { color } from 'styles/constants'; -import Icon from 'components/Icon'; +import StarredIcon from 'components/Icon/StarredIcon'; import PublishingInfo from './components/PublishingInfo'; type Props = { @@ -15,18 +15,13 @@ type Props = { innerRef?: Function, }; -const StyledStar = styled(({ solid, ...props }) => ).attrs({ - type: 'Star', - color: color.text, -})` - width: 16px; - height: 16px; - top: 1px; - margin-left: 4px; +const StyledStar = styled(({ solid, ...props }) => ( + +))` + position: absolute; opacity: ${props => (props.solid ? '1 !important' : 0)}; transition: all 100ms ease-in-out; - - ${props => props.solid && 'polygon { fill: #000};'} + margin-left: 2px; &:hover { transform: scale(1.1); diff --git a/frontend/components/DropToImport/DropToImport.js b/frontend/components/DropToImport/DropToImport.js index 39924d48..8c1341f7 100644 --- a/frontend/components/DropToImport/DropToImport.js +++ b/frontend/components/DropToImport/DropToImport.js @@ -40,11 +40,7 @@ class DropToImport extends Component { text, }; - if (documentId) { - data.parentDocument = { - id: documentId, - }; - } + if (documentId) data.parentDocument = documentId; let document = new Document(data); document = await document.save(); diff --git a/frontend/components/DropdownMenu/DropdownMenu.js b/frontend/components/DropdownMenu/DropdownMenu.js index d97f2dc3..2ce37c8c 100644 --- a/frontend/components/DropdownMenu/DropdownMenu.js +++ b/frontend/components/DropdownMenu/DropdownMenu.js @@ -1,5 +1,5 @@ // @flow -import React from 'react'; +import React, { Component } from 'react'; import invariant from 'invariant'; import { observable } from 'mobx'; import { observer } from 'mobx-react'; @@ -10,14 +10,14 @@ import { color } from 'styles/constants'; import { fadeAndScaleIn } from 'styles/animations'; type Props = { - label: React.Element, - onShow?: () => void, + label: React.Element<*>, + onOpen?: () => void, onClose?: () => void, - children?: React.Element, + children?: React.Element<*>, style?: Object, }; -@observer class DropdownMenu extends React.Component { +@observer class DropdownMenu extends Component { props: Props; actionRef: Object; @observable open: boolean = false; @@ -37,7 +37,7 @@ type Props = { this.open = true; this.top = targetRect.bottom - bodyRect.top; this.right = bodyRect.width - targetRect.left - targetRect.width; - if (this.props.onShow) this.props.onShow(); + if (this.props.onOpen) this.props.onOpen(); } }; @@ -85,7 +85,7 @@ const Label = styled(Flex).attrs({ `; const Menu = styled.div` - animation: ${fadeAndScaleIn} 250ms ease; + animation: ${fadeAndScaleIn} 200ms ease; transform-origin: 75% 0; position: absolute; diff --git a/frontend/components/DropdownMenu/DropdownMenuItem.js b/frontend/components/DropdownMenu/DropdownMenuItem.js index 3ceb8e10..7e6738f4 100644 --- a/frontend/components/DropdownMenu/DropdownMenuItem.js +++ b/frontend/components/DropdownMenu/DropdownMenuItem.js @@ -1,13 +1,14 @@ // @flow import React from 'react'; import styled from 'styled-components'; +import Flex from 'components/Flex'; import { color } from 'styles/constants'; const DropdownMenuItem = ({ onClick, children, }: { - onClick?: () => void, + onClick?: SyntheticEvent => void, children?: React.Element, }) => { return ( @@ -17,18 +18,21 @@ const DropdownMenuItem = ({ ); }; -const MenuItem = styled.div` +const MenuItem = styled(Flex)` margin: 0; padding: 5px 10px; height: 32px; color: ${color.slateDark}; - display: flex; - justify-content: space-between; + justify-content: left; align-items: center; cursor: pointer; font-size: 15px; + svg { + margin-right: 8px; + } + a { text-decoration: none; width: 100%; @@ -37,6 +41,10 @@ const MenuItem = styled.div` &:hover { color: ${color.white}; background: ${color.primary}; + + svg { + fill: ${color.white}; + } } `; diff --git a/frontend/components/Editor/Editor.js b/frontend/components/Editor/Editor.js index 922dc5f0..6a99e730 100644 --- a/frontend/components/Editor/Editor.js +++ b/frontend/components/Editor/Editor.js @@ -8,6 +8,7 @@ import getDataTransferFiles from 'utils/getDataTransferFiles'; import Flex from 'components/Flex'; import ClickablePadding from './components/ClickablePadding'; import Toolbar from './components/Toolbar'; +import BlockInsert from './components/BlockInsert'; import Placeholder from './components/Placeholder'; import Markdown from './serializer'; import createSchema from './schema'; @@ -22,7 +23,7 @@ type Props = { onCancel: Function, onImageUploadStart: Function, onImageUploadStop: Function, - emoji: string, + emoji?: string, readOnly: boolean, }; @@ -173,6 +174,8 @@ type KeyData = { }; render = () => { + const { readOnly, emoji, onSave } = this.props; + return ( -
- +
+ {!readOnly && + } + {!readOnly && + } (this.editor = ref)} placeholder="Start with a titleโ€ฆ" bodyPlaceholder="โ€ฆthe rest is your canvas" schema={this.schema} plugins={this.plugins} - emoji={this.props.emoji} + emoji={emoji} state={this.state.state} onKeyDown={this.onKeyDown} onChange={this.onChange} onDocumentChange={this.onDocumentChange} - onSave={this.props.onSave} - readOnly={this.props.readOnly} + onSave={onSave} + readOnly={readOnly} /> @@ -281,6 +291,10 @@ const StyledEditor = styled(Editor)` position: relative; } + a:hover { + text-decoration: ${({ readOnly }) => (readOnly ? 'underline' : 'none')}; + } + li p { display: inline; margin: 0; @@ -322,6 +336,10 @@ const StyledEditor = styled(Editor)` td { padding: 5px 20px 5px 0; } + + b, strong { + font-weight: 600; + } `; export default MarkdownEditor; diff --git a/frontend/components/Editor/components/BlockInsert.js b/frontend/components/Editor/components/BlockInsert.js new file mode 100644 index 00000000..441d8880 --- /dev/null +++ b/frontend/components/Editor/components/BlockInsert.js @@ -0,0 +1,196 @@ +// @flow +import React, { Component } from 'react'; +import EditList from '../plugins/EditList'; +import getDataTransferFiles from 'utils/getDataTransferFiles'; +import Portal from 'react-portal'; +import { observable } from 'mobx'; +import { observer } from 'mobx-react'; +import styled from 'styled-components'; +import { color } from 'styles/constants'; +import PlusIcon from 'components/Icon/PlusIcon'; +import BlockMenu from 'menus/BlockMenu'; +import type { State } from '../types'; + +const { transforms } = EditList; + +type Props = { + state: State, + onChange: Function, + onInsertImage: File => Promise<*>, +}; + +@observer +export default class BlockInsert extends Component { + props: Props; + mouseMoveTimeout: number; + file: HTMLInputElement; + + @observable active: boolean = false; + @observable menuOpen: boolean = false; + @observable top: number; + @observable left: number; + @observable mouseX: number; + + componentDidMount = () => { + this.update(); + window.addEventListener('mousemove', this.handleMouseMove); + }; + + componentWillUpdate = (nextProps: Props) => { + this.update(nextProps); + }; + + componentWillUnmount = () => { + window.removeEventListener('mousemove', this.handleMouseMove); + }; + + setInactive = () => { + if (this.menuOpen) return; + this.active = false; + }; + + handleMouseMove = (ev: SyntheticMouseEvent) => { + const windowWidth = window.innerWidth / 3; + let active = ev.clientX < windowWidth; + + if (active !== this.active) { + this.active = active || this.menuOpen; + } + if (active) { + clearTimeout(this.mouseMoveTimeout); + this.mouseMoveTimeout = setTimeout(this.setInactive, 2000); + } + }; + + handleMenuOpen = () => { + this.menuOpen = true; + }; + + handleMenuClose = () => { + this.menuOpen = false; + }; + + update = (props?: Props) => { + if (!document.activeElement) return; + const { state } = props || this.props; + const boxRect = document.activeElement.getBoundingClientRect(); + const selection = window.getSelection(); + if (!selection.focusNode) return; + + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + if (rect.top <= 0 || boxRect.left <= 0) return; + + if (state.startBlock.type === 'heading1') { + this.active = false; + } + + this.top = Math.round(rect.top + window.scrollY); + this.left = Math.round(boxRect.left + window.scrollX - 20); + }; + + insertBlock = ( + ev: SyntheticEvent, + options: { + type: string | Object, + wrapper?: string | Object, + append?: string | Object, + } + ) => { + ev.preventDefault(); + const { type, wrapper, append } = options; + let { state } = this.props; + let transform = state.transform(); + const { document } = state; + const parent = document.getParent(state.startBlock.key); + + // lists get some special treatment + if (parent && parent.type === 'list-item') { + transform = transforms.unwrapList( + transforms + .splitListItem(transform.collapseToStart()) + .collapseToEndOfPreviousBlock() + ); + } + + transform = transform.insertBlock(type); + + if (wrapper) transform = transform.wrapBlock(wrapper); + if (append) transform = transform.insertBlock(append); + + state = transform.focus().apply(); + this.props.onChange(state); + this.active = false; + }; + + onPickImage = (ev: SyntheticEvent) => { + // simulate a click on the file upload input element + this.file.click(); + }; + + onChooseImage = async (ev: SyntheticEvent) => { + const files = getDataTransferFiles(ev); + for (const file of files) { + await this.props.onInsertImage(file); + } + }; + + render() { + const style = { top: `${this.top}px`, left: `${this.left}px` }; + const todo = { type: 'list-item', data: { checked: false } }; + const rule = { type: 'horizontal-rule', isVoid: true }; + + return ( + + + (this.file = ref)} + onChange={this.onChooseImage} + accept="image/*" + /> + } + onPickImage={this.onPickImage} + onInsertList={ev => + this.insertBlock(ev, { + type: 'list-item', + wrapper: 'bulleted-list', + })} + onInsertTodoList={ev => + this.insertBlock(ev, { type: todo, wrapper: 'todo-list' })} + onInsertBreak={ev => + this.insertBlock(ev, { type: rule, append: 'paragraph' })} + onOpen={this.handleMenuOpen} + onClose={this.handleMenuClose} + /> + + + ); + } +} + +const HiddenInput = styled.input` + position: absolute; + top: -100px; + left: -100px; + visibility: hidden; +`; + +const Trigger = styled.div` + position: absolute; + z-index: 1; + opacity: 0; + background-color: ${color.white}; + transition: opacity 250ms ease-in-out, transform 250ms ease-in-out; + line-height: 0; + margin-top: -2px; + margin-left: -4px; + transform: scale(.9); + + ${({ active }) => active && ` + transform: scale(1); + opacity: .9; + `} +`; diff --git a/frontend/components/Editor/components/HorizontalRule.js b/frontend/components/Editor/components/HorizontalRule.js new file mode 100644 index 00000000..286a5acb --- /dev/null +++ b/frontend/components/Editor/components/HorizontalRule.js @@ -0,0 +1,17 @@ +// @flow +import React from 'react'; +import styled from 'styled-components'; +import type { Props } from '../types'; +import { color } from 'styles/constants'; + +function HorizontalRule(props: Props) { + const { state, node } = props; + const active = state.isFocused && state.selection.hasEdgeIn(node); + return ; +} + +const StyledHr = styled.hr` + border-bottom: 1px solid ${props => (props.active ? color.slate : color.slateLight)}; +`; + +export default HorizontalRule; diff --git a/frontend/components/Editor/components/Toolbar/Toolbar.js b/frontend/components/Editor/components/Toolbar/Toolbar.js index 106c347f..281d40cb 100644 --- a/frontend/components/Editor/components/Toolbar/Toolbar.js +++ b/frontend/components/Editor/components/Toolbar/Toolbar.js @@ -144,15 +144,16 @@ const Menu = styled.div` top: -10000px; left: -10000px; opacity: 0; - background-color: #222; + background-color: #2F3336; border-radius: 4px; - transition: opacity 250ms ease-in-out, transform 250ms ease-in-out; + transform: scale(.95); + transition: opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275), transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275); line-height: 0; height: 40px; min-width: 260px; ${({ active }) => active && ` - transform: translateY(-6px); + transform: translateY(-6px) scale(1); opacity: 1; `} `; diff --git a/frontend/components/Editor/components/Toolbar/components/DocumentResult.js b/frontend/components/Editor/components/Toolbar/components/DocumentResult.js index e7ac8c2e..8351681d 100644 --- a/frontend/components/Editor/components/Toolbar/components/DocumentResult.js +++ b/frontend/components/Editor/components/Toolbar/components/DocumentResult.js @@ -3,7 +3,7 @@ import React from 'react'; import styled from 'styled-components'; import { fontWeight, color } from 'styles/constants'; import Document from 'models/Document'; -import Icon from 'components/Icon'; +import GoToIcon from 'components/Icon/GoToIcon'; type Props = { innerRef?: Function, @@ -14,7 +14,7 @@ type Props = { function DocumentResult({ document, ...rest }: Props) { return ( - + {document.title} ); diff --git a/frontend/components/Editor/components/Toolbar/components/FormattingToolbar.js b/frontend/components/Editor/components/Toolbar/components/FormattingToolbar.js index 2b1938bf..effd427f 100644 --- a/frontend/components/Editor/components/Toolbar/components/FormattingToolbar.js +++ b/frontend/components/Editor/components/Toolbar/components/FormattingToolbar.js @@ -1,5 +1,6 @@ // @flow import React, { Component } from 'react'; +import styled from 'styled-components'; import type { State } from '../../../types'; import ToolbarButton from './ToolbarButton'; import BoldIcon from 'components/Icon/BoldIcon'; @@ -9,9 +10,8 @@ import Heading2Icon from 'components/Icon/Heading2Icon'; import ItalicIcon from 'components/Icon/ItalicIcon'; import LinkIcon from 'components/Icon/LinkIcon'; import StrikethroughIcon from 'components/Icon/StrikethroughIcon'; -import BulletedListIcon from 'components/Icon/BulletedListIcon'; -export default class FormattingToolbar extends Component { +class FormattingToolbar extends Component { props: { state: State, onChange: Function, @@ -93,10 +93,11 @@ export default class FormattingToolbar extends Component { {this.renderMarkButton('bold', BoldIcon)} {this.renderMarkButton('italic', ItalicIcon)} {this.renderMarkButton('deleted', StrikethroughIcon)} + {this.renderMarkButton('code', CodeIcon)} + {this.renderBlockButton('heading1', Heading1Icon)} {this.renderBlockButton('heading2', Heading2Icon)} - {this.renderBlockButton('bulleted-list', BulletedListIcon)} - {this.renderMarkButton('code', CodeIcon)} + @@ -104,3 +105,14 @@ export default class FormattingToolbar extends Component { ); } } + +const Separator = styled.div` + height: 100%; + width: 1px; + background: #FFF; + opacity: .2; + display: inline-block; + margin-left: 10px; +`; + +export default FormattingToolbar; diff --git a/frontend/components/Editor/components/Toolbar/components/LinkToolbar.js b/frontend/components/Editor/components/Toolbar/components/LinkToolbar.js index e34d04b4..01004834 100644 --- a/frontend/components/Editor/components/Toolbar/components/LinkToolbar.js +++ b/frontend/components/Editor/components/Toolbar/components/LinkToolbar.js @@ -11,7 +11,9 @@ import DocumentResult from './DocumentResult'; import type { State } from '../../../types'; import DocumentsStore from 'stores/DocumentsStore'; import keydown from 'react-keydown'; -import Icon from 'components/Icon'; +import CloseIcon from 'components/Icon/CloseIcon'; +import OpenIcon from 'components/Icon/OpenIcon'; +import TrashIcon from 'components/Icon/TrashIcon'; import Flex from 'components/Flex'; @keydown @@ -109,16 +111,15 @@ class LinkToolbar extends Component { save = (href: string) => { href = href.trim(); - const transform = this.props.state.transform(); - transform.unwrapInline('link'); + const { state } = this.props; + const transform = state.transform(); - if (href) { - const data = { href }; - transform.wrapInline({ type: 'link', data }); + if (state.selection.isExpanded) { + transform.unwrapInline('link'); + if (href) transform.wrapInline({ type: 'link', data: { href } }); } - const state = transform.apply(); - this.props.onChange(state); + this.props.onChange(transform.apply()); this.props.onBlur(); }; @@ -144,12 +145,10 @@ class LinkToolbar extends Component { /> {this.isEditing && - + } - {this.isEditing - ? - : } + {this.isEditing ? : } {hasResults && diff --git a/frontend/components/Editor/insertImage.js b/frontend/components/Editor/insertImage.js index 43e56069..7f59ab5c 100644 --- a/frontend/components/Editor/insertImage.js +++ b/frontend/components/Editor/insertImage.js @@ -15,7 +15,6 @@ export default async function insertImageFile( try { // load the file as a data URL const id = uuid.v4(); - const alt = file.name; const reader = new FileReader(); reader.addEventListener('load', () => { const src = reader.result; @@ -25,7 +24,7 @@ export default async function insertImageFile( .insertBlock({ type: 'image', isVoid: true, - data: { src, alt, id, loading: true }, + data: { src, id, loading: true }, }) .apply(); editor.onChange(state); @@ -46,7 +45,7 @@ export default async function insertImageFile( ); return finalTransform.setNodeByKey(placeholder.key, { - data: { src, alt, loading: false }, + data: { src, loading: false }, }); } catch (err) { throw err; diff --git a/frontend/components/Editor/plugins.js b/frontend/components/Editor/plugins.js index c487df2a..30688ea1 100644 --- a/frontend/components/Editor/plugins.js +++ b/frontend/components/Editor/plugins.js @@ -1,11 +1,11 @@ // @flow import DropOrPasteImages from '@tommoor/slate-drop-or-paste-images'; import PasteLinkify from 'slate-paste-linkify'; -import EditList from 'slate-edit-list'; import CollapseOnEscape from 'slate-collapse-on-escape'; import TrailingBlock from 'slate-trailing-block'; import EditCode from 'slate-edit-code'; import Prism from 'slate-prism'; +import EditList from './plugins/EditList'; import KeyboardShortcuts from './plugins/KeyboardShortcuts'; import MarkdownShortcuts from './plugins/MarkdownShortcuts'; import insertImage from './insertImage'; @@ -35,10 +35,7 @@ const createPlugins = ({ onImageUploadStart, onImageUploadStop }: Options) => { ); }, }), - EditList({ - types: ['ordered-list', 'bulleted-list', 'todo-list'], - typeItem: 'list-item', - }), + EditList, EditCode({ onlyIn: onlyInCode, containerType: 'code', diff --git a/frontend/components/Editor/plugins/EditList.js b/frontend/components/Editor/plugins/EditList.js new file mode 100644 index 00000000..7bef46f4 --- /dev/null +++ b/frontend/components/Editor/plugins/EditList.js @@ -0,0 +1,7 @@ +// @flow +import EditList from 'slate-edit-list'; + +export default EditList({ + types: ['ordered-list', 'bulleted-list', 'todo-list'], + typeItem: 'list-item', +}); diff --git a/frontend/components/Editor/plugins/MarkdownShortcuts.js b/frontend/components/Editor/plugins/MarkdownShortcuts.js index 0dd92415..de530b00 100644 --- a/frontend/components/Editor/plugins/MarkdownShortcuts.js +++ b/frontend/components/Editor/plugins/MarkdownShortcuts.js @@ -112,19 +112,17 @@ export default function MarkdownShortcuts() { if (chars === '--') { ev.preventDefault(); - const transform = state + return state .transform() .extendToStartOf(startBlock) .delete() .setBlock({ type: 'horizontal-rule', isVoid: true, - }); - state = transform + }) .collapseToStartOfNextBlock() .insertBlock('paragraph') .apply(); - return state; } }, diff --git a/frontend/components/Editor/schema.js b/frontend/components/Editor/schema.js index e915b636..2010e4c2 100644 --- a/frontend/components/Editor/schema.js +++ b/frontend/components/Editor/schema.js @@ -1,6 +1,7 @@ // @flow import React from 'react'; import Code from './components/Code'; +import HorizontalRule from './components/HorizontalRule'; import InlineCode from './components/InlineCode'; import Image from './components/Image'; import Link from './components/Link'; @@ -33,7 +34,7 @@ const createSchema = () => { 'block-quote': (props: Props) => (
{props.children}
), - 'horizontal-rule': (props: Props) =>
, + 'horizontal-rule': HorizontalRule, 'bulleted-list': (props: Props) =>
    {props.children}
, 'ordered-list': (props: Props) =>
    {props.children}
, 'todo-list': (props: Props) => {props.children}, diff --git a/frontend/components/Empty/Empty.js b/frontend/components/Empty/Empty.js new file mode 100644 index 00000000..822d990a --- /dev/null +++ b/frontend/components/Empty/Empty.js @@ -0,0 +1,21 @@ +// @flow +import React from 'react'; +import styled from 'styled-components'; +import { color } from 'styles/constants'; + +type Props = { + children: string, +}; + +const Empty = (props: Props) => { + const { children, ...rest } = props; + return {children}; +}; + +const Container = styled.div` + display: flex; + color: ${color.slate}; + text-align: center; +`; + +export default Empty; diff --git a/frontend/components/Empty/index.js b/frontend/components/Empty/index.js new file mode 100644 index 00000000..5c3c3a07 --- /dev/null +++ b/frontend/components/Empty/index.js @@ -0,0 +1,3 @@ +// @flow +import Empty from './Empty'; +export default Empty; diff --git a/frontend/components/HtmlContent/HtmlContent.js b/frontend/components/HtmlContent/HtmlContent.js deleted file mode 100644 index 8f670b9a..00000000 --- a/frontend/components/HtmlContent/HtmlContent.js +++ /dev/null @@ -1,84 +0,0 @@ -// @flow -import styled from 'styled-components'; - -const HtmlContent = styled.div` - h1, h2, h3, h4, h5, h6 { - :global { - .anchor { - visibility: hidden; - color: ; - } - } - - &:hover { - :global { - .anchor { - visibility: visible; - } - } - } - } - - ul { - padding-left: 1.5em; - - ul { - margin: 0; - } - } - - blockquote { - font-style: italic; - border-left: 2px solid $lightGray; - padding-left: 0.8em; - } - - table { - width: 100%; - overflow: auto; - display: block; - border-spacing: 0; - border-collapse: collapse; - - thead, tbody { - width: 100%; - } - - thead { - tr { - border-bottom: 2px solid $lightGray; - } - } - - tbody { - tr { - border-bottom: 1px solid $lightGray; - } - } - - tr { - background-color: #fff; - - // &:nth-child(2n) { - // background-color: #f8f8f8; - // } - } - - th, td { - text-align: left; - border: 1px 0 solid $lightGray; - padding: 5px 20px 5px 0; - - &:last-child { - padding-right: 0; - width: 100%; - } - } - - th { - font-weight: bold; - } - } -`; - -export default HtmlContent; diff --git a/frontend/components/HtmlContent/index.js b/frontend/components/HtmlContent/index.js deleted file mode 100644 index 2ecb47b9..00000000 --- a/frontend/components/HtmlContent/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import HtmlContent from './HtmlContent'; -export default HtmlContent; diff --git a/frontend/components/Icon/BackIcon.js b/frontend/components/Icon/BackIcon.js new file mode 100644 index 00000000..4586fd1a --- /dev/null +++ b/frontend/components/Icon/BackIcon.js @@ -0,0 +1,15 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function BackIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/BoldIcon.js b/frontend/components/Icon/BoldIcon.js index 011686bd..fa5dd4e6 100644 --- a/frontend/components/Icon/BoldIcon.js +++ b/frontend/components/Icon/BoldIcon.js @@ -6,16 +6,7 @@ import type { Props } from './Icon'; export default function BoldIcon(props: Props) { return ( - - - - + ); } diff --git a/frontend/components/Icon/BulletedListIcon.js b/frontend/components/Icon/BulletedListIcon.js index b87afa19..89fa741b 100644 --- a/frontend/components/Icon/BulletedListIcon.js +++ b/frontend/components/Icon/BulletedListIcon.js @@ -6,16 +6,7 @@ import type { Props } from './Icon'; export default function BulletedListIcon(props: Props) { return ( - - - - + ); } diff --git a/frontend/components/Icon/CheckboxIcon.js b/frontend/components/Icon/CheckboxIcon.js new file mode 100644 index 00000000..5540808c --- /dev/null +++ b/frontend/components/Icon/CheckboxIcon.js @@ -0,0 +1,20 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function CheckboxIcon({ + checked, + ...rest +}: Props & { checked: boolean }) { + return ( + + {checked + ? + : } + + ); +} diff --git a/frontend/components/Icon/ChevronIcon.js b/frontend/components/Icon/ChevronIcon.js deleted file mode 100644 index 88453bbc..00000000 --- a/frontend/components/Icon/ChevronIcon.js +++ /dev/null @@ -1,21 +0,0 @@ -// @flow -import React from 'react'; -import Icon from './Icon'; -import type { Props } from './Icon'; - -export default function NextIcon(props: Props) { - return ( - - - - - - - ); -} diff --git a/frontend/components/Icon/CloseIcon.js b/frontend/components/Icon/CloseIcon.js new file mode 100644 index 00000000..d21adb3d --- /dev/null +++ b/frontend/components/Icon/CloseIcon.js @@ -0,0 +1,15 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function CloseIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/CodeIcon.js b/frontend/components/Icon/CodeIcon.js index 1116cb68..ea08486b 100644 --- a/frontend/components/Icon/CodeIcon.js +++ b/frontend/components/Icon/CodeIcon.js @@ -6,16 +6,7 @@ import type { Props } from './Icon'; export default function CodeIcon(props: Props) { return ( - - - - + ); } diff --git a/frontend/components/Icon/CollapsedIcon.js b/frontend/components/Icon/CollapsedIcon.js new file mode 100644 index 00000000..8e2a2455 --- /dev/null +++ b/frontend/components/Icon/CollapsedIcon.js @@ -0,0 +1,12 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function CollapsedIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/CollectionIcon.js b/frontend/components/Icon/CollectionIcon.js new file mode 100644 index 00000000..411d0920 --- /dev/null +++ b/frontend/components/Icon/CollectionIcon.js @@ -0,0 +1,17 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function CollectionIcon({ + expanded, + ...rest +}: Props & { expanded: boolean }) { + return ( + + {expanded + ? + : } + + ); +} diff --git a/frontend/components/Icon/DocumentIcon.js b/frontend/components/Icon/DocumentIcon.js new file mode 100644 index 00000000..95df6e52 --- /dev/null +++ b/frontend/components/Icon/DocumentIcon.js @@ -0,0 +1,15 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function DocumentIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/EditIcon.js b/frontend/components/Icon/EditIcon.js new file mode 100644 index 00000000..cfe26540 --- /dev/null +++ b/frontend/components/Icon/EditIcon.js @@ -0,0 +1,12 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function EditIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/GoToIcon.js b/frontend/components/Icon/GoToIcon.js new file mode 100644 index 00000000..04dbb626 --- /dev/null +++ b/frontend/components/Icon/GoToIcon.js @@ -0,0 +1,15 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function GoToIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/Heading1Icon.js b/frontend/components/Icon/Heading1Icon.js index 1dc44054..41fd8552 100644 --- a/frontend/components/Icon/Heading1Icon.js +++ b/frontend/components/Icon/Heading1Icon.js @@ -6,16 +6,7 @@ import type { Props } from './Icon'; export default function Heading1Icon(props: Props) { return ( - - - - + ); } diff --git a/frontend/components/Icon/Heading2Icon.js b/frontend/components/Icon/Heading2Icon.js index 46b11590..567fa418 100644 --- a/frontend/components/Icon/Heading2Icon.js +++ b/frontend/components/Icon/Heading2Icon.js @@ -6,16 +6,7 @@ import type { Props } from './Icon'; export default function Heading2Icon(props: Props) { return ( - - - - + ); } diff --git a/frontend/components/Icon/HomeIcon.js b/frontend/components/Icon/HomeIcon.js new file mode 100644 index 00000000..47d51f01 --- /dev/null +++ b/frontend/components/Icon/HomeIcon.js @@ -0,0 +1,12 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function HomeIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/HorizontalRuleIcon.js b/frontend/components/Icon/HorizontalRuleIcon.js new file mode 100644 index 00000000..a0a663c2 --- /dev/null +++ b/frontend/components/Icon/HorizontalRuleIcon.js @@ -0,0 +1,15 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function HorizontalRuleIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/Icon.js b/frontend/components/Icon/Icon.js index 5921db7e..23619a08 100644 --- a/frontend/components/Icon/Icon.js +++ b/frontend/components/Icon/Icon.js @@ -1,53 +1,43 @@ // @flow import React from 'react'; -import styled from 'styled-components'; import { color } from 'styles/constants'; -import * as Icons from 'react-feather'; export type Props = { className?: string, - type?: string, light?: boolean, + black?: boolean, + primary?: boolean, + color?: string, + size?: number, }; type BaseProps = { - children?: React$Element, + children?: React$Element<*>, }; export default function Icon({ children, - light, - type, + className, ...rest }: Props & BaseProps) { - if (type) { - children = React.createElement(Icons[type], { - size: '1em', - color: light ? color.white : undefined, - ...rest, - }); + const size = rest.size ? rest.size + 'px' : '24px'; - return ( - - {children} - - ); - } + let fill = color.slateDark; + if (rest.color) fill = rest.color; + if (rest.light) fill = color.white; + if (rest.black) fill = color.black; + if (rest.primary) fill = color.primary; return ( - + {children} - + ); } - -const FeatherWrapper = styled.span` - position: relative; - top: .1em; -`; - -const Wrapper = styled.span` - svg { - fill: ${props => (props.light ? color.white : color.black)} - } -`; diff --git a/frontend/components/Icon/ImageIcon.js b/frontend/components/Icon/ImageIcon.js new file mode 100644 index 00000000..3901f9e0 --- /dev/null +++ b/frontend/components/Icon/ImageIcon.js @@ -0,0 +1,12 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function ImageIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/ItalicIcon.js b/frontend/components/Icon/ItalicIcon.js index 9b194b6b..0987bdda 100644 --- a/frontend/components/Icon/ItalicIcon.js +++ b/frontend/components/Icon/ItalicIcon.js @@ -6,16 +6,7 @@ import type { Props } from './Icon'; export default function ItalicIcon(props: Props) { return ( - - - - + ); } diff --git a/frontend/components/Icon/LinkIcon.js b/frontend/components/Icon/LinkIcon.js index 5f4c80e2..73d9257d 100644 --- a/frontend/components/Icon/LinkIcon.js +++ b/frontend/components/Icon/LinkIcon.js @@ -6,16 +6,7 @@ import type { Props } from './Icon'; export default function LinkIcon(props: Props) { return ( - - - - + ); } diff --git a/frontend/components/Icon/MoreIcon.js b/frontend/components/Icon/MoreIcon.js new file mode 100644 index 00000000..cffcbe0f --- /dev/null +++ b/frontend/components/Icon/MoreIcon.js @@ -0,0 +1,15 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function MoreIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/NewDocumentIcon.js b/frontend/components/Icon/NewDocumentIcon.js new file mode 100644 index 00000000..65140ce8 --- /dev/null +++ b/frontend/components/Icon/NewDocumentIcon.js @@ -0,0 +1,15 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function NewDocumentIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/OpenIcon.js b/frontend/components/Icon/OpenIcon.js new file mode 100644 index 00000000..4324f761 --- /dev/null +++ b/frontend/components/Icon/OpenIcon.js @@ -0,0 +1,12 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function OpenIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/OrderedListIcon.js b/frontend/components/Icon/OrderedListIcon.js index 89e51f22..855794be 100644 --- a/frontend/components/Icon/OrderedListIcon.js +++ b/frontend/components/Icon/OrderedListIcon.js @@ -6,16 +6,7 @@ import type { Props } from './Icon'; export default function OrderedListIcon(props: Props) { return ( - - - - + ); } diff --git a/frontend/components/Icon/PlusIcon.js b/frontend/components/Icon/PlusIcon.js new file mode 100644 index 00000000..9301d2a8 --- /dev/null +++ b/frontend/components/Icon/PlusIcon.js @@ -0,0 +1,12 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function PlusIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/SearchIcon.js b/frontend/components/Icon/SearchIcon.js new file mode 100644 index 00000000..d111fef9 --- /dev/null +++ b/frontend/components/Icon/SearchIcon.js @@ -0,0 +1,12 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function SearchIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/StarredIcon.js b/frontend/components/Icon/StarredIcon.js new file mode 100644 index 00000000..8c6da200 --- /dev/null +++ b/frontend/components/Icon/StarredIcon.js @@ -0,0 +1,12 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function StarredIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/StrikethroughIcon.js b/frontend/components/Icon/StrikethroughIcon.js index 27c8d1b2..f00c91ec 100644 --- a/frontend/components/Icon/StrikethroughIcon.js +++ b/frontend/components/Icon/StrikethroughIcon.js @@ -6,16 +6,7 @@ import type { Props } from './Icon'; export default function StrikethroughIcon(props: Props) { return ( - - - - + ); } diff --git a/frontend/components/Icon/TableIcon.js b/frontend/components/Icon/TableIcon.js new file mode 100644 index 00000000..fb5e527f --- /dev/null +++ b/frontend/components/Icon/TableIcon.js @@ -0,0 +1,15 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function TableIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/TodoListIcon.js b/frontend/components/Icon/TodoListIcon.js new file mode 100644 index 00000000..7cd7c198 --- /dev/null +++ b/frontend/components/Icon/TodoListIcon.js @@ -0,0 +1,12 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function TodoListIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/TrashIcon.js b/frontend/components/Icon/TrashIcon.js new file mode 100644 index 00000000..d76379fe --- /dev/null +++ b/frontend/components/Icon/TrashIcon.js @@ -0,0 +1,12 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function TrashIcon(props: Props) { + return ( + + + + ); +} diff --git a/frontend/components/Icon/UnderlinedIcon.js b/frontend/components/Icon/UnderlinedIcon.js deleted file mode 100644 index 14379e98..00000000 --- a/frontend/components/Icon/UnderlinedIcon.js +++ /dev/null @@ -1,21 +0,0 @@ -// @flow -import React from 'react'; -import Icon from './Icon'; -import type { Props } from './Icon'; - -export default function UnderlinedIcon(props: Props) { - return ( - - - - - - - ); -} diff --git a/frontend/components/Layout/Layout.js b/frontend/components/Layout/Layout.js index 79f85039..22dc66b9 100644 --- a/frontend/components/Layout/Layout.js +++ b/frontend/components/Layout/Layout.js @@ -12,7 +12,9 @@ import { documentEditUrl, homeUrl, searchUrl } from 'utils/routeHelpers'; import Avatar from 'components/Avatar'; import { LoadingIndicatorBar } from 'components/LoadingIndicator'; import Scrollable from 'components/Scrollable'; -import Icon from 'components/Icon'; +import HomeIcon from 'components/Icon/HomeIcon'; +import SearchIcon from 'components/Icon/SearchIcon'; +import StarredIcon from 'components/Icon/StarredIcon'; import Toasts from 'components/Toasts'; import AccountMenu from 'menus/AccountMenu'; @@ -35,16 +37,12 @@ type Props = { title?: ?React.Element, auth: AuthStore, ui: UiStore, - search: ?boolean, notifications?: React.Element, }; @observer class Layout extends React.Component { props: Props; - - static defaultProps = { - search: true, - }; + scrollable: ?HTMLDivElement; @keydown(['/', 't']) goToSearch(ev) { @@ -81,6 +79,20 @@ type Props = { this.props.ui.setActiveModal('collection-edit'); }; + setScrollableRef = ref => { + this.scrollable = ref; + }; + + scrollToActiveDocument = ref => { + const scrollable = this.scrollable; + if (!ref || !scrollable) return; + + const container = scrollable.getBoundingClientRect(); + const bounds = ref.getBoundingClientRect(); + const scrollTop = bounds.top + container.top; + scrollable.scrollTop = scrollTop; + }; + render() { const { auth, documents, ui } = this.props; const { user, team } = auth; @@ -115,16 +127,16 @@ type Props = { /> - + - - Home + }> + Home - - Search + }> + Search - - Starred + }> + Starred @@ -132,6 +144,7 @@ type Props = { history={this.props.history} activeDocument={documents.active} onCreateCollection={this.handleCreateCollection} + activeDocumentRef={this.scrollToActiveDocument} /> diff --git a/frontend/components/Layout/components/SidebarCollections.js b/frontend/components/Layout/components/SidebarCollections.js index 3dbec12d..0f224250 100644 --- a/frontend/components/Layout/components/SidebarCollections.js +++ b/frontend/components/Layout/components/SidebarCollections.js @@ -8,19 +8,23 @@ import { color, fontWeight } from 'styles/constants'; import SidebarLink from './SidebarLink'; import DropToImport from 'components/DropToImport'; -import Icon from 'components/Icon'; +import PlusIcon from 'components/Icon/PlusIcon'; +import CollectionIcon from 'components/Icon/CollectionIcon'; import CollectionMenu from 'menus/CollectionMenu'; import CollectionsStore from 'stores/CollectionsStore'; import UiStore from 'stores/UiStore'; import Document from 'models/Document'; +import DocumentsStore from 'stores/DocumentsStore'; import { type NavigationNode } from 'types'; type Props = { history: Object, collections: CollectionsStore, + documents: DocumentsStore, activeDocument: ?Document, - onCreateCollection: Function, + onCreateCollection: () => void, + activeDocumentRef: HTMLElement => void, ui: UiStore, }; @@ -28,7 +32,14 @@ type Props = { props: Props; render() { - const { history, collections, activeDocument, ui } = this.props; + const { + history, + collections, + activeDocument, + ui, + activeDocumentRef, + documents, + } = this.props; return ( @@ -39,13 +50,18 @@ type Props = { history={history} collection={collection} activeDocument={activeDocument} + activeDocumentRef={activeDocumentRef} + prefetchDocument={documents.prefetchDocument} ui={ui} /> ))} {collections.isLoaded && - - Add new collection + } + > + New collectionโ€ฆ } ); @@ -62,7 +78,15 @@ type Props = { }; render() { - const { history, collection, activeDocument, ui } = this.props; + const { + history, + collection, + activeDocument, + ui, + activeDocumentRef, + prefetchDocument, + } = this.props; + const expanded = collection.id === ui.activeCollectionId; return ( (this.dropzoneRef = ref)} > - + } + > {collection.name} @@ -81,7 +109,7 @@ type Props = { (this.menuOpen = true)} + onOpen={() => (this.menuOpen = true)} onClose={() => (this.menuOpen = false)} onImport={this.handleImport} open={this.menuOpen} @@ -89,14 +117,16 @@ type Props = { - {collection.id === ui.activeCollectionId && + {expanded && {collection.documents.map(document => ( ))} @@ -111,53 +141,76 @@ type DocumentLinkProps = { document: NavigationNode, history: Object, activeDocument: ?Document, + activeDocumentRef: HTMLElement => void, + prefetchDocument: (documentId: string) => void, depth: number, }; -const DocumentLink = observer((props: DocumentLinkProps) => { - const { document, activeDocument, depth } = props; +const DocumentLink = observer( + ({ + document, + activeDocument, + activeDocumentRef, + prefetchDocument, + depth, + }: DocumentLinkProps) => { + const isActiveDocument = + activeDocument && activeDocument.id === document.id; + const showChildren = !!(activeDocument && + (activeDocument.pathToDocument + .map(entry => entry.id) + .includes(document.id) || + isActiveDocument)); - const showChildren = - activeDocument && - (activeDocument.pathToDocument - .map(entry => entry.id) - .includes(document.id) || - activeDocument.id === document.id); + const handleMouseEnter = (event: SyntheticEvent) => { + event.stopPropagation(); + event.preventDefault(); + prefetchDocument(document.id); + }; - return ( - - - 0} - expanded={showChildren} + - {document.title} - - + 0} + expanded={showChildren} + > + {document.title} + + - {showChildren && - - {document.children && - document.children.map(childDocument => ( - - ))} - } - - ); -}); + {showChildren && + + {document.children && + document.children.map(childDocument => ( + + ))} + } + + ); + } +); const CollectionAction = styled.a` + position: absolute; + right: 0; color: ${color.slate}; svg { opacity: .75; } @@ -179,15 +232,16 @@ const StyledDropToImport = styled(DropToImport)` `; const Header = styled(Flex)` - font-size: 11px; + font-size: 12px; font-weight: ${fontWeight.semiBold}; text-transform: uppercase; color: ${color.slate}; letter-spacing: 0.04em; + margin-bottom: 4px; `; const Children = styled(Flex)` margin-left: 20px; `; -export default inject('collections', 'ui')(SidebarCollections); +export default inject('collections', 'ui', 'documents')(SidebarCollections); diff --git a/frontend/components/Layout/components/SidebarLink.js b/frontend/components/Layout/components/SidebarLink.js index 95d7fe57..b3c5456a 100644 --- a/frontend/components/Layout/components/SidebarLink.js +++ b/frontend/components/Layout/components/SidebarLink.js @@ -3,9 +3,8 @@ import React from 'react'; import { NavLink } from 'react-router-dom'; import { color, fontWeight } from 'styles/constants'; import styled from 'styled-components'; - import Flex from 'components/Flex'; -import ChevronIcon from 'components/Icon/ChevronIcon'; +import CollapsedIcon from 'components/Icon/CollapsedIcon'; const activeStyle = { color: color.black, @@ -15,12 +14,25 @@ const activeStyle = { // This is a hack for `styleComponent()` as NavLink fails to render without `to` prop const StyleableDiv = props =>
; +const StyledGoTo = styled(CollapsedIcon)` + margin-bottom: -4px; + margin-right: 0; + ${({ expanded }) => !expanded && 'transform: rotate(-90deg);'} +`; + +const IconWrapper = styled.span` + margin-left: -4px; + margin-right: 4px; + height: 24px; +`; + const styleComponent = component => styled(component)` - display: block; + display: flex; width: 100%; + position: relative; overflow: hidden; text-overflow: ellipsis; - margin: 5px 0; + padding: 4px 0; margin-left: ${({ hasChildren }) => (hasChildren ? '-20px;' : '0')}; color: ${color.slateDark}; font-size: 15px; @@ -30,36 +42,38 @@ const styleComponent = component => styled(component)` color: ${color.text}; } - &.active ${StyledChevron} svg { - fill: ${activeStyle.color}; + &.active { + svg { + fill: ${activeStyle.color} + } } `; -function SidebarLink(props: Object) { - const Component = styleComponent(props.to ? NavLink : StyleableDiv); +type Props = { + to?: string, + onClick?: SyntheticEvent => *, + children?: React$Element<*>, + icon?: React$Element<*>, + hasChildren?: boolean, + expanded?: boolean, +}; + +function SidebarLink({ icon, children, expanded, ...rest }: Props) { + const Component = styleComponent(rest.to ? NavLink : StyleableDiv); return ( - - {props.hasChildren && } - {props.children} + + {icon && {icon}} + {rest.hasChildren && } + {children} ); } -const StyledChevron = styled(ChevronIcon)` - margin-right: -10px; - - svg { - height: 18px; - margin-bottom: -4px; - margin-right: 6px; - - fill: ${color.slateDark}; - - ${({ expanded }) => expanded && 'transform: rotate(90deg);'} - } +const Content = styled.div` + width: 100%; `; export default SidebarLink; diff --git a/frontend/components/Modal/Modal.js b/frontend/components/Modal/Modal.js index 92dc4db6..de4f8841 100644 --- a/frontend/components/Modal/Modal.js +++ b/frontend/components/Modal/Modal.js @@ -5,7 +5,7 @@ import styled from 'styled-components'; import ReactModal from 'react-modal'; import { color } from 'styles/constants'; import { fadeAndScaleIn } from 'styles/animations'; -import Icon from 'components/Icon'; +import CloseIcon from 'components/Icon/CloseIcon'; import Flex from 'components/Flex'; type Props = { @@ -33,7 +33,7 @@ const Modal = ({ > {title &&

{title}

} - + {children}
diff --git a/frontend/components/Scrollable/Scrollable.js b/frontend/components/Scrollable/Scrollable.js index 526cc29e..536c2a24 100644 --- a/frontend/components/Scrollable/Scrollable.js +++ b/frontend/components/Scrollable/Scrollable.js @@ -1,18 +1,11 @@ // @flow -import React, { Component } from 'react'; import styled from 'styled-components'; -const Scroll = styled.div` +const Scrollable = styled.div` height: 100%; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; `; -class Scrollable extends Component { - render() { - return ; - } -} - export default Scrollable; diff --git a/frontend/components/Toasts/components/Toast.js b/frontend/components/Toasts/components/Toast.js index d4a96f5f..da25a022 100644 --- a/frontend/components/Toasts/components/Toast.js +++ b/frontend/components/Toasts/components/Toast.js @@ -4,7 +4,6 @@ import styled from 'styled-components'; import { darken } from 'polished'; import { color } from 'styles/constants'; import { fadeAndScaleIn } from 'styles/animations'; -import Icon from 'components/Icon'; type Props = { onRequestClose: () => void, @@ -38,9 +37,6 @@ class Toast extends Component { return ( - {type === 'info' - ? - : } {message} ); diff --git a/frontend/index.js b/frontend/index.js index e6784ea7..12e49fe7 100644 --- a/frontend/index.js +++ b/frontend/index.js @@ -73,6 +73,15 @@ const Auth = ({ children }: AuthProps) => { }), }; + if (window.Bugsnag) { + Bugsnag.user = { + id: user.id, + name: user.name, + teamId: team.id, + team: team.name, + }; + } + authenticatedStores.collections.fetchAll(); } diff --git a/frontend/menus/BlockMenu.js b/frontend/menus/BlockMenu.js new file mode 100644 index 00000000..08192ce3 --- /dev/null +++ b/frontend/menus/BlockMenu.js @@ -0,0 +1,52 @@ +// @flow +import React, { Component } from 'react'; +import ImageIcon from 'components/Icon/ImageIcon'; +import BulletedListIcon from 'components/Icon/BulletedListIcon'; +import HorizontalRuleIcon from 'components/Icon/HorizontalRuleIcon'; +import TodoListIcon from 'components/Icon/TodoListIcon'; +import { observer } from 'mobx-react'; +import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; + +@observer class BlockMenu extends Component { + props: { + label?: React$Element<*>, + onPickImage: SyntheticEvent => void, + onInsertList: SyntheticEvent => void, + onInsertTodoList: SyntheticEvent => void, + onInsertBreak: SyntheticEvent => void, + }; + + render() { + const { + label, + onPickImage, + onInsertList, + onInsertTodoList, + onInsertBreak, + ...rest + } = this.props; + + return ( + + + Add images + + + Start list + + + Start checklist + + + Add break + + + ); + } +} + +export default BlockMenu; diff --git a/frontend/menus/CollectionMenu.js b/frontend/menus/CollectionMenu.js index 6b57bf09..76c778be 100644 --- a/frontend/menus/CollectionMenu.js +++ b/frontend/menus/CollectionMenu.js @@ -1,18 +1,17 @@ // @flow import React, { Component } from 'react'; import { inject, observer } from 'mobx-react'; -import styled from 'styled-components'; import Collection from 'models/Collection'; import UiStore from 'stores/UiStore'; -import Icon from 'components/Icon'; +import MoreIcon from 'components/Icon/MoreIcon'; import Flex from 'components/Flex'; import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; @observer class CollectionMenu extends Component { props: { - label?: React$Element, - onShow?: () => void, + label?: React$Element<*>, + onOpen?: () => void, onClose?: () => void, onImport?: () => void, history: Object, @@ -36,13 +35,13 @@ import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; }; render() { - const { collection, label, onShow, onClose, onImport } = this.props; + const { collection, label, onOpen, onClose, onImport } = this.props; const { allowDelete } = collection; return ( } - onShow={onShow} + label={label || } + onOpen={onOpen} onClose={onClose} > {collection && @@ -53,17 +52,13 @@ import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; Import document - Edit + Editโ€ฆ } {allowDelete && - Delete} + Deleteโ€ฆ} ); } } -const MoreIcon = styled(Icon)` - width: 22px; -`; - export default inject('ui')(CollectionMenu); diff --git a/frontend/menus/DocumentMenu.js b/frontend/menus/DocumentMenu.js index e60933f8..3e74bd8b 100644 --- a/frontend/menus/DocumentMenu.js +++ b/frontend/menus/DocumentMenu.js @@ -4,7 +4,7 @@ import { withRouter } from 'react-router-dom'; import { inject, observer } from 'mobx-react'; import Document from 'models/Document'; import UiStore from 'stores/UiStore'; -import Icon from 'components/Icon'; +import MoreIcon from 'components/Icon/MoreIcon'; import { documentMoveUrl } from 'utils/routeHelpers'; import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; @@ -49,7 +49,7 @@ import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; const { allowDelete } = document; return ( - }> + }> {document.starred ? Unstar @@ -61,11 +61,13 @@ import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; > New child - Move - Export + + Download + + Moveโ€ฆ {allowDelete && - Delete + Deleteโ€ฆ } ); diff --git a/frontend/scenes/Dashboard/Dashboard.js b/frontend/scenes/Dashboard/Dashboard.js index 326ba542..dbbc805c 100644 --- a/frontend/scenes/Dashboard/Dashboard.js +++ b/frontend/scenes/Dashboard/Dashboard.js @@ -1,5 +1,5 @@ // @flow -import React from 'react'; +import React, { Component } from 'react'; import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; import styled from 'styled-components'; @@ -26,9 +26,9 @@ type Props = { documents: DocumentsStore, }; -@observer class Dashboard extends React.Component { +@observer class Dashboard extends Component { props: Props; - @observable isLoaded = false; + @observable isLoaded: boolean = false; componentDidMount() { this.loadContent(); @@ -43,25 +43,27 @@ type Props = { }; render() { + const { documents } = this.props; + const recentlyViewedLoaded = documents.recentlyViewed.length > 0; + const recentlyEditedLoaded = documents.recentlyEdited.length > 0; + const showContent = + this.isLoaded || (recentlyViewedLoaded && recentlyEditedLoaded); + return (

Home

- {this.isLoaded + {showContent ? - {this.props.documents.recentlyViewed.length > 0 && + {recentlyViewedLoaded && Recently viewed - + } - {this.props.documents.recentlyEdited.length > 0 && + {recentlyEditedLoaded && Recently edited - + } : } diff --git a/frontend/scenes/Document/Document.js b/frontend/scenes/Document/Document.js index 3db9f3b0..371765cd 100644 --- a/frontend/scenes/Document/Document.js +++ b/frontend/scenes/Document/Document.js @@ -31,6 +31,7 @@ import LoadingIndicator from 'components/LoadingIndicator'; import Collaborators from 'components/Collaborators'; import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; +import NewDocumentIcon from 'components/Icon/NewDocumentIcon'; import Search from 'scenes/Search'; const DISCARD_CHANGES = ` @@ -57,7 +58,6 @@ type Props = { @observable isDragging = false; @observable isLoading = false; @observable isSaving = false; - @observable showAsSaved = false; @observable notFound = false; @observable moveModalOpen: boolean = false; @@ -169,17 +169,9 @@ type Props = { if (redirect || this.props.newDocument) { this.props.history.push(document.url); - } else { - this.toggleShowAsSaved(); } }; - toggleShowAsSaved() { - this.showAsSaved = true; - this.isSaving = false; - this.savedTimeout = setTimeout(() => (this.showAsSaved = false), 2000); - } - onImageUploadStart = () => { this.isLoading = true; }; @@ -193,7 +185,7 @@ type Props = { this.document.updateData({ text }, true); }; - onCancel = () => { + onDiscard = () => { let url; if (this.document && this.document.url) { url = this.document.url; @@ -264,7 +256,7 @@ type Props = { onImageUploadStop={this.onImageUploadStop} onChange={this.onChange} onSave={this.onSave} - onCancel={this.onCancel} + onCancel={this.onDiscard} readOnly={!this.isEditing} /> {this.isEditing && - Cancel + Discard } {!this.isEditing && @@ -303,7 +295,7 @@ type Props = { {!this.isEditing && - New + } @@ -325,19 +317,11 @@ const Separator = styled.div` const HeaderAction = styled(Flex)` justify-content: center; align-items: center; - min-height: 43px; - color: ${color.text}; - padding: 0 0 0 14px; + padding: 0 0 0 10px; - a, - svg { + a { color: ${color.text}; - opacity: .8; - transition: opacity 100ms ease-in-out; - - &:hover { - opacity: 1; - } + height: 24px; } `; diff --git a/frontend/scenes/Document/components/DocumentMove/components/PathToDocument.js b/frontend/scenes/Document/components/DocumentMove/components/PathToDocument.js index 2e5bab44..82058efd 100644 --- a/frontend/scenes/Document/components/DocumentMove/components/PathToDocument.js +++ b/frontend/scenes/Document/components/DocumentMove/components/PathToDocument.js @@ -7,7 +7,7 @@ import styled from 'styled-components'; import { color } from 'styles/constants'; import Flex from 'components/Flex'; -import ChevronIcon from 'components/Icon/ChevronIcon'; +import GoToIcon from 'components/Icon/GoToIcon'; import Document from 'models/Document'; @@ -19,10 +19,7 @@ const ResultWrapper = styled.div` cursor: default; `; -const StyledChevronIcon = styled(ChevronIcon)` - padding-top: 2px; - width: 24px; - height: 24px; +const StyledGoToIcon = styled(GoToIcon)` `; const ResultWrapperLink = ResultWrapper.withComponent('a').extend` @@ -40,8 +37,8 @@ const ResultWrapperLink = ResultWrapper.withComponent('a').extend` outline: none; cursor: pointer; - ${StyledChevronIcon} svg { - fill: ${color.smokeLight}; + ${StyledGoToIcon} { + fill: ${color.white}; } } `; @@ -82,14 +79,14 @@ type Props = { if (!result) return
; return ( - + {result.path .map(doc => {doc.title}) - .reduce((prev, curr) => [prev, , curr])} + .reduce((prev, curr) => [prev, , curr])} {document && {' '} - + {' '}{document.title} } diff --git a/frontend/scenes/Flatpage/Flatpage.js b/frontend/scenes/Flatpage/Flatpage.js index b2353808..58bff50e 100644 --- a/frontend/scenes/Flatpage/Flatpage.js +++ b/frontend/scenes/Flatpage/Flatpage.js @@ -2,30 +2,31 @@ import React from 'react'; import { observer } from 'mobx-react'; import CenteredContent from 'components/CenteredContent'; -import HtmlContent from 'components/HtmlContent'; +import Editor from 'components/Editor'; import PageTitle from 'components/PageTitle'; -import { convertToMarkdown } from 'utils/markdown'; - type Props = { title: string, content: string, }; -@observer class Flatpage extends React.Component { - props: Props; +const Flatpage = observer((props: Props) => { + const { title, content } = props; - render() { - const { title, content } = this.props; - const htmlContent = convertToMarkdown(content); - - return ( - - - - - ); - } -} + return ( + + + {}} + onSave={() => {}} + onCancel={() => {}} + onImageUploadStart={() => {}} + onImageUploadStop={() => {}} + readOnly + /> + + ); +}); export default Flatpage; diff --git a/frontend/scenes/Search/Search.js b/frontend/scenes/Search/Search.js index 71e0b2c8..9050d056 100644 --- a/frontend/scenes/Search/Search.js +++ b/frontend/scenes/Search/Search.js @@ -12,6 +12,7 @@ import { searchUrl } from 'utils/routeHelpers'; import styled from 'styled-components'; import ArrowKeyNavigation from 'boundless-arrow-key-navigation'; +import Empty from 'components/Empty'; import Flex from 'components/Flex'; import CenteredContent from 'components/CenteredContent'; import LoadingIndicator from 'components/LoadingIndicator'; @@ -57,7 +58,7 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)` firstDocument: HTMLElement; props: Props; - @observable resultIds: Array = []; // Document IDs + @observable resultIds: string[] = []; // Document IDs @observable searchTerm: ?string = null; @observable isFetching = false; @@ -131,18 +132,19 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)` } render() { - const { documents } = this.props; + const { documents, notFound } = this.props; const query = this.props.match.params.query; const hasResults = this.resultIds.length > 0; + const showEmpty = !this.isFetching && this.searchTerm && !hasResults; return ( {this.isFetching && } - {this.props.notFound && + {notFound &&

Not Found

-

We're unable to find the page you're accessing.

+

Weโ€™re unable to find the page youโ€™re accessing.

} + {showEmpty && Oop, no matching documents.} { + setRef = (ref: HTMLInputElement) => { this.input = ref; }; render() { return ( - - @@ -58,4 +45,23 @@ class SearchField extends Component { } } +const StyledInput = styled.input` + width: 100%; + padding: 10px; + font-size: 48px; + font-weight: 400; + outline: none; + border: 0; + + ::-webkit-input-placeholder { color: ${color.slateLight}; } + :-moz-placeholder { color: ${color.slateLight}; } + ::-moz-placeholder { color: ${color.slateLight}; } + :-ms-input-placeholder { color: ${color.slateLight}; } +`; + +const StyledIcon = styled(SearchIcon)` + position: relative; + top: 4px; +`; + export default SearchField; diff --git a/frontend/scenes/SlackAuth/SlackAuth.js b/frontend/scenes/SlackAuth/SlackAuth.js index 21aa6bea..2846b75a 100644 --- a/frontend/scenes/SlackAuth/SlackAuth.js +++ b/frontend/scenes/SlackAuth/SlackAuth.js @@ -2,6 +2,7 @@ import React from 'react'; import { Redirect } from 'react-router'; import queryString from 'query-string'; +import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; import { client } from 'utils/ApiClient'; @@ -12,17 +13,15 @@ type Props = { location: Object, }; -type State = { - redirectTo: string, -}; - @observer class SlackAuth extends React.Component { props: Props; - state: State; - state = {}; + @observable redirectTo: string; - // $FlowIssue Flow doesn't like async lifecycle components https://github.com/facebook/flow/issues/1803 - async componentDidMount(): void { + componentDidMount() { + this.redirect(); + } + + async redirect() { const { error, code, state } = queryString.parse( this.props.location.search ); @@ -30,18 +29,18 @@ type State = { if (error) { if (error === 'access_denied') { // User selected "Deny" access on Slack OAuth - this.setState({ redirectTo: '/dashboard' }); + this.redirectTo = '/dashboard'; } else { - this.setState({ redirectTo: '/auth/error' }); + this.redirectTo = '/auth/error'; } } else { if (this.props.location.pathname === '/auth/slack/commands') { // User adding webhook integrations try { await client.post('/auth.slackCommands', { code }); - this.setState({ redirectTo: '/dashboard' }); + this.redirectTo = '/dashboard'; } catch (e) { - this.setState({ redirectTo: '/auth/error' }); + this.redirectTo = '/auth/error'; } } else { // Regular Slack authentication @@ -50,18 +49,15 @@ type State = { const { success } = await this.props.auth.authWithSlack(code, state); success - ? this.setState({ redirectTo: redirectTo || '/dashboard' }) - : this.setState({ redirectTo: '/auth/error' }); + ? (this.redirectTo = redirectTo || '/dashboard') + : (this.redirectTo = '/auth/error'); } } } render() { - return ( -
- {this.state.redirectTo && } -
- ); + if (this.redirectTo) return ; + return null; } } diff --git a/frontend/scenes/Starred/Starred.js b/frontend/scenes/Starred/Starred.js index 6704045c..c2abfbb0 100644 --- a/frontend/scenes/Starred/Starred.js +++ b/frontend/scenes/Starred/Starred.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import { observer, inject } from 'mobx-react'; import CenteredContent from 'components/CenteredContent'; import { ListPlaceholder } from 'components/LoadingPlaceholder'; +import Empty from 'components/Empty'; import PageTitle from 'components/PageTitle'; import DocumentList from 'components/DocumentList'; import DocumentsStore from 'stores/DocumentsStore'; @@ -17,14 +18,17 @@ import DocumentsStore from 'stores/DocumentsStore'; } render() { - const { isLoaded, isFetching } = this.props.documents; + const { isLoaded, isFetching, starred } = this.props.documents; + const showLoading = !isLoaded && isFetching; + const showEmpty = isLoaded && !starred.length; return (

Starred

- {!isLoaded && isFetching && } - + {showLoading && } + {showEmpty && No starred documents yet.} +
); } diff --git a/frontend/stores/DocumentsStore.js b/frontend/stores/DocumentsStore.js index 9ddf8925..6314d1b0 100644 --- a/frontend/stores/DocumentsStore.js +++ b/frontend/stores/DocumentsStore.js @@ -111,8 +111,12 @@ class DocumentsStore extends BaseStore { return data.map(documentData => documentData.id); }; - @action fetch = async (id: string): Promise<*> => { - this.isFetching = true; + @action prefetchDocument = async (id: string) => { + if (!this.getById(id)) this.fetch(id, true); + }; + + @action fetch = async (id: string, prefetch?: boolean): Promise<*> => { + if (!prefetch) this.isFetching = true; try { const res = await client.post('/documents.info', { id }); diff --git a/frontend/styles/animations.js b/frontend/styles/animations.js index 257528b5..e6a9d5c2 100644 --- a/frontend/styles/animations.js +++ b/frontend/styles/animations.js @@ -1,6 +1,11 @@ // @flow import { keyframes } from 'styled-components'; +export const fadeIn = keyframes` + from { opacity: 0; } + to { opacity: 1; } +`; + export const fadeAndScaleIn = keyframes` from { opacity: 0; diff --git a/frontend/styles/base.css b/frontend/styles/base.css index d7d50a1f..711104aa 100644 --- a/frontend/styles/base.css +++ b/frontend/styles/base.css @@ -50,7 +50,7 @@ svg { max-height: 100%; } a { - color: #005aa6; + color: #16B3FF; text-decoration: none; cursor: pointer; } diff --git a/frontend/utils/emoji-mapping.json b/frontend/utils/emoji-mapping.json deleted file mode 100644 index b965bfac..00000000 --- a/frontend/utils/emoji-mapping.json +++ /dev/null @@ -1 +0,0 @@ -{"100":"๐Ÿ’ฏ","1234":"๐Ÿ”ข","grinning":"๐Ÿ˜€","grimacing":"๐Ÿ˜ฌ","grin":"๐Ÿ˜","joy":"๐Ÿ˜‚","smiley":"๐Ÿ˜ƒ","smile":"๐Ÿ˜„","sweat_smile":"๐Ÿ˜…","laughing":"๐Ÿ˜†","innocent":"๐Ÿ˜‡","wink":"๐Ÿ˜‰","blush":"๐Ÿ˜Š","slightly_smiling":"๐Ÿ™‚","upside_down":"๐Ÿ™ƒ","relaxed":"โ˜บ๏ธ","yum":"๐Ÿ˜‹","relieved":"๐Ÿ˜Œ","heart_eyes":"๐Ÿ˜","kissing_heart":"๐Ÿ˜˜","kissing":"๐Ÿ˜—","kissing_smiling_eyes":"๐Ÿ˜™","kissing_closed_eyes":"๐Ÿ˜š","stuck_out_tongue_winking_eye":"๐Ÿ˜œ","stuck_out_tongue_closed_eyes":"๐Ÿ˜","stuck_out_tongue":"๐Ÿ˜›","money_mouth":"๐Ÿค‘","nerd":"๐Ÿค“","sunglasses":"๐Ÿ˜Ž","hugging":"๐Ÿค—","smirk":"๐Ÿ˜","no_mouth":"๐Ÿ˜ถ","neutral_face":"๐Ÿ˜","expressionless":"๐Ÿ˜‘","unamused":"๐Ÿ˜’","rolling_eyes":"๐Ÿ™„","thinking":"๐Ÿค”","flushed":"๐Ÿ˜ณ","disappointed":"๐Ÿ˜ž","worried":"๐Ÿ˜Ÿ","angry":"๐Ÿ˜ ","rage":"๐Ÿ˜ก","pensive":"๐Ÿ˜”","confused":"๐Ÿ˜•","slightly_sad":"๐Ÿ™","white_frowning":"โ˜น","persevere":"๐Ÿ˜ฃ","confounded":"๐Ÿ˜–","tired_face":"๐Ÿ˜ซ","weary":"๐Ÿ˜ฉ","triumph":"๐Ÿ˜ค","open_mouth":"๐Ÿ˜ฎ","scream":"๐Ÿ˜ฑ","fearful":"๐Ÿ˜จ","cold_sweat":"๐Ÿ˜ฐ","hushed":"๐Ÿ˜ฏ","frowning":"๐Ÿ˜ฆ","anguished":"๐Ÿ˜ง","cry":"๐Ÿ˜ข","disappointed_relieved":"๐Ÿ˜ฅ","sleepy":"๐Ÿ˜ช","sweat":"๐Ÿ˜“","sob":"๐Ÿ˜ญ","dizzy_face":"๐Ÿ˜ต","astonished":"๐Ÿ˜ฒ","zipper_mouth":"๐Ÿค","mask":"๐Ÿ˜ท","thermometer_face":"๐Ÿค’","bandage_face":"๐Ÿค•","sleeping":"๐Ÿ˜ด","zzz":"๐Ÿ’ค","hankey":"๐Ÿ’ฉ","smiling_imp":"๐Ÿ˜ˆ","imp":"๐Ÿ‘ฟ","japanese_ogre":"๐Ÿ‘น","japanese_goblin":"๐Ÿ‘บ","skull":"๐Ÿ’€","ghost":"๐Ÿ‘ป","alien":"๐Ÿ‘ฝ","robot":"๐Ÿค–","smiley_cat":"๐Ÿ˜บ","smile_cat":"๐Ÿ˜ธ","joy_cat":"๐Ÿ˜น","heart_eyes_cat":"๐Ÿ˜ป","smirk_cat":"๐Ÿ˜ผ","kissing_cat":"๐Ÿ˜ฝ","scream_cat":"๐Ÿ™€","crying_cat_face":"๐Ÿ˜ฟ","pouting_cat":"๐Ÿ˜พ","raised_hands":"๐Ÿ™Œ","clap":"๐Ÿ‘","wave":"๐Ÿ‘‹","+1":"๐Ÿ‘","-1":"๐Ÿ‘Ž","facepunch":"๐Ÿ‘Š","fist":"โœŠ","v":"โœŒ๏ธ","ok_hand":"๐Ÿ‘Œ","hand":"โœ‹","open_hands":"๐Ÿ‘","muscle":"๐Ÿ’ช","pray":"๐Ÿ™","point_up":"โ˜๏ธ","point_up_2":"๐Ÿ‘†","point_down":"๐Ÿ‘‡","point_left":"๐Ÿ‘ˆ","point_right":"๐Ÿ‘‰","middle_finger":"๐Ÿ–•","splayed_fingers":"๐Ÿ–","sign_of_horns":"๐Ÿค˜","vulcan_salute":"๐Ÿ––","writing_hand":"โœ","nail_care":"๐Ÿ’…","lips":"๐Ÿ‘„","tongue":"๐Ÿ‘…","ear":"๐Ÿ‘‚","nose":"๐Ÿ‘ƒ","eye":"๐Ÿ‘","eyes":"๐Ÿ‘€","bust_in_silhouette":"๐Ÿ‘ค","busts_in_silhouette":"๐Ÿ‘ฅ","speaking_head":"๐Ÿ—ฃ","baby":"๐Ÿ‘ถ","boy":"๐Ÿ‘ฆ","girl":"๐Ÿ‘ง","man":"๐Ÿ‘จ","woman":"๐Ÿ‘ฉ","person_with_blond_hair":"๐Ÿ‘ฑ","older_man":"๐Ÿ‘ด","older_woman":"๐Ÿ‘ต","man_with_gua_pi_mao":"๐Ÿ‘ฒ","man_with_turban":"๐Ÿ‘ณ","cop":"๐Ÿ‘ฎ","construction_worker":"๐Ÿ‘ท","guardsman":"๐Ÿ’‚","sleuth":"๐Ÿ•ต","santa":"๐ŸŽ…","angel":"๐Ÿ‘ผ","princess":"๐Ÿ‘ธ","bride_with_veil":"๐Ÿ‘ฐ","runner":"๐Ÿƒ","walking":"๐Ÿšถ","dancer":"๐Ÿ’ƒ","dancers":"๐Ÿ‘ฏ","couple":"๐Ÿ‘ซ","two_men_holding_hands":"๐Ÿ‘ฌ","two_women_holding_hands":"๐Ÿ‘ญ","bow":"๐Ÿ™‡","information_desk_person":"๐Ÿ’","no_good":"๐Ÿ™…","ok_woman":"๐Ÿ™†","raising_hand":"๐Ÿ™‹","person_with_pouting_face":"๐Ÿ™Ž","person_frowning":"๐Ÿ™","haircut":"๐Ÿ’‡","massage":"๐Ÿ’†","couple_with_heart":"๐Ÿ’‘","female_couple_with_heart":"๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘ฉ","male_couple_with_heart":"๐Ÿ‘จโ€โค๏ธโ€๐Ÿ‘จ","couplekiss":"๐Ÿ’","female_couplekiss":"๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ","male_couplekiss":"๐Ÿ‘จโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ","family":"๐Ÿ‘ช","family_man_woman_girl":"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง","family_man_woman_girl_boy":"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ","family_man_woman_boys":"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ","family_man_woman_girls":"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง","family_women_boy":"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ","family_women_girl":"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ง","family_women_girl_boy":"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ","family_women_boys":"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ","family_women_girls":"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง","family_men_boy":"๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆ","family_men_girl":"๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ง","family_men_girl_boy":"๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ","family_men_boys":"๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ","family_men_girls":"๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง","womans_clothes":"๐Ÿ‘š","shirt":"๐Ÿ‘•","jeans":"๐Ÿ‘–","necktie":"๐Ÿ‘”","dress":"๐Ÿ‘—","bikini":"๐Ÿ‘™","kimono":"๐Ÿ‘˜","lipstick":"๐Ÿ’„","kiss":"๐Ÿ’‹","footprints":"๐Ÿ‘ฃ","high_heel":"๐Ÿ‘ ","sandal":"๐Ÿ‘ก","boot":"๐Ÿ‘ข","mans_shoe":"๐Ÿ‘ž","athletic_shoe":"๐Ÿ‘Ÿ","womans_hat":"๐Ÿ‘’","tophat":"๐ŸŽฉ","helmet":"โ›‘","mortar_board":"๐ŸŽ“","crown":"๐Ÿ‘‘","school_satchel":"๐ŸŽ’","pouch":"๐Ÿ‘","purse":"๐Ÿ‘›","handbag":"๐Ÿ‘œ","briefcase":"๐Ÿ’ผ","eyeglasses":"๐Ÿ‘“","dark_sunglasses":"๐Ÿ•ถ","ring":"๐Ÿ’","closed_umbrella":"๐ŸŒ‚","dog":"๐Ÿถ","cat":"๐Ÿฑ","mouse":"๐Ÿญ","hamster":"๐Ÿน","rabbit":"๐Ÿฐ","bear":"๐Ÿป","panda_face":"๐Ÿผ","koala":"๐Ÿจ","tiger":"๐Ÿฏ","lion_face":"๐Ÿฆ","cow":"๐Ÿฎ","pig":"๐Ÿท","pig_nose":"๐Ÿฝ","frog":"๐Ÿธ","octopus":"๐Ÿ™","monkey_face":"๐Ÿต","see_no_evil":"๐Ÿ™ˆ","hear_no_evil":"๐Ÿ™‰","speak_no_evil":"๐Ÿ™Š","monkey":"๐Ÿ’","chicken":"๐Ÿ”","penguin":"๐Ÿง","bird":"๐Ÿฆ","baby_chick":"๐Ÿค","hatching_chick":"๐Ÿฃ","hatched_chick":"๐Ÿฅ","wolf":"๐Ÿบ","boar":"๐Ÿ—","horse":"๐Ÿด","unicorn_face":"๐Ÿฆ„","bee":"๐Ÿ","bug":"๐Ÿ›","snail":"๐ŸŒ","beetle":"๐Ÿž","ant":"๐Ÿœ","spider":"๐Ÿ•ท","scorpion":"๐Ÿฆ‚","crab":"๐Ÿฆ€","snake":"๐Ÿ","turtle":"๐Ÿข","tropical_fish":"๐Ÿ ","fish":"๐ŸŸ","blowfish":"๐Ÿก","dolphin":"๐Ÿฌ","whale":"๐Ÿณ","whale2":"๐Ÿ‹","crocodile":"๐ŸŠ","leopard":"๐Ÿ†","tiger2":"๐Ÿ…","water_buffalo":"๐Ÿƒ","ox":"๐Ÿ‚","cow2":"๐Ÿ„","dromedary_camel":"๐Ÿช","camel":"๐Ÿซ","elephant":"๐Ÿ˜","goat":"๐Ÿ","ram":"๐Ÿ","sheep":"๐Ÿ‘","racehorse":"๐ŸŽ","pig2":"๐Ÿ–","rat":"๐Ÿ€","mouse2":"๐Ÿ","rooster":"๐Ÿ“","turkey":"๐Ÿฆƒ","dove_of_peace":"๐Ÿ•Š","dog2":"๐Ÿ•","poodle":"๐Ÿฉ","cat2":"๐Ÿˆ","rabbit2":"๐Ÿ‡","chipmunk":"๐Ÿฟ","feet":"๐Ÿพ","dragon":"๐Ÿ‰","dragon_face":"๐Ÿฒ","cactus":"๐ŸŒต","christmas_tree":"๐ŸŽ„","evergreen_tree":"๐ŸŒฒ","deciduous_tree":"๐ŸŒณ","palm_tree":"๐ŸŒด","seedling":"๐ŸŒฑ","herb":"๐ŸŒฟ","shamrock":"โ˜˜","four_leaf_clover":"๐Ÿ€","bamboo":"๐ŸŽ","tanabata_tree":"๐ŸŽ‹","leaves":"๐Ÿƒ","fallen_leaf":"๐Ÿ‚","maple_leaf":"๐Ÿ","ear_of_rice":"๐ŸŒพ","hibiscus":"๐ŸŒบ","sunflower":"๐ŸŒป","rose":"๐ŸŒน","tulip":"๐ŸŒท","blossom":"๐ŸŒผ","cherry_blossom":"๐ŸŒธ","bouquet":"๐Ÿ’","mushroom":"๐Ÿ„","chestnut":"๐ŸŒฐ","jack_o_lantern":"๐ŸŽƒ","shell":"๐Ÿš","spider_web":"๐Ÿ•ธ","earth_americas":"๐ŸŒŽ","earth_africa":"๐ŸŒ","earth_asia":"๐ŸŒ","full_moon":"๐ŸŒ•","waning_gibbous_moon":"๐ŸŒ–","last_quarter_moon":"๐ŸŒ—","waning_crescent_moon":"๐ŸŒ˜","new_moon":"๐ŸŒ‘","waxing_crescent_moon":"๐ŸŒ’","first_quarter_moon":"๐ŸŒ“","moon":"๐ŸŒ”","new_moon_with_face":"๐ŸŒš","full_moon_with_face":"๐ŸŒ","first_quarter_moon_with_face":"๐ŸŒ›","last_quarter_moon_with_face":"๐ŸŒœ","sun_with_face":"๐ŸŒž","crescent_moon":"๐ŸŒ™","star":"โญ","star2":"๐ŸŒŸ","dizzy":"๐Ÿ’ซ","sparkles":"โœจ","comet":"โ˜„","sunny":"โ˜€๏ธ","white_sun_with_small_cloud":"๐ŸŒค","partly_sunny":"โ›…","white_sun_behind_cloud":"๐ŸŒฅ","white_sun_behind_cloud_with_rain":"๐ŸŒฆ","cloud":"โ˜๏ธ","cloud_with_rain":"๐ŸŒง","thunder_cloud_and_rain":"โ›ˆ","cloud_with_lightning":"๐ŸŒฉ","zap":"โšก","fire":"๐Ÿ”ฅ","boom":"๐Ÿ’ฅ","snowflake":"โ„๏ธ","cloud_with_snow":"๐ŸŒจ","snowman":"โ›„","snowman_with_snow":"โ˜ƒ","wind_blowing_face":"๐ŸŒฌ","dash":"๐Ÿ’จ","cloud_with_tornado":"๐ŸŒช","fog":"๐ŸŒซ","umbrella_without_rain":"โ˜‚","umbrella":"โ˜”","droplet":"๐Ÿ’ง","sweat_drops":"๐Ÿ’ฆ","ocean":"๐ŸŒŠ","green_apple":"๐Ÿ","apple":"๐ŸŽ","pear":"๐Ÿ","tangerine":"๐ŸŠ","lemon":"๐Ÿ‹","banana":"๐ŸŒ","watermelon":"๐Ÿ‰","grapes":"๐Ÿ‡","strawberry":"๐Ÿ“","melon":"๐Ÿˆ","cherries":"๐Ÿ’","peach":"๐Ÿ‘","pineapple":"๐Ÿ","tomato":"๐Ÿ…","eggplant":"๐Ÿ†","hot_pepper":"๐ŸŒถ","corn":"๐ŸŒฝ","sweet_potato":"๐Ÿ ","honey_pot":"๐Ÿฏ","bread":"๐Ÿž","cheese_wedge":"๐Ÿง€","poultry_leg":"๐Ÿ—","meat_on_bone":"๐Ÿ–","fried_shrimp":"๐Ÿค","egg":"๐Ÿณ","hamburger":"๐Ÿ”","fries":"๐ŸŸ","hot_dog":"๐ŸŒญ","pizza":"๐Ÿ•","spaghetti":"๐Ÿ","taco":"๐ŸŒฎ","burrito":"๐ŸŒฏ","ramen":"๐Ÿœ","stew":"๐Ÿฒ","fish_cake":"๐Ÿฅ","sushi":"๐Ÿฃ","bento":"๐Ÿฑ","curry":"๐Ÿ›","rice_ball":"๐Ÿ™","rice":"๐Ÿš","rice_cracker":"๐Ÿ˜","oden":"๐Ÿข","dango":"๐Ÿก","shaved_ice":"๐Ÿง","ice_cream":"๐Ÿจ","icecream":"๐Ÿฆ","cake":"๐Ÿฐ","birthday":"๐ŸŽ‚","custard":"๐Ÿฎ","candy":"๐Ÿฌ","lollipop":"๐Ÿญ","chocolate_bar":"๐Ÿซ","popcorn":"๐Ÿฟ","doughnut":"๐Ÿฉ","cookie":"๐Ÿช","beer":"๐Ÿบ","beers":"๐Ÿป","wine_glass":"๐Ÿท","cocktail":"๐Ÿธ","tropical_drink":"๐Ÿน","bottle":"๐Ÿพ","sake":"๐Ÿถ","tea":"๐Ÿต","coffee":"โ˜•","baby_bottle":"๐Ÿผ","fork_and_knife":"๐Ÿด","fork_and_knife_with_plate":"๐Ÿฝ","soccer":"โšฝ","basketball":"๐Ÿ€","football":"๐Ÿˆ","baseball":"โšพ","tennis":"๐ŸŽพ","volleyball":"๐Ÿ","rugby_football":"๐Ÿ‰","8ball":"๐ŸŽฑ","golf":"โ›ณ","golfer":"๐ŸŒ","table_tennis":"๐Ÿ“","badminton":"๐Ÿธ","ice_hockey":"๐Ÿ’","field_hockey":"๐Ÿ‘","cricket":"๐Ÿ","ski":"๐ŸŽฟ","skier":"โ›ท","snowboarder":"๐Ÿ‚","ice_skate":"โ›ธ","bow_and_arrow":"๐Ÿน","fishing_pole_and_fish":"๐ŸŽฃ","rowboat":"๐Ÿšฃ","swimmer":"๐ŸŠ","surfer":"๐Ÿ„","bath":"๐Ÿ›€","person_with_ball":"โ›น","weight_lifter":"๐Ÿ‹","bicyclist":"๐Ÿšด","mountain_bicyclist":"๐Ÿšต","horse_racing":"๐Ÿ‡","man_levitating":"๐Ÿ•ด","trophy":"๐Ÿ†","running_shirt_with_sash":"๐ŸŽฝ","sports_medal":"๐Ÿ…","military_medal":"๐ŸŽ–","reminder_ribbon":"๐ŸŽ—","rosette":"๐Ÿต","ticket":"๐ŸŽซ","admission_ticket":"๐ŸŽŸ","performing_arts":"๐ŸŽญ","art":"๐ŸŽจ","circus_tent":"๐ŸŽช","microphone":"๐ŸŽค","headphones":"๐ŸŽง","musical_score":"๐ŸŽผ","musical_keyboard":"๐ŸŽน","saxophone":"๐ŸŽท","trumpet":"๐ŸŽบ","guitar":"๐ŸŽธ","violin":"๐ŸŽป","clapper":"๐ŸŽฌ","video_game":"๐ŸŽฎ","space_invader":"๐Ÿ‘พ","dart":"๐ŸŽฏ","game_die":"๐ŸŽฒ","slot_machine":"๐ŸŽฐ","bowling":"๐ŸŽณ","car":"๐Ÿš—","taxi":"๐Ÿš•","blue_car":"๐Ÿš™","bus":"๐ŸšŒ","trolleybus":"๐ŸšŽ","racing_car":"๐ŸŽ","police_car":"๐Ÿš“","ambulance":"๐Ÿš‘","fire_engine":"๐Ÿš’","minibus":"๐Ÿš","truck":"๐Ÿšš","articulated_lorry":"๐Ÿš›","tractor":"๐Ÿšœ","racing_motorcycle":"๐Ÿ","bike":"๐Ÿšฒ","rotating_light":"๐Ÿšจ","oncoming_police_car":"๐Ÿš”","oncoming_bus":"๐Ÿš","oncoming_automobile":"๐Ÿš˜","oncoming_taxi":"๐Ÿš–","aerial_tramway":"๐Ÿšก","mountain_cableway":"๐Ÿš ","suspension_railway":"๐ŸšŸ","railway_car":"๐Ÿšƒ","train":"๐Ÿš‹","monorail":"๐Ÿš","bullettrain_side":"๐Ÿš„","bullettrain_front":"๐Ÿš…","light_rail":"๐Ÿšˆ","mountain_railway":"๐Ÿšž","steam_locomotive":"๐Ÿš‚","train2":"๐Ÿš†","metro":"๐Ÿš‡","tram":"๐ŸšŠ","station":"๐Ÿš‰","helicopter":"๐Ÿš","small_airplane":"๐Ÿ›ฉ","airplane":"โœˆ๏ธ","airplane_departure":"๐Ÿ›ซ","airplane_arrival":"๐Ÿ›ฌ","boat":"โ›ต","motor_boat":"๐Ÿ›ฅ","speedboat":"๐Ÿšค","ferry":"โ›ด","passenger_ship":"๐Ÿ›ณ","rocket":"๐Ÿš€","artificial_satellite":"๐Ÿ›ฐ","seat":"๐Ÿ’บ","anchor":"โš“","construction":"๐Ÿšง","fuelpump":"โ›ฝ","busstop":"๐Ÿš","vertical_traffic_light":"๐Ÿšฆ","traffic_light":"๐Ÿšฅ","checkered_flag":"๐Ÿ","ship":"๐Ÿšข","ferris_wheel":"๐ŸŽก","roller_coaster":"๐ŸŽข","carousel_horse":"๐ŸŽ ","building_construction":"๐Ÿ—","foggy":"๐ŸŒ","tokyo_tower":"๐Ÿ—ผ","factory":"๐Ÿญ","fountain":"โ›ฒ","rice_scene":"๐ŸŽ‘","mountain":"โ›ฐ","snow_capped_mountain":"๐Ÿ”","mount_fuji":"๐Ÿ—ป","volcano":"๐ŸŒ‹","japan":"๐Ÿ—พ","camping":"๐Ÿ•","tent":"โ›บ","national_park":"๐Ÿž","motorway":"๐Ÿ›ฃ","railway_track":"๐Ÿ›ค","sunrise":"๐ŸŒ…","sunrise_over_mountains":"๐ŸŒ„","desert":"๐Ÿœ","beach_with_umbrella":"๐Ÿ–","desert_island":"๐Ÿ","city_sunrise":"๐ŸŒ‡","city_sunset":"๐ŸŒ†","cityscape":"๐Ÿ™","night_with_stars":"๐ŸŒƒ","bridge_at_night":"๐ŸŒ‰","milky_way":"๐ŸŒŒ","stars":"๐ŸŒ ","sparkler":"๐ŸŽ‡","fireworks":"๐ŸŽ†","rainbow":"๐ŸŒˆ","buildings":"๐Ÿ˜","european_castle":"๐Ÿฐ","japanese_castle":"๐Ÿฏ","stadium":"๐ŸŸ","statue_of_liberty":"๐Ÿ—ฝ","house":"๐Ÿ ","house_with_garden":"๐Ÿก","derelict_house":"๐Ÿš","office":"๐Ÿข","department_store":"๐Ÿฌ","post_office":"๐Ÿฃ","european_post_office":"๐Ÿค","hospital":"๐Ÿฅ","bank":"๐Ÿฆ","hotel":"๐Ÿจ","convenience_store":"๐Ÿช","school":"๐Ÿซ","love_hotel":"๐Ÿฉ","wedding":"๐Ÿ’’","museum":"๐Ÿ›","church":"โ›ช","mosque":"๐Ÿ•Œ","synagogue":"๐Ÿ•","kaaba":"๐Ÿ•‹","shinto_shrine":"โ›ฉ","watch":"โŒš","iphone":"๐Ÿ“ฑ","calling":"๐Ÿ“ฒ","computer":"๐Ÿ’ป","keyboard":"โŒจ","desktop_computer":"๐Ÿ–ฅ","printer":"๐Ÿ–จ","three_button_mouse":"๐Ÿ–ฑ","trackball":"๐Ÿ–ฒ","joystick":"๐Ÿ•น","compression":"๐Ÿ—œ","minidisc":"๐Ÿ’ฝ","floppy_disk":"๐Ÿ’พ","cd":"๐Ÿ’ฟ","dvd":"๐Ÿ“€","vhs":"๐Ÿ“ผ","camera":"๐Ÿ“ท","camera_with_flash":"๐Ÿ“ธ","video_camera":"๐Ÿ“น","movie_camera":"๐ŸŽฅ","film_projector":"๐Ÿ“ฝ","film_frames":"๐ŸŽž","telephone_receiver":"๐Ÿ“ž","phone":"โ˜Ž๏ธ","pager":"๐Ÿ“Ÿ","fax":"๐Ÿ“ ","tv":"๐Ÿ“บ","radio":"๐Ÿ“ป","studio_microphone":"๐ŸŽ™","level_slider":"๐ŸŽš","control_knobs":"๐ŸŽ›","stopwatch":"โฑ","timer_clock":"โฒ","alarm_clock":"โฐ","mantelpiece_clock":"๐Ÿ•ฐ","hourglass_flowing_sand":"โณ","hourglass":"โŒ›","satellite":"๐Ÿ“ก","battery":"๐Ÿ”‹","electric_plug":"๐Ÿ”Œ","bulb":"๐Ÿ’ก","flashlight":"๐Ÿ”ฆ","candle":"๐Ÿ•ฏ","wastebasket":"๐Ÿ—‘","oil_drum":"๐Ÿ›ข","money_with_wings":"๐Ÿ’ธ","dollar":"๐Ÿ’ต","yen":"๐Ÿ’ด","euro":"๐Ÿ’ถ","pound":"๐Ÿ’ท","moneybag":"๐Ÿ’ฐ","credit_card":"๐Ÿ’ณ","gem":"๐Ÿ’Ž","scales":"โš–","wrench":"๐Ÿ”ง","hammer":"๐Ÿ”จ","hammer_and_pick":"โš’","hammer_and_wrench":"๐Ÿ› ","pick":"โ›","nut_and_bolt":"๐Ÿ”ฉ","gear":"โš™","chains":"โ›“","gun":"๐Ÿ”ซ","bomb":"๐Ÿ’ฃ","hocho":"๐Ÿ”ช","dagger_knife":"๐Ÿ—ก","crossed_swords":"โš”","shield":"๐Ÿ›ก","smoking":"๐Ÿšฌ","skull_and_crossbones":"โ˜ ","coffin":"โšฐ","funeral_urn":"โšฑ","amphora":"๐Ÿบ","crystal_ball":"๐Ÿ”ฎ","prayer_beads":"๐Ÿ“ฟ","barber":"๐Ÿ’ˆ","alembic":"โš—","telescope":"๐Ÿ”ญ","microscope":"๐Ÿ”ฌ","hole":"๐Ÿ•ณ","pill":"๐Ÿ’Š","syringe":"๐Ÿ’‰","thermometer":"๐ŸŒก","label":"๐Ÿท","bookmark":"๐Ÿ”–","toilet":"๐Ÿšฝ","shower":"๐Ÿšฟ","bathtub":"๐Ÿ›","key":"๐Ÿ”‘","old_key":"๐Ÿ—","couch_and_lamp":"๐Ÿ›‹","sleeping_accommodation":"๐Ÿ›Œ","bed":"๐Ÿ›","door":"๐Ÿšช","bellhop_bell":"๐Ÿ›Ž","frame_with_picture":"๐Ÿ–ผ","world_map":"๐Ÿ—บ","umbrella_on_ground":"โ›ฑ","moyai":"๐Ÿ—ฟ","shopping_bags":"๐Ÿ›","balloon":"๐ŸŽˆ","flags":"๐ŸŽ","ribbon":"๐ŸŽ€","gift":"๐ŸŽ","confetti_ball":"๐ŸŽŠ","tada":"๐ŸŽ‰","dolls":"๐ŸŽŽ","wind_chime":"๐ŸŽ","crossed_flags":"๐ŸŽŒ","izakaya_lantern":"๐Ÿฎ","envelope":"โœ‰๏ธ","envelope_with_arrow":"๐Ÿ“ฉ","incoming_envelope":"๐Ÿ“จ","email":"๐Ÿ“ง","love_letter":"๐Ÿ’Œ","postbox":"๐Ÿ“ฎ","mailbox_closed":"๐Ÿ“ช","mailbox":"๐Ÿ“ซ","mailbox_with_mail":"๐Ÿ“ฌ","mailbox_with_no_mail":"๐Ÿ“ญ","package":"๐Ÿ“ฆ","postal_horn":"๐Ÿ“ฏ","inbox_tray":"๐Ÿ“ฅ","outbox_tray":"๐Ÿ“ค","scroll":"๐Ÿ“œ","page_with_curl":"๐Ÿ“ƒ","bookmark_tabs":"๐Ÿ“‘","bar_chart":"๐Ÿ“Š","chart_with_upwards_trend":"๐Ÿ“ˆ","chart_with_downwards_trend":"๐Ÿ“‰","page_facing_up":"๐Ÿ“„","date":"๐Ÿ“…","calendar":"๐Ÿ“†","spiral_calendar_pad":"๐Ÿ—“","card_index":"๐Ÿ“‡","card_file_box":"๐Ÿ—ƒ","ballot_box_with_ballot":"๐Ÿ—ณ","file_cabinet":"๐Ÿ—„","clipboard":"๐Ÿ“‹","spiral_note_pad":"๐Ÿ—’","file_folder":"๐Ÿ“","open_file_folder":"๐Ÿ“‚","card_index_dividers":"๐Ÿ—‚","rolled_up_newspaper":"๐Ÿ—ž","newspaper":"๐Ÿ“ฐ","notebook":"๐Ÿ““","closed_book":"๐Ÿ“•","green_book":"๐Ÿ“—","blue_book":"๐Ÿ“˜","orange_book":"๐Ÿ“™","notebook_with_decorative_cover":"๐Ÿ“”","ledger":"๐Ÿ“’","books":"๐Ÿ“š","book":"๐Ÿ“–","link":"๐Ÿ”—","paperclip":"๐Ÿ“Ž","linked_paperclips":"๐Ÿ–‡","scissors":"โœ‚๏ธ","triangular_ruler":"๐Ÿ“","straight_ruler":"๐Ÿ“","pushpin":"๐Ÿ“Œ","round_pushpin":"๐Ÿ“","triangular_flag_on_post":"๐Ÿšฉ","waving_white_flag":"๐Ÿณ","waving_black_flag":"๐Ÿด","closed_lock_with_key":"๐Ÿ”","lock":"๐Ÿ”’","unlock":"๐Ÿ”“","lock_with_ink_pen":"๐Ÿ”","lower_left_ballpoint_pen":"๐Ÿ–Š","lower_left_fountain_pen":"๐Ÿ–‹","black_nib":"โœ’๏ธ","memo":"๐Ÿ“","pencil2":"โœ๏ธ","lower_left_crayon":"๐Ÿ–","lower_left_paintbrush":"๐Ÿ–Œ","mag":"๐Ÿ”","mag_right":"๐Ÿ”Ž","heart":"โค๏ธ","yellow_heart":"๐Ÿ’›","green_heart":"๐Ÿ’š","blue_heart":"๐Ÿ’™","purple_heart":"๐Ÿ’œ","broken_heart":"๐Ÿ’”","heavy_heart_exclamation_mark_ornament":"โฃ","two_hearts":"๐Ÿ’•","revolving_hearts":"๐Ÿ’ž","heartbeat":"๐Ÿ’“","heartpulse":"๐Ÿ’—","sparkling_heart":"๐Ÿ’–","cupid":"๐Ÿ’˜","gift_heart":"๐Ÿ’","heart_decoration":"๐Ÿ’Ÿ","peace_symbol":"โ˜ฎ","latin_cross":"โœ","star_and_crescent":"โ˜ช","om_symbol":"๐Ÿ•‰","wheel_of_dharma":"โ˜ธ","star_of_david":"โœก","six_pointed_star":"๐Ÿ”ฏ","menorah_with_nine_branches":"๐Ÿ•Ž","yin_yang":"โ˜ฏ","orthodox_cross":"โ˜ฆ","place_of_worship":"๐Ÿ›","ophiuchus":"โ›Ž","aries":"โ™ˆ","taurus":"โ™‰","gemini":"โ™Š","cancer":"โ™‹","leo":"โ™Œ","virgo":"โ™","libra":"โ™Ž","scorpius":"โ™","sagittarius":"โ™","capricorn":"โ™‘","aquarius":"โ™’","pisces":"โ™“","id":"๐Ÿ†”","atom_symbol":"โš›","u7a7a":"๐Ÿˆณ","u5272":"๐Ÿˆน","radioactive_sign":"โ˜ข","biohazard_sign":"โ˜ฃ","mobile_phone_off":"๐Ÿ“ด","vibration_mode":"๐Ÿ“ณ","u6709":"๐Ÿˆถ","u7121":"๐Ÿˆš","u7533":"๐Ÿˆธ","u55b6":"๐Ÿˆบ","u6708":"๐Ÿˆท๏ธ","eight_pointed_black_star":"โœด๏ธ","vs":"๐Ÿ†š","accept":"๐Ÿ‰‘","white_flower":"๐Ÿ’ฎ","ideograph_advantage":"๐Ÿ‰","secret":"ใŠ™๏ธ","congratulations":"ใŠ—๏ธ","u5408":"๐Ÿˆด","u6e80":"๐Ÿˆต","u7981":"๐Ÿˆฒ","a":"๐Ÿ…ฐ๏ธ","b":"๐Ÿ…ฑ๏ธ","ab":"๐Ÿ†Ž","cl":"๐Ÿ†‘","o2":"๐Ÿ…พ๏ธ","sos":"๐Ÿ†˜","no_entry":"โ›”","name_badge":"๐Ÿ“›","no_entry_sign":"๐Ÿšซ","x":"โŒ","o":"โญ•","anger":"๐Ÿ’ข","hotsprings":"โ™จ๏ธ","no_pedestrians":"๐Ÿšท","do_not_litter":"๐Ÿšฏ","no_bicycles":"๐Ÿšณ","non-potable_water":"๐Ÿšฑ","underage":"๐Ÿ”ž","no_mobile_phones":"๐Ÿ“ต","exclamation":"โ—","grey_exclamation":"โ•","question":"โ“","grey_question":"โ”","bangbang":"โ€ผ๏ธ","interrobang":"โ‰๏ธ","low_brightness":"๐Ÿ”…","high_brightness":"๐Ÿ”†","trident":"๐Ÿ”ฑ","fleur_de_lis":"โšœ","part_alternation_mark":"ใ€ฝ๏ธ","warning":"โš ๏ธ","children_crossing":"๐Ÿšธ","beginner":"๐Ÿ”ฐ","recycle":"โ™ป๏ธ","u6307":"๐Ÿˆฏ","chart":"๐Ÿ’น","sparkle":"โ‡๏ธ","eight_spoked_asterisk":"โœณ๏ธ","negative_squared_cross_mark":"โŽ","white_check_mark":"โœ…","diamond_shape_with_a_dot_inside":"๐Ÿ’ ","cyclone":"๐ŸŒ€","loop":"โžฟ","globe_with_meridians":"๐ŸŒ","m":"โ“‚๏ธ","atm":"๐Ÿง","sa":"๐Ÿˆ‚๏ธ","passport_control":"๐Ÿ›‚","customs":"๐Ÿ›ƒ","baggage_claim":"๐Ÿ›„","left_luggage":"๐Ÿ›…","wheelchair":"โ™ฟ","no_smoking":"๐Ÿšญ","wc":"๐Ÿšพ","parking":"๐Ÿ…ฟ๏ธ","potable_water":"๐Ÿšฐ","mens":"๐Ÿšน","womens":"๐Ÿšบ","baby_symbol":"๐Ÿšผ","restroom":"๐Ÿšป","put_litter_in_its_place":"๐Ÿšฎ","cinema":"๐ŸŽฆ","signal_strength":"๐Ÿ“ถ","koko":"๐Ÿˆ","ng":"๐Ÿ†–","ok":"๐Ÿ†—","up":"๐Ÿ†™","cool":"๐Ÿ†’","new":"๐Ÿ†•","free":"๐Ÿ†“","zero":"0๏ธโƒฃ","one":"1๏ธโƒฃ","two":"2๏ธโƒฃ","three":"3๏ธโƒฃ","four":"4๏ธโƒฃ","five":"5๏ธโƒฃ","six":"6๏ธโƒฃ","seven":"7๏ธโƒฃ","eight":"8๏ธโƒฃ","nine":"9๏ธโƒฃ","keycap_ten":"๐Ÿ”Ÿ","keycap_star":"*โƒฃ","arrow_forward":"โ–ถ๏ธ","double_vertical_bar":"โธ","black_right_pointing_triangle_with_double_vertical_bar":"โญ","black_square_for_stop":"โน","black_circle_for_record":"โบ","black_right_pointing_double_triangle_with_vertical_bar":"โฏ","black_left_pointing_double_triangle_with_vertical_bar":"โฎ","fast_forward":"โฉ","rewind":"โช","twisted_rightwards_arrows":"๐Ÿ”€","repeat":"๐Ÿ”","repeat_one":"๐Ÿ”‚","arrow_backward":"โ—€๏ธ","arrow_up_small":"๐Ÿ”ผ","arrow_down_small":"๐Ÿ”ฝ","arrow_double_up":"โซ","arrow_double_down":"โฌ","arrow_right":"โžก๏ธ","arrow_left":"โฌ…๏ธ","arrow_up":"โฌ†๏ธ","arrow_down":"โฌ‡๏ธ","arrow_upper_right":"โ†—๏ธ","arrow_lower_right":"โ†˜๏ธ","arrow_lower_left":"โ†™๏ธ","arrow_upper_left":"โ†–๏ธ","arrow_up_down":"โ†•๏ธ","left_right_arrow":"โ†”๏ธ","arrows_counterclockwise":"๐Ÿ”„","arrow_right_hook":"โ†ช๏ธ","leftwards_arrow_with_hook":"โ†ฉ๏ธ","arrow_heading_up":"โคด๏ธ","arrow_heading_down":"โคต๏ธ","hash":"#๏ธโƒฃ","information_source":"โ„น๏ธ","abc":"๐Ÿ”ค","abcd":"๐Ÿ”ก","capital_abcd":"๐Ÿ” ","symbols":"๐Ÿ”ฃ","musical_note":"๐ŸŽต","notes":"๐ŸŽถ","wavy_dash":"ใ€ฐ๏ธ","curly_loop":"โžฐ","heavy_check_mark":"โœ”๏ธ","arrows_clockwise":"๐Ÿ”ƒ","heavy_plus_sign":"โž•","heavy_minus_sign":"โž–","heavy_division_sign":"โž—","heavy_multiplication_x":"โœ–๏ธ","heavy_dollar_sign":"๐Ÿ’ฒ","currency_exchange":"๐Ÿ’ฑ","copyright":"ยฉ๏ธ","registered":"ยฎ๏ธ","tm":"โ„ข๏ธ","end":"๐Ÿ”š","back":"๐Ÿ”™","on":"๐Ÿ”›","top":"๐Ÿ”","soon":"๐Ÿ”œ","ballot_box_with_check":"โ˜‘๏ธ","radio_button":"๐Ÿ”˜","white_circle":"โšช","black_circle":"โšซ","red_circle":"๐Ÿ”ด","large_blue_circle":"๐Ÿ”ต","small_orange_diamond":"๐Ÿ”ธ","small_blue_diamond":"๐Ÿ”น","large_orange_diamond":"๐Ÿ”ถ","large_blue_diamond":"๐Ÿ”ท","small_red_triangle":"๐Ÿ”บ","black_small_square":"โ–ช๏ธ","white_small_square":"โ–ซ๏ธ","black_large_square":"โฌ›","white_large_square":"โฌœ","small_red_triangle_down":"๐Ÿ”ป","black_medium_square":"โ—ผ๏ธ","white_medium_square":"โ—ป๏ธ","black_medium_small_square":"โ—พ","white_medium_small_square":"โ—ฝ","black_square_button":"๐Ÿ”ฒ","white_square_button":"๐Ÿ”ณ","speaker":"๐Ÿ”ˆ","sound":"๐Ÿ”‰","loud_sound":"๐Ÿ”Š","mute":"๐Ÿ”‡","mega":"๐Ÿ“ฃ","loudspeaker":"๐Ÿ“ข","bell":"๐Ÿ””","no_bell":"๐Ÿ”•","black_joker":"๐Ÿƒ","mahjong":"๐Ÿ€„","spades":"โ™ ๏ธ","clubs":"โ™ฃ๏ธ","hearts":"โ™ฅ๏ธ","diamonds":"โ™ฆ๏ธ","flower_playing_cards":"๐ŸŽด","thought_balloon":"๐Ÿ’ญ","right_anger_bubble":"๐Ÿ—ฏ","speech_balloon":"๐Ÿ’ฌ","left_speech_bubble":"๐Ÿ—จ","clock1":"๐Ÿ•","clock2":"๐Ÿ•‘","clock3":"๐Ÿ•’","clock4":"๐Ÿ•“","clock5":"๐Ÿ•”","clock6":"๐Ÿ••","clock7":"๐Ÿ•–","clock8":"๐Ÿ•—","clock9":"๐Ÿ•˜","clock10":"๐Ÿ•™","clock11":"๐Ÿ•š","clock12":"๐Ÿ•›","clock130":"๐Ÿ•œ","clock230":"๐Ÿ•","clock330":"๐Ÿ•ž","clock430":"๐Ÿ•Ÿ","clock530":"๐Ÿ• ","clock630":"๐Ÿ•ก","clock730":"๐Ÿ•ข","clock830":"๐Ÿ•ฃ","clock930":"๐Ÿ•ค","clock1030":"๐Ÿ•ฅ","clock1130":"๐Ÿ•ฆ","clock1230":"๐Ÿ•ง","af":"๐Ÿ‡ฆ๐Ÿ‡ซ","ax":"๐Ÿ‡ฆ๐Ÿ‡ฝ","al":"๐Ÿ‡ฆ๐Ÿ‡ฑ","dz":"๐Ÿ‡ฉ๐Ÿ‡ฟ","as":"๐Ÿ‡ฆ๐Ÿ‡ธ","ad":"๐Ÿ‡ฆ๐Ÿ‡ฉ","ao":"๐Ÿ‡ฆ๐Ÿ‡ด","ai":"๐Ÿ‡ฆ๐Ÿ‡ฎ","aq":"๐Ÿ‡ฆ๐Ÿ‡ถ","ag":"๐Ÿ‡ฆ๐Ÿ‡ฌ","ar":"๐Ÿ‡ฆ๐Ÿ‡ท","am":"๐Ÿ‡ฆ๐Ÿ‡ฒ","aw":"๐Ÿ‡ฆ๐Ÿ‡ผ","au":"๐Ÿ‡ฆ๐Ÿ‡บ","at":"๐Ÿ‡ฆ๐Ÿ‡น","az":"๐Ÿ‡ฆ๐Ÿ‡ฟ","bs":"๐Ÿ‡ง๐Ÿ‡ธ","bh":"๐Ÿ‡ง๐Ÿ‡ญ","bd":"๐Ÿ‡ง๐Ÿ‡ฉ","bb":"๐Ÿ‡ง๐Ÿ‡ง","by":"๐Ÿ‡ง๐Ÿ‡พ","be":"๐Ÿ‡ง๐Ÿ‡ช","bz":"๐Ÿ‡ง๐Ÿ‡ฟ","bj":"๐Ÿ‡ง๐Ÿ‡ฏ","bm":"๐Ÿ‡ง๐Ÿ‡ฒ","bt":"๐Ÿ‡ง๐Ÿ‡น","bo":"๐Ÿ‡ง๐Ÿ‡ด","bq":"๐Ÿ‡ง๐Ÿ‡ถ","ba":"๐Ÿ‡ง๐Ÿ‡ฆ","bw":"๐Ÿ‡ง๐Ÿ‡ผ","br":"๐Ÿ‡ง๐Ÿ‡ท","io":"๐Ÿ‡ฎ๐Ÿ‡ด","vg":"๐Ÿ‡ป๐Ÿ‡ฌ","bn":"๐Ÿ‡ง๐Ÿ‡ณ","bg":"๐Ÿ‡ง๐Ÿ‡ฌ","bf":"๐Ÿ‡ง๐Ÿ‡ซ","bi":"๐Ÿ‡ง๐Ÿ‡ฎ","cv":"๐Ÿ‡จ๐Ÿ‡ป","kh":"๐Ÿ‡ฐ๐Ÿ‡ญ","cm":"๐Ÿ‡จ๐Ÿ‡ฒ","ca":"๐Ÿ‡จ๐Ÿ‡ฆ","ic":"๐Ÿ‡ฎ๐Ÿ‡จ","ky":"๐Ÿ‡ฐ๐Ÿ‡พ","cf":"๐Ÿ‡จ๐Ÿ‡ซ","td":"๐Ÿ‡น๐Ÿ‡ฉ","chile":"๐Ÿ‡จ๐Ÿ‡ฑ","cn":"๐Ÿ‡จ๐Ÿ‡ณ","cx":"๐Ÿ‡จ๐Ÿ‡ฝ","cc":"๐Ÿ‡จ๐Ÿ‡จ","co":"๐Ÿ‡จ๐Ÿ‡ด","km":"๐Ÿ‡ฐ๐Ÿ‡ฒ","cg":"๐Ÿ‡จ๐Ÿ‡ฌ","drc":"๐Ÿ‡จ๐Ÿ‡ฉ","ck":"๐Ÿ‡จ๐Ÿ‡ฐ","cr":"๐Ÿ‡จ๐Ÿ‡ท","hr":"๐Ÿ‡ญ๐Ÿ‡ท","cu":"๐Ÿ‡จ๐Ÿ‡บ","cw":"๐Ÿ‡จ๐Ÿ‡ผ","cy":"๐Ÿ‡จ๐Ÿ‡พ","cz":"๐Ÿ‡จ๐Ÿ‡ฟ","dk":"๐Ÿ‡ฉ๐Ÿ‡ฐ","dj":"๐Ÿ‡ฉ๐Ÿ‡ฏ","dm":"๐Ÿ‡ฉ๐Ÿ‡ฒ","do":"๐Ÿ‡ฉ๐Ÿ‡ด","ec":"๐Ÿ‡ช๐Ÿ‡จ","eg":"๐Ÿ‡ช๐Ÿ‡ฌ","sv":"๐Ÿ‡ธ๐Ÿ‡ป","gq":"๐Ÿ‡ฌ๐Ÿ‡ถ","er":"๐Ÿ‡ช๐Ÿ‡ท","ee":"๐Ÿ‡ช๐Ÿ‡ช","et":"๐Ÿ‡ช๐Ÿ‡น","eu":"๐Ÿ‡ช๐Ÿ‡บ","fk":"๐Ÿ‡ซ๐Ÿ‡ฐ","fo":"๐Ÿ‡ซ๐Ÿ‡ด","fj":"๐Ÿ‡ซ๐Ÿ‡ฏ","fi":"๐Ÿ‡ซ๐Ÿ‡ฎ","fr":"๐Ÿ‡ซ๐Ÿ‡ท","gf":"๐Ÿ‡ฌ๐Ÿ‡ซ","pf":"๐Ÿ‡ต๐Ÿ‡ซ","tf":"๐Ÿ‡น๐Ÿ‡ซ","ga":"๐Ÿ‡ฌ๐Ÿ‡ฆ","gm":"๐Ÿ‡ฌ๐Ÿ‡ฒ","ge":"๐Ÿ‡ฌ๐Ÿ‡ช","de":"๐Ÿ‡ฉ๐Ÿ‡ช","gh":"๐Ÿ‡ฌ๐Ÿ‡ญ","gi":"๐Ÿ‡ฌ๐Ÿ‡ฎ","gr":"๐Ÿ‡ฌ๐Ÿ‡ท","gl":"๐Ÿ‡ฌ๐Ÿ‡ฑ","gd":"๐Ÿ‡ฌ๐Ÿ‡ฉ","gp":"๐Ÿ‡ฌ๐Ÿ‡ต","gu":"๐Ÿ‡ฌ๐Ÿ‡บ","gt":"๐Ÿ‡ฌ๐Ÿ‡น","gg":"๐Ÿ‡ฌ๐Ÿ‡ฌ","gn":"๐Ÿ‡ฌ๐Ÿ‡ณ","gw":"๐Ÿ‡ฌ๐Ÿ‡ผ","gy":"๐Ÿ‡ฌ๐Ÿ‡พ","ht":"๐Ÿ‡ญ๐Ÿ‡น","hn":"๐Ÿ‡ญ๐Ÿ‡ณ","hk":"๐Ÿ‡ญ๐Ÿ‡ฐ","hu":"๐Ÿ‡ญ๐Ÿ‡บ","is":"๐Ÿ‡ฎ๐Ÿ‡ธ","in":"๐Ÿ‡ฎ๐Ÿ‡ณ","indonesia":"๐Ÿ‡ฎ๐Ÿ‡ฉ","ir":"๐Ÿ‡ฎ๐Ÿ‡ท","iq":"๐Ÿ‡ฎ๐Ÿ‡ถ","ie":"๐Ÿ‡ฎ๐Ÿ‡ช","im":"๐Ÿ‡ฎ๐Ÿ‡ฒ","il":"๐Ÿ‡ฎ๐Ÿ‡ฑ","it":"๐Ÿ‡ฎ๐Ÿ‡น","ci":"๐Ÿ‡จ๐Ÿ‡ฎ","jm":"๐Ÿ‡ฏ๐Ÿ‡ฒ","jp":"๐Ÿ‡ฏ๐Ÿ‡ต","je":"๐Ÿ‡ฏ๐Ÿ‡ช","jo":"๐Ÿ‡ฏ๐Ÿ‡ด","kz":"๐Ÿ‡ฐ๐Ÿ‡ฟ","ke":"๐Ÿ‡ฐ๐Ÿ‡ช","ki":"๐Ÿ‡ฐ๐Ÿ‡ฎ","xk":"๐Ÿ‡ฝ๐Ÿ‡ฐ","kw":"๐Ÿ‡ฐ๐Ÿ‡ผ","kg":"๐Ÿ‡ฐ๐Ÿ‡ฌ","la":"๐Ÿ‡ฑ๐Ÿ‡ฆ","lv":"๐Ÿ‡ฑ๐Ÿ‡ป","lb":"๐Ÿ‡ฑ๐Ÿ‡ง","ls":"๐Ÿ‡ฑ๐Ÿ‡ธ","lr":"๐Ÿ‡ฑ๐Ÿ‡ท","ly":"๐Ÿ‡ฑ๐Ÿ‡พ","li":"๐Ÿ‡ฑ๐Ÿ‡ฎ","lt":"๐Ÿ‡ฑ๐Ÿ‡น","lu":"๐Ÿ‡ฑ๐Ÿ‡บ","mo":"๐Ÿ‡ฒ๐Ÿ‡ด","mk":"๐Ÿ‡ฒ๐Ÿ‡ฐ","mg":"๐Ÿ‡ฒ๐Ÿ‡ฌ","mw":"๐Ÿ‡ฒ๐Ÿ‡ผ","my":"๐Ÿ‡ฒ๐Ÿ‡พ","mv":"๐Ÿ‡ฒ๐Ÿ‡ป","ml":"๐Ÿ‡ฒ๐Ÿ‡ฑ","mt":"๐Ÿ‡ฒ๐Ÿ‡น","mh":"๐Ÿ‡ฒ๐Ÿ‡ญ","mq":"๐Ÿ‡ฒ๐Ÿ‡ถ","mr":"๐Ÿ‡ฒ๐Ÿ‡ท","mu":"๐Ÿ‡ฒ๐Ÿ‡บ","yt":"๐Ÿ‡พ๐Ÿ‡น","mx":"๐Ÿ‡ฒ๐Ÿ‡ฝ","fm":"๐Ÿ‡ซ๐Ÿ‡ฒ","md":"๐Ÿ‡ฒ๐Ÿ‡ฉ","mc":"๐Ÿ‡ฒ๐Ÿ‡จ","mn":"๐Ÿ‡ฒ๐Ÿ‡ณ","me":"๐Ÿ‡ฒ๐Ÿ‡ช","ms":"๐Ÿ‡ฒ๐Ÿ‡ธ","ma":"๐Ÿ‡ฒ๐Ÿ‡ฆ","mz":"๐Ÿ‡ฒ๐Ÿ‡ฟ","mm":"๐Ÿ‡ฒ๐Ÿ‡ฒ","na":"๐Ÿ‡ณ๐Ÿ‡ฆ","nr":"๐Ÿ‡ณ๐Ÿ‡ท","np":"๐Ÿ‡ณ๐Ÿ‡ต","nl":"๐Ÿ‡ณ๐Ÿ‡ฑ","nc":"๐Ÿ‡ณ๐Ÿ‡จ","nz":"๐Ÿ‡ณ๐Ÿ‡ฟ","ni":"๐Ÿ‡ณ๐Ÿ‡ฎ","ne":"๐Ÿ‡ณ๐Ÿ‡ช","nigeria":"๐Ÿ‡ณ๐Ÿ‡ฌ","nu":"๐Ÿ‡ณ๐Ÿ‡บ","nf":"๐Ÿ‡ณ๐Ÿ‡ซ","mp":"๐Ÿ‡ฒ๐Ÿ‡ต","kp":"๐Ÿ‡ฐ๐Ÿ‡ต","no":"๐Ÿ‡ณ๐Ÿ‡ด","om":"๐Ÿ‡ด๐Ÿ‡ฒ","pk":"๐Ÿ‡ต๐Ÿ‡ฐ","pw":"๐Ÿ‡ต๐Ÿ‡ผ","ps":"๐Ÿ‡ต๐Ÿ‡ธ","pa":"๐Ÿ‡ต๐Ÿ‡ฆ","pg":"๐Ÿ‡ต๐Ÿ‡ฌ","py":"๐Ÿ‡ต๐Ÿ‡พ","pe":"๐Ÿ‡ต๐Ÿ‡ช","ph":"๐Ÿ‡ต๐Ÿ‡ญ","pn":"๐Ÿ‡ต๐Ÿ‡ณ","pl":"๐Ÿ‡ต๐Ÿ‡ฑ","pt":"๐Ÿ‡ต๐Ÿ‡น","pr":"๐Ÿ‡ต๐Ÿ‡ท","qa":"๐Ÿ‡ถ๐Ÿ‡ฆ","re":"๐Ÿ‡ท๐Ÿ‡ช","ro":"๐Ÿ‡ท๐Ÿ‡ด","ru":"๐Ÿ‡ท๐Ÿ‡บ","rw":"๐Ÿ‡ท๐Ÿ‡ผ","bl":"๐Ÿ‡ง๐Ÿ‡ฑ","sh":"๐Ÿ‡ธ๐Ÿ‡ญ","kn":"๐Ÿ‡ฐ๐Ÿ‡ณ","lc":"๐Ÿ‡ฑ๐Ÿ‡จ","pm":"๐Ÿ‡ต๐Ÿ‡ฒ","vc":"๐Ÿ‡ป๐Ÿ‡จ","ws":"๐Ÿ‡ผ๐Ÿ‡ธ","sm":"๐Ÿ‡ธ๐Ÿ‡ฒ","st":"๐Ÿ‡ธ๐Ÿ‡น","saudi_arabia":"๐Ÿ‡ธ๐Ÿ‡ฆ","sn":"๐Ÿ‡ธ๐Ÿ‡ณ","rs":"๐Ÿ‡ท๐Ÿ‡ธ","sc":"๐Ÿ‡ธ๐Ÿ‡จ","sl":"๐Ÿ‡ธ๐Ÿ‡ฑ","sg":"๐Ÿ‡ธ๐Ÿ‡ฌ","sx":"๐Ÿ‡ธ๐Ÿ‡ฝ","sk":"๐Ÿ‡ธ๐Ÿ‡ฐ","si":"๐Ÿ‡ธ๐Ÿ‡ฎ","sb":"๐Ÿ‡ธ๐Ÿ‡ง","so":"๐Ÿ‡ธ๐Ÿ‡ด","za":"๐Ÿ‡ฟ๐Ÿ‡ฆ","gs":"๐Ÿ‡ฌ๐Ÿ‡ธ","kr":"๐Ÿ‡ฐ๐Ÿ‡ท","ss":"๐Ÿ‡ธ๐Ÿ‡ธ","es":"๐Ÿ‡ช๐Ÿ‡ธ","lk":"๐Ÿ‡ฑ๐Ÿ‡ฐ","sd":"๐Ÿ‡ธ๐Ÿ‡ฉ","sr":"๐Ÿ‡ธ๐Ÿ‡ท","sz":"๐Ÿ‡ธ๐Ÿ‡ฟ","se":"๐Ÿ‡ธ๐Ÿ‡ช","ch":"๐Ÿ‡จ๐Ÿ‡ญ","sy":"๐Ÿ‡ธ๐Ÿ‡พ","tw":"๐Ÿ‡น๐Ÿ‡ผ","tj":"๐Ÿ‡น๐Ÿ‡ฏ","tz":"๐Ÿ‡น๐Ÿ‡ฟ","th":"๐Ÿ‡น๐Ÿ‡ญ","tl":"๐Ÿ‡น๐Ÿ‡ฑ","tg":"๐Ÿ‡น๐Ÿ‡ฌ","tk":"๐Ÿ‡น๐Ÿ‡ฐ","to":"๐Ÿ‡น๐Ÿ‡ด","tt":"๐Ÿ‡น๐Ÿ‡น","tn":"๐Ÿ‡น๐Ÿ‡ณ","tr":"๐Ÿ‡น๐Ÿ‡ท","turkmenistan":"๐Ÿ‡น๐Ÿ‡ฒ","tc":"๐Ÿ‡น๐Ÿ‡จ","tuvalu":"๐Ÿ‡น๐Ÿ‡ป","ug":"๐Ÿ‡บ๐Ÿ‡ฌ","ua":"๐Ÿ‡บ๐Ÿ‡ฆ","ae":"๐Ÿ‡ฆ๐Ÿ‡ช","gb":"๐Ÿ‡ฌ๐Ÿ‡ง","us":"๐Ÿ‡บ๐Ÿ‡ธ","vi":"๐Ÿ‡ป๐Ÿ‡ฎ","uy":"๐Ÿ‡บ๐Ÿ‡พ","uz":"๐Ÿ‡บ๐Ÿ‡ฟ","vu":"๐Ÿ‡ป๐Ÿ‡บ","va":"๐Ÿ‡ป๐Ÿ‡ฆ","ve":"๐Ÿ‡ป๐Ÿ‡ช","vn":"๐Ÿ‡ป๐Ÿ‡ณ","wf":"๐Ÿ‡ผ๐Ÿ‡ซ","eh":"๐Ÿ‡ช๐Ÿ‡ญ","ye":"๐Ÿ‡พ๐Ÿ‡ช","zm":"๐Ÿ‡ฟ๐Ÿ‡ฒ","zw":"๐Ÿ‡ฟ๐Ÿ‡ผ"} \ No newline at end of file diff --git a/frontend/utils/emojify.js b/frontend/utils/emojify.js deleted file mode 100644 index eeee3e1f..00000000 --- a/frontend/utils/emojify.js +++ /dev/null @@ -1,16 +0,0 @@ -// @flow -import emojiMapping from './emoji-mapping.json'; - -const EMOJI_REGEX = /:([A-Za-z0-9_\-+]+?):/gm; - -const emojify = (text: string = '') => { - let emojifiedText = text; - - emojifiedText = text.replace(EMOJI_REGEX, (match, p1, offset, string) => { - return emojiMapping[p1] || match; - }); - - return emojifiedText; -}; - -export default emojify; diff --git a/frontend/utils/markdown.js b/frontend/utils/markdown.js deleted file mode 100644 index e5511c66..00000000 --- a/frontend/utils/markdown.js +++ /dev/null @@ -1,53 +0,0 @@ -// @flow -import slug from 'slug'; -import marked from 'marked'; -import sanitizedRenderer from 'marked-sanitized'; -import highlight from 'highlight.js'; -import _ from 'lodash'; -import emojify from './emojify'; -import toc from './toc'; - -// $FlowIssue invalid flow-typed -slug.defaults.mode = 'rfc3986'; - -const Renderer = sanitizedRenderer(marked.Renderer); -const renderer = new Renderer(); -renderer.code = (code, language) => { - const validLang = !!(language && highlight.getLanguage(language)); - const highlighted = validLang - ? highlight.highlight(language, code).value - : _.escape(code); - return `
${highlighted}
`; -}; -renderer.heading = (text, level) => { - const headingSlug = _.escape(slug(text)); - return ` - - ${text} - # - - `; -}; - -const convertToMarkdown = (text: string) => { - // Add TOC - text = toc.insert(text || '', { - slugify: heading => { - // FIXME: E.g. `&` gets messed up - const headingSlug = _.escape(slug(heading)); - return headingSlug; - }, - }); - - return marked.parse(emojify(text), { - renderer, - gfm: true, - tables: true, - breaks: false, - pedantic: false, - smartLists: true, - smartypants: true, - }); -}; - -export { convertToMarkdown }; diff --git a/frontend/utils/toc/index.js b/frontend/utils/toc/index.js deleted file mode 100644 index 8eaa5499..00000000 --- a/frontend/utils/toc/index.js +++ /dev/null @@ -1,148 +0,0 @@ -// @flow -/* eslint-disable */ - -/** - * marked-toc - * - * Copyright (c) 2014 Jon Schlinkert, contributors. - * Licensed under the MIT license. - */ - -'use strict'; - -var marked = require('marked'); -var _ = require('lodash'); -var utils = require('./utils'); - -/** - * Expose `toc` - */ - -module.exports = toc; - -/** - * Default template to use for generating - * a table of contents. - */ - -var defaultTemplate = - '<%= depth %><%= bullet %>[<%= heading %>](#<%= url %>)\n'; - -/** - * Create the table of contents object that - * will be used as context for the template. - * - * @param {String} `str` - * @param {Object} `options` - * @return {Object} - */ - -function generate(str, options) { - var opts = _.extend( - { - firsth1: false, - blacklist: true, - omit: [], - maxDepth: 3, - slugify: function(text) { - return text; // Override this! - }, - }, - options - ); - - var toc = ''; - // $FlowIssue invalid flow-typed - var tokens = marked.lexer(str); - var tocArray = []; - - // Remove the very first h1, true by default - if (opts.firsth1 === false) { - tokens.shift(); - } - - // Do any h1's still exist? - var h1 = _.some(tokens, { depth: 1 }); - - tokens - .filter(function(token) { - // Filter out everything but headings - if (token.type !== 'heading' || token.type === 'code') { - return false; - } - - // Since we removed the first h1, we'll check to see if other h1's - // exist. If none exist, then we unindent the rest of the TOC - if (!h1) { - token.depth = token.depth - 1; - } - - // Store original text and create an id for linking - token.heading = opts.strip ? utils.strip(token.text, opts) : token.text; - - // Create a "slugified" id for linking - token.id = opts.slugify(token.text); - - // Omit headings with these strings - var omissions = ['Table of Contents', 'TOC', 'TABLE OF CONTENTS']; - var omit = _.union([], opts.omit, omissions); - - if (utils.isMatch(omit, token.heading)) { - return; - } - - return true; - }) - .forEach(function(h) { - if (h.depth > opts.maxDepth) { - return; - } - - var bullet = Array.isArray(opts.bullet) - ? opts.bullet[(h.depth - 1) % opts.bullet.length] - : opts.bullet; - - var data = _.extend({}, opts.data, { - depth: new Array((h.depth - 1) * 2 + 1).join(' '), - bullet: bullet ? bullet : '* ', - heading: h.heading, - url: h.id, - }); - - tocArray.push(data); - toc += _.template(opts.template || defaultTemplate)(data); - }); - - return { - data: tocArray, - toc: opts.strip ? utils.strip(toc, opts) : toc, - }; -} - -/** - * toc - */ - -function toc(str: string, options: Object) { - return generate(str, options).toc; -} - -toc.raw = function(str, options) { - return generate(str, options); -}; - -toc.insert = function(content, options) { - var start = ''; - var stop = ''; - var re = /([\s\S]+?)/; - - // remove the existing TOC - content = content.replace(re, start); - - // generate new TOC - var newtoc = - '\n\n' + start + '\n\n' + toc(content, options) + '\n' + stop + '\n'; - - // If front-matter existed, put it back - return content.replace(start, newtoc); -}; diff --git a/frontend/utils/toc/utils.js b/frontend/utils/toc/utils.js deleted file mode 100644 index e1cd9177..00000000 --- a/frontend/utils/toc/utils.js +++ /dev/null @@ -1,83 +0,0 @@ -/* eslint-disable */ - -/*! - * marked-toc - * - * Copyright (c) 2014 Jon Schlinkert, contributors. - * Licensed under the MIT license. - */ - -'use strict'; - -var _ = require('lodash'); -var utils = (module.exports = {}); - -utils.arrayify = function(arr) { - return !Array.isArray(arr) ? [arr] : arr; -}; - -utils.escapeRegex = function(re) { - return re.replace(/(\[|\]|\(|\)|\/|\.|\^|\$|\*|\+|\?)/g, '\\$1'); -}; - -utils.isDest = function(dest) { - return !dest || dest === 'undefined' || typeof dest === 'object'; -}; - -utils.isMatch = function(keys, str) { - keys = utils.arrayify(keys); - keys = keys.length > 0 ? keys.join('|') : '.*'; - - // Escape certain characters, like '[', '(' - var k = utils.escapeRegex(String(keys)); - - // Build up the regex to use for replacement patterns - var re = new RegExp('(?:' + k + ')', 'g'); - if (String(str).match(re)) { - return true; - } else { - return false; - } -}; - -utils.sanitize = function(src) { - src = src.replace(/(\s*\[!|(?:\[.+ โ†’\]\()).+/g, ''); - src = src.replace(/\s*\*\s*\[\].+/g, ''); - return src; -}; - -utils.slugify = function(str) { - str = str.replace(/\/\//g, '-'); - str = str.replace(/\//g, '-'); - str = str.replace(/\./g, '-'); - str = _.str.slugify(str); - str = str.replace(/^-/, ''); - str = str.replace(/-$/, ''); - return str; -}; - -/** - * Strip certain words from headings. These can be - * overridden. Might seem strange but it makes - * sense in context. - */ - -var omit = [ - 'grunt', - 'helper', - 'handlebars-helper', - 'mixin', - 'filter', - 'assemble-contrib', - 'assemble', -]; - -utils.strip = function(name, options) { - var opts = _.extend({}, options); - if (opts.omit === false) { - omit = []; - } - var exclusions = _.union(omit, utils.arrayify(opts.strip || [])); - var re = new RegExp('^(?:' + exclusions.join('|') + ')[-_]?', 'g'); - return name.replace(re, ''); -}; diff --git a/package.json b/package.json index e391dc57..dc878af4 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ }, "dependencies": { "@tommoor/slate-drop-or-paste-images": "0.5.1", + "aws-sdk": "^2.135.0", "babel-core": "^6.24.1", "babel-eslint": "^7.2.3", "babel-loader": "6.2.5", @@ -85,7 +86,6 @@ "css-loader": "^0.28.7", "debug": "2.2.0", "dotenv": "^4.0.0", - "emoji-name-map": "1.1.2", "emoji-regex": "^6.5.1", "eslint": "^3.19.0", "eslint-config-react-app": "^0.6.2", @@ -100,7 +100,6 @@ "fbemitter": "^2.1.1", "file-loader": "0.9.0", "flow-typed": "^2.1.2", - "highlight.js": "9.4.0", "history": "3.0.0", "html-webpack-plugin": "2.17.0", "http-errors": "1.4.0", @@ -109,7 +108,6 @@ "isomorphic-fetch": "2.2.1", "js-cookie": "^2.1.4", "js-search": "^1.4.2", - "js-tree": "1.1.0", "json-loader": "0.5.4", "jsonwebtoken": "7.0.1", "koa": "^2.2.0", @@ -126,8 +124,6 @@ "localforage": "^1.5.0", "lodash": "^4.17.4", "lodash.orderby": "4.4.0", - "marked": "0.3.6", - "marked-sanitized": "^0.1.1", "mobx": "^3.1.9", "mobx-react": "^4.1.8", "mobx-react-devtools": "^4.2.11", @@ -146,7 +142,6 @@ "react-addons-css-transition-group": "15.3.2", "react-dom": "^15.6.1", "react-dropzone": "3.6.0", - "react-feather": "^1.0.7", "react-helmet": "3.1.0", "react-keydown": "^1.7.3", "react-modal": "^2.2.1", @@ -171,7 +166,6 @@ "string-hash": "^1.1.0", "style-loader": "^0.18.2", "styled-components": "^2.0.0", - "truncate-html": "https://github.com/jorilallo/truncate-html/tarball/master", "url-loader": "0.5.7", "uuid": "2.0.2", "validator": "5.2.0", diff --git a/scripts/update_emoji_mapping.js b/scripts/update_emoji_mapping.js deleted file mode 100644 index 958763e4..00000000 --- a/scripts/update_emoji_mapping.js +++ /dev/null @@ -1,8 +0,0 @@ -var fs = require('fs'); -var path = require('path'); -var mapping = require('emoji-name-map'); - -fs.writeFile( - path.join(__dirname, '../frontend/utils/emoji-mapping.json'), - JSON.stringify(mapping.emoji) -); diff --git a/server/api/auth.js b/server/api/auth.js index e41890d5..8be7d026 100644 --- a/server/api/auth.js +++ b/server/api/auth.js @@ -55,6 +55,10 @@ router.post('auth.slack', async ctx => { expires: new Date('2100'), }); + // Update user's avatar + await user.updateAvatar(); + await user.save(); + ctx.body = { data: { user: await presentUser(ctx, user), diff --git a/server/api/documents.js b/server/api/documents.js index b21b7da8..5e098ca8 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -4,10 +4,16 @@ import httpErrors from 'http-errors'; import auth from './middlewares/authentication'; import pagination from './middlewares/pagination'; -import { presentDocument } from '../presenters'; -import { Document, Collection, Star, View } from '../models'; +import { presentDocument, presentRevision } from '../presenters'; +import { Document, Collection, Star, View, Revision } from '../models'; + +const authDocumentForUser = (ctx, document) => { + const user = ctx.state.user; + if (!document || document.teamId !== user.teamId) throw httpErrors.NotFound(); +}; const router = new Router(); + router.post('documents.list', auth(), pagination(), async ctx => { let { sort = 'updatedAt', direction } = ctx.body; if (direction !== 'ASC') direction = 'DESC'; @@ -101,23 +107,38 @@ router.post('documents.info', auth(), async ctx => { ctx.assertPresent(id, 'id is required'); const document = await Document.findById(id); - if (!document) throw httpErrors.NotFound(); - - // Don't expose private documents outside the team - if (document.private) { - if (!ctx.state.user) throw httpErrors.NotFound(); - - const user = await ctx.state.user; - if (document.teamId !== user.teamId) { - throw httpErrors.NotFound(); - } - } + authDocumentForUser(ctx, document); ctx.body = { data: await presentDocument(ctx, document), }; }); +router.post('documents.revisions', auth(), pagination(), async ctx => { + let { id, sort = 'updatedAt', direction } = ctx.body; + if (direction !== 'ASC') direction = 'DESC'; + ctx.assertPresent(id, 'id is required'); + const document = await Document.findById(id); + + authDocumentForUser(ctx, document); + + const revisions = await Revision.findAll({ + where: { documentId: id }, + order: [[sort, direction]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + const data = await Promise.all( + revisions.map(revision => presentRevision(ctx, revision)) + ); + + ctx.body = { + pagination: ctx.state.pagination, + data, + }; +}); + router.post('documents.search', auth(), async ctx => { const { query } = ctx.body; ctx.assertPresent(query, 'query is required'); @@ -142,8 +163,7 @@ router.post('documents.star', auth(), async ctx => { const user = await ctx.state.user; const document = await Document.findById(id); - if (!document || document.teamId !== user.teamId) - throw httpErrors.BadRequest(); + authDocumentForUser(ctx, document); await Star.findOrCreate({ where: { documentId: document.id, userId: user.id }, @@ -156,8 +176,7 @@ router.post('documents.unstar', auth(), async ctx => { const user = await ctx.state.user; const document = await Document.findById(id); - if (!document || document.teamId !== user.teamId) - throw httpErrors.BadRequest(); + authDocumentForUser(ctx, document); await Star.destroy({ where: { documentId: document.id, userId: user.id }, @@ -228,7 +247,7 @@ router.post('documents.update', auth(), async ctx => { const document = await Document.findById(id); const collection = document.collection; - if (!document || document.teamId !== user.teamId) throw httpErrors.NotFound(); + authDocumentForUser(ctx, document); // Update document if (title) document.title = title; @@ -254,15 +273,14 @@ router.post('documents.move', auth(), async ctx => { ctx.assertUuid(parentDocument, 'parentDocument must be an uuid'); if (index) ctx.assertPositiveInteger(index, 'index must be an integer (>=0)'); - const user = ctx.state.user; const document = await Document.findById(id); const collection = await Collection.findById(document.atlasId); + authDocumentForUser(ctx, document); + if (collection.type !== 'atlas') throw httpErrors.BadRequest("This document can't be moved"); - if (!document || document.teamId !== user.teamId) throw httpErrors.NotFound(); - // Set parent document if (parentDocument) { const parent = await Document.findById(parentDocument); @@ -292,12 +310,10 @@ router.post('documents.delete', auth(), async ctx => { const { id } = ctx.body; ctx.assertPresent(id, 'id is required'); - const user = ctx.state.user; const document = await Document.findById(id); const collection = await Collection.findById(document.atlasId); - if (!document || document.teamId !== user.teamId) - throw httpErrors.BadRequest(); + authDocumentForUser(ctx, document); if (collection.type === 'atlas') { // Don't allow deletion of root docs diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 8f1b7c58..7d2bc37d 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -43,6 +43,24 @@ describe('#documents.list', async () => { }); }); +describe('#documents.revision', async () => { + it("should return document's revisions", async () => { + const { user, document } = await seed(); + const res = await server.post('/api/documents.revisions', { + body: { + token: user.getJwtToken(), + id: document.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).not.toEqual(document.id); + expect(body.data[0].title).toEqual(document.title); + }); +}); + describe('#documents.search', async () => { it('should return results', async () => { const { user } = await seed(); diff --git a/server/index.js b/server/index.js index ae6317b7..10c7bb92 100644 --- a/server/index.js +++ b/server/index.js @@ -65,8 +65,8 @@ if (process.env.NODE_ENV === 'development') { app.use(logger()); } -if (process.env.NODE_ENV === 'production') { - bugsnag.register('ad7a85f99b1b9324a31e16732cdf3192'); +if (process.env.NODE_ENV === 'production' && process.env.BUGSNAG_KEY) { + bugsnag.register(process.env.BUGSNAG_KEY); app.on('error', bugsnag.koaHandler); } diff --git a/server/migrations/20171016012353-remove-collection-navigationtree.js b/server/migrations/20171016012353-remove-collection-navigationtree.js new file mode 100644 index 00000000..0ba1eb4c --- /dev/null +++ b/server/migrations/20171016012353-remove-collection-navigationtree.js @@ -0,0 +1,12 @@ +module.exports = { + up: function(queryInterface, Sequelize) { + queryInterface.removeColumn('collections', 'navigationTree'); + }, + + down: function(queryInterface, Sequelize) { + queryInterface.addColumn('collections', 'navigationTree', { + type: Sequelize.JSONB, + allowNull: true, + }); + }, +}; diff --git a/server/migrations/20171017055026-remove-document-html.js b/server/migrations/20171017055026-remove-document-html.js new file mode 100644 index 00000000..b9cdd821 --- /dev/null +++ b/server/migrations/20171017055026-remove-document-html.js @@ -0,0 +1,23 @@ +module.exports = { + up: function(queryInterface, Sequelize) { + queryInterface.removeColumn('documents', 'html'); + queryInterface.removeColumn('documents', 'preview'); + queryInterface.removeColumn('revisions', 'html'); + queryInterface.removeColumn('revisions', 'preview'); + }, + + down: function(queryInterface, Sequelize) { + queryInterface.addColumn('documents', 'html', { + type: Sequelize.TEXT, + }); + queryInterface.addColumn('documents', 'preview', { + type: Sequelize.TEXT, + }); + queryInterface.addColumn('revisions', 'html', { + type: Sequelize.TEXT, + }); + queryInterface.addColumn('revisions', 'preview', { + type: Sequelize.TEXT, + }); + }, +}; diff --git a/server/migrations/20171019071915-user-avatar-url.js b/server/migrations/20171019071915-user-avatar-url.js new file mode 100644 index 00000000..5be75d52 --- /dev/null +++ b/server/migrations/20171019071915-user-avatar-url.js @@ -0,0 +1,12 @@ +module.exports = { + up: function(queryInterface, Sequelize) { + queryInterface.addColumn('users', 'avatarUrl', { + type: Sequelize.TEXT, + allowNull: true, + }); + }, + + down: function(queryInterface, Sequelize) { + queryInterface.removeColumn('users', 'avatarUrl'); + }, +}; diff --git a/server/models/Collection.js b/server/models/Collection.js index 1ca29075..a05f0289 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -29,7 +29,6 @@ const Collection = sequelize.define( creatorId: DataTypes.UUID, /* type: atlas */ - navigationTree: DataTypes.JSONB, // legacy documentStructure: DataTypes.JSONB, }, { @@ -98,28 +97,6 @@ Collection.prototype.getUrl = function() { return `/collections/${this.id}`; }; -Collection.prototype.getDocumentsStructure = async function() { - // Lazy fill this.documentStructure - TMP for internal release - if (!this.documentStructure) { - this.documentStructure = this.navigationTree.children; - - // Remove parent references from all root documents - await this.navigationTree.children.forEach(async ({ id }) => { - const document = await Document.findById(id); - document.parentDocumentId = null; - await document.save(); - }); - - // Remove root document - const rootDocument = await Document.findById(this.navigationTree.id); - await rootDocument.destroy(); - - await this.save(); - } - - return this.documentStructure; -}; - Collection.prototype.addDocumentToStructure = async function( document, index, diff --git a/server/models/Document.js b/server/models/Document.js index 7ee9e425..95e3b146 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -2,12 +2,9 @@ import slug from 'slug'; import _ from 'lodash'; import randomstring from 'randomstring'; -import emojiRegex from 'emoji-regex'; import isUUID from 'validator/lib/isUUID'; import { DataTypes, sequelize } from '../sequelize'; -import { convertToMarkdown } from '../../frontend/utils/markdown'; -import { truncateMarkdown } from '../utils/truncate'; import parseTitle from '../../shared/parseTitle'; import Revision from './Revision'; @@ -25,8 +22,6 @@ const createRevision = doc => { return Revision.create({ title: doc.title, text: doc.text, - html: doc.html, - preview: doc.preview, userId: doc.lastModifiedById, documentId: doc.id, }); @@ -40,8 +35,6 @@ const beforeSave = async doc => { const { emoji } = parseTitle(doc.text); doc.emoji = emoji; - doc.html = convertToMarkdown(doc.text); - doc.preview = truncateMarkdown(doc.text, 160); doc.revisionCount += 1; // Collaborators @@ -74,8 +67,6 @@ const Document = sequelize.define( private: { type: DataTypes.BOOLEAN, defaultValue: true }, title: DataTypes.STRING, text: DataTypes.TEXT, - html: DataTypes.TEXT, - preview: DataTypes.TEXT, revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 }, parentDocumentId: DataTypes.UUID, createdById: { diff --git a/server/models/Revision.js b/server/models/Revision.js index d69892ea..f5dcc846 100644 --- a/server/models/Revision.js +++ b/server/models/Revision.js @@ -9,8 +9,6 @@ const Revision = sequelize.define('revision', { }, title: DataTypes.STRING, text: DataTypes.TEXT, - html: DataTypes.TEXT, - preview: DataTypes.TEXT, userId: { type: 'UUID', diff --git a/server/models/User.js b/server/models/User.js index 69158780..94b0affc 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -1,7 +1,9 @@ // @flow import crypto from 'crypto'; import bcrypt from 'bcrypt'; +import uuid from 'uuid'; import { DataTypes, sequelize, encryptedFields } from '../sequelize'; +import { uploadToS3FromUrl } from '../utils/s3'; import JWT from 'jsonwebtoken'; @@ -18,6 +20,7 @@ const User = sequelize.define( email: { type: DataTypes.STRING }, username: { type: DataTypes.STRING }, name: DataTypes.STRING, + avatarUrl: { type: DataTypes.STRING, allowNull: true }, password: DataTypes.VIRTUAL, passwordDigest: DataTypes.STRING, isAdmin: DataTypes.BOOLEAN, @@ -66,6 +69,12 @@ User.prototype.verifyPassword = function(password) { }); }); }; +User.prototype.updateAvatar = async function() { + this.avatarUrl = await uploadToS3FromUrl( + this.slackData.image_192, + `avatars/${this.id}/${uuid.v4()}` + ); +}; const setRandomJwtSecret = model => { model.jwtSecret = crypto.randomBytes(64).toString('hex'); diff --git a/server/presenters/collection.js b/server/presenters/collection.js index a6fdbc8e..aeae3dce 100644 --- a/server/presenters/collection.js +++ b/server/presenters/collection.js @@ -19,7 +19,7 @@ async function present(ctx: Object, collection: Collection) { }; if (collection.type === 'atlas') { - data.documents = await collection.getDocumentsStructure(); + data.documents = collection.documentStructure; } if (collection.documents) { diff --git a/server/presenters/document.js b/server/presenters/document.js index 5927a83d..bf483cd1 100644 --- a/server/presenters/document.js +++ b/server/presenters/document.js @@ -21,8 +21,6 @@ async function present(ctx: Object, document: Document, options: ?Options) { private: document.private, title: document.title, text: document.text, - html: document.html, - preview: document.preview, emoji: document.emoji, createdAt: document.createdAt, createdBy: presentUser(ctx, document.createdBy), diff --git a/server/presenters/index.js b/server/presenters/index.js index 22e34f25..8eab4228 100644 --- a/server/presenters/index.js +++ b/server/presenters/index.js @@ -2,6 +2,7 @@ import presentUser from './user'; import presentView from './view'; import presentDocument from './document'; +import presentRevision from './revision'; import presentCollection from './collection'; import presentApiKey from './apiKey'; import presentTeam from './team'; @@ -10,6 +11,7 @@ export { presentUser, presentView, presentDocument, + presentRevision, presentCollection, presentApiKey, presentTeam, diff --git a/server/presenters/revision.js b/server/presenters/revision.js new file mode 100644 index 00000000..ef137767 --- /dev/null +++ b/server/presenters/revision.js @@ -0,0 +1,15 @@ +// @flow +import _ from 'lodash'; +import { Revision } from '../models'; + +function present(ctx: Object, revision: Revision) { + return { + id: revision.id, + title: revision.title, + text: revision.text, + createdAt: revision.createdAt, + updatedAt: revision.updatedAt, + }; +} + +export default present; diff --git a/server/presenters/user.js b/server/presenters/user.js index f545cd66..1d064ace 100644 --- a/server/presenters/user.js +++ b/server/presenters/user.js @@ -8,7 +8,8 @@ function present(ctx: Object, user: User) { id: user.id, username: user.username, name: user.name, - avatarUrl: user.slackData ? user.slackData.image_192 : null, + avatarUrl: user.avatarUrl || + (user.slackData ? user.slackData.image_192 : null), }; } diff --git a/server/slack.js b/server/slack.js index 62bb75eb..82d1a07f 100644 --- a/server/slack.js +++ b/server/slack.js @@ -15,7 +15,6 @@ export async function request(endpoint: string, body: Object) { } catch (e) { throw httpErrors.BadRequest(); } - console.log('DATA', data); if (!data.ok) throw httpErrors.BadRequest(data.error); return data; @@ -28,7 +27,7 @@ export async function oauthAccess( return request('oauth.access', { client_id: process.env.SLACK_KEY, client_secret: process.env.SLACK_SECRET, - redirect_uri: `${process.env.URL || ''}/auth/slack`, + redirect_uri, code, }); } diff --git a/server/static/index.html b/server/static/index.html index e79463d5..3613c5c0 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -26,6 +26,6 @@
- + - + \ No newline at end of file diff --git a/server/utils/s3.js b/server/utils/s3.js index 65351a98..14072b5a 100644 --- a/server/utils/s3.js +++ b/server/utils/s3.js @@ -1,5 +1,18 @@ +// @flow import crypto from 'crypto'; import moment from 'moment'; +import AWS from 'aws-sdk'; +import invariant from 'invariant'; +import fetch from 'isomorphic-fetch'; +import bugsnag from 'bugsnag'; + +AWS.config.update({ + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, +}); + +const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY; +const AWS_S3_UPLOAD_BUCKET_NAME = process.env.AWS_S3_UPLOAD_BUCKET_NAME; const makePolicy = () => { const policy = { @@ -19,13 +32,37 @@ const makePolicy = () => { return new Buffer(JSON.stringify(policy)).toString('base64'); }; -const signPolicy = policy => { +const signPolicy = (policy: any) => { + invariant(AWS_SECRET_ACCESS_KEY, 'AWS_SECRET_ACCESS_KEY not set'); const signature = crypto - .createHmac('sha1', process.env.AWS_SECRET_ACCESS_KEY) + .createHmac('sha1', AWS_SECRET_ACCESS_KEY) .update(policy) .digest('base64'); return signature; }; -export { makePolicy, signPolicy }; +const uploadToS3FromUrl = async (url: string, key: string) => { + const s3 = new AWS.S3(); + invariant(AWS_S3_UPLOAD_BUCKET_NAME, 'AWS_S3_UPLOAD_BUCKET_NAME not set'); + + try { + // $FlowIssue dunno it's fine + const res = await fetch(url); + const buffer = await res.buffer(); + await s3 + .putObject({ + Bucket: process.env.AWS_S3_UPLOAD_BUCKET_NAME, + Key: key, + ContentType: res.headers['content-type'], + ContentLength: res.headers['content-length'], + Body: buffer, + }) + .promise(); + return `https://s3.amazonaws.com/${AWS_S3_UPLOAD_BUCKET_NAME}/${key}`; + } catch (e) { + bugsnag.notify(e); + } +}; + +export { makePolicy, signPolicy, uploadToS3FromUrl }; diff --git a/server/utils/truncate.js b/server/utils/truncate.js deleted file mode 100644 index 85664daa..00000000 --- a/server/utils/truncate.js +++ /dev/null @@ -1,16 +0,0 @@ -import truncate from 'truncate-html'; -import { convertToMarkdown } from '../../frontend/utils/markdown'; - -truncate.defaultOptions = { - stripTags: false, - ellipsis: '...', - decodeEntities: false, - excludes: ['h1', 'pre'], -}; - -const truncateMarkdown = (text, length) => { - const html = convertToMarkdown(text); - return truncate(html, length); -}; - -export { truncateMarkdown }; diff --git a/webpack.config.js b/webpack.config.js index f99b890c..5e33b37a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -13,6 +13,7 @@ const definePlugin = new webpack.DefinePlugin({ SLACK_REDIRECT_URI: JSON.stringify(process.env.SLACK_REDIRECT_URI), SLACK_KEY: JSON.stringify(process.env.SLACK_KEY), BASE_URL: JSON.stringify(process.env.URL), + BUGSNAG_KEY: JSON.stringify(process.env.BUGSNAG_KEY), DEPLOYMENT: JSON.stringify(process.env.DEPLOYMENT || 'hosted'), }); diff --git a/yarn.lock b/yarn.lock index 294344d8..3baf998b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -339,6 +339,21 @@ autoprefixer@^6.3.1: postcss "^5.2.16" postcss-value-parser "^3.2.3" +aws-sdk@^2.135.0: + version "2.135.0" + resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.135.0.tgz#81f4a47b99212f2f236bf5b11b0b3a3a02086db4" + dependencies: + buffer "4.9.1" + crypto-browserify "1.0.9" + events "^1.1.1" + jmespath "0.15.0" + querystring "0.2.0" + sax "1.2.1" + url "0.10.3" + uuid "3.1.0" + xml2js "0.4.17" + xmlbuilder "4.2.1" + aws-sign2@~0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f" @@ -1264,7 +1279,7 @@ buffer-xor@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" -buffer@^4.3.0, buffer@^4.9.0: +buffer@4.9.1, buffer@^4.3.0, buffer@^4.9.0: version "4.9.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298" dependencies: @@ -1450,7 +1465,7 @@ charenc@~0.0.1: version "0.0.2" resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" -cheerio@0.22.0, cheerio@^0.22.0: +cheerio@^0.22.0: version "0.22.0" resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-0.22.0.tgz#a9baa860a3f9b595a6b81b1a86873121ed3a269e" dependencies: @@ -1955,6 +1970,10 @@ cryptiles@2.x.x: dependencies: boom "2.x.x" +crypto-browserify@1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-1.0.9.tgz#cc5449685dfb85eb11c9828acc7cb87ab5bbfcc0" + crypto-browserify@^3.11.0: version "3.11.1" resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.1.tgz#948945efc6757a400d6e5e5af47194d10064279f" @@ -2492,22 +2511,10 @@ elliptic@^6.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.0" -emoji-name-map@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/emoji-name-map/-/emoji-name-map-1.1.2.tgz#662f6b5582b0eaf817be6a9ac272fbd8af10ae73" - dependencies: - emojilib "^2.0.2" - iterate-object "^1.3.1" - map-o "^2.0.1" - emoji-regex@^6.1.0, emoji-regex@^6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.5.1.tgz#9baea929b155565c11ea41c6626eaa65cef992c2" -emojilib@^2.0.2: - version "2.2.9" - resolved "https://registry.yarnpkg.com/emojilib/-/emojilib-2.2.9.tgz#ec5722689fc148f56422c14b0dc16a901d446b75" - emojis-list@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" @@ -2931,7 +2938,7 @@ event-stream@~3.3.0: stream-combiner "~0.0.4" through "~2.3.1" -events@^1.0.0: +events@^1.0.0, events@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" @@ -3857,10 +3864,6 @@ hide-powered-by@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/hide-powered-by/-/hide-powered-by-1.0.0.tgz#4a85ad65881f62857fc70af7174a1184dccce32b" -highlight.js@9.4.0: - version "9.4.0" - resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.4.0.tgz#2687d6cf6df0d57bc68585e836bfe3ab3edf9452" - history@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/history/-/history-3.0.0.tgz#02cff4e6f69dc62dd81161104a63f5b85ead0c85" @@ -3961,7 +3964,7 @@ html-webpack-plugin@2.17.0: pretty-error "^2.0.0" toposort "^0.2.12" -htmlparser2@^3.9.0, htmlparser2@^3.9.1: +htmlparser2@^3.9.1: version "3.9.2" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.9.2.tgz#1bdf87acca0f3f9e53fa4fcceb0f4b4cbb00b338" dependencies: @@ -4607,10 +4610,6 @@ isurl@^1.0.0-alpha5: has-to-string-tag-x "^1.2.0" is-object "^1.0.1" -iterate-object@^1.3.0, iterate-object@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/iterate-object/-/iterate-object-1.3.2.tgz#24ec15affa5d0039e8839695a21c2cae1f45b66b" - jest-changed-files@^20.0.3: version "20.0.3" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-20.0.3.tgz#9394d5cc65c438406149bef1bf4d52b68e03e3f8" @@ -4832,6 +4831,10 @@ jest-validate@^20.0.3: leven "^2.1.0" pretty-format "^20.0.3" +jmespath@0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217" + joi@^6.10.1, joi@~6.10.1: version "6.10.1" resolved "https://registry.yarnpkg.com/joi/-/joi-6.10.1.tgz#4d50c318079122000fe5f16af1ff8e1917b77e06" @@ -4870,10 +4873,6 @@ js-tokens@^3.0.0, js-tokens@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" -js-tree@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/js-tree/-/js-tree-1.1.0.tgz#087ee3ec366a5b74eb14f486016c5e0e631f1670" - js-yaml@^3.4.3, js-yaml@^3.5.1, js-yaml@^3.7.0: version "3.9.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.9.1.tgz#08775cebdfdd359209f0d2acd383c8f86a6904a0" @@ -5720,12 +5719,6 @@ map-cache@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" -map-o@^2.0.1: - version "2.0.7" - resolved "https://registry.yarnpkg.com/map-o/-/map-o-2.0.7.tgz#7b59395ee87a5200ec2ef881938e9e257f747d61" - dependencies: - iterate-object "^1.3.0" - map-obj@^1.0.0, map-obj@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" @@ -5734,12 +5727,6 @@ map-stream@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194" -marked-sanitized@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/marked-sanitized/-/marked-sanitized-0.1.1.tgz#8a5756887217f64fe92e1a92e71d0cc10e767829" - dependencies: - sanitize-html "^1.5.2" - marked-terminal@^1.6.2: version "1.7.0" resolved "https://registry.yarnpkg.com/marked-terminal/-/marked-terminal-1.7.0.tgz#c8c460881c772c7604b64367007ee5f77f125904" @@ -5750,7 +5737,7 @@ marked-terminal@^1.6.2: lodash.assign "^4.2.0" node-emoji "^1.4.1" -marked@0.3.6, marked@^0.3.6: +marked@^0.3.6: version "0.3.6" resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.6.tgz#b2c6c618fccece4ef86c4fc6cb8a7cbf5aeda8d7" @@ -7327,10 +7314,6 @@ react-dropzone@3.6.0: dependencies: attr-accept "^1.0.3" -react-feather@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/react-feather/-/react-feather-1.0.7.tgz#f2118f1d2402b0c1e6f23c732f9e7f9fd4ca61e2" - react-helmet@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-3.1.0.tgz#63486194682f33004826f3687dc49a138b557050" @@ -7614,10 +7597,6 @@ regex-cache@^0.4.2: dependencies: is-equal-shallow "^0.1.3" -regexp-quote@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/regexp-quote/-/regexp-quote-0.0.0.tgz#1e0f4650c862dcbfed54fd42b148e9bb1721fcf2" - regexpu-core@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-1.0.0.tgz#86a763f58ee4d7c2f6b102e4764050de7ed90c6b" @@ -7850,15 +7829,11 @@ sane@~1.6.0: walker "~1.0.5" watch "~0.10.0" -sanitize-html@^1.5.2: - version "1.14.1" - resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-1.14.1.tgz#730ffa2249bdf18333effe45b286173c9c5ad0b8" - dependencies: - htmlparser2 "^3.9.0" - regexp-quote "0.0.0" - xtend "^4.0.0" +sax@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" -sax@^1.2.1, sax@~1.2.1: +sax@>=0.6.0, sax@^1.2.1, sax@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" @@ -8088,8 +8063,8 @@ slate-edit-list@^0.7.0: resolved "https://registry.yarnpkg.com/slate-edit-list/-/slate-edit-list-0.7.1.tgz#84ee960d2d5b5a20ce267ad9df894395a91b93d5" slate-markdown-serializer@tommoor/slate-markdown-serializer: - version "0.4.1" - resolved "https://codeload.github.com/tommoor/slate-markdown-serializer/tar.gz/652eaa69fe38ee0047877ca8732d1148347e2ecf" + version "0.5.2" + resolved "https://codeload.github.com/tommoor/slate-markdown-serializer/tar.gz/75708cf421c0a0ac0d7d541b295315cbff0839c0" slate-paste-linkify@^0.2.1: version "0.2.1" @@ -8715,12 +8690,6 @@ trim-right@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" -"truncate-html@https://github.com/jorilallo/truncate-html/tarball/master": - version "0.1.1" - resolved "https://github.com/jorilallo/truncate-html/tarball/master#5856f297610d202045d997965fc8c33be453c2e9" - dependencies: - cheerio "0.22.0" - tryit@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb" @@ -8907,16 +8876,16 @@ url-to-options@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9" -url@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" +url@0.10.3, url@~0.10.1: + version "0.10.3" + resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64" dependencies: punycode "1.3.2" querystring "0.2.0" -url@~0.10.1: - version "0.10.3" - resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64" +url@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" dependencies: punycode "1.3.2" querystring "0.2.0" @@ -8957,14 +8926,14 @@ uuid@2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.2.tgz#48bd5698f0677e3c7901a1c46ef15b1643794726" +uuid@3.1.0, uuid@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" + uuid@^2.0.1, uuid@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" -uuid@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" - v8flags@^2.0.2: version "2.1.1" resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.1.1.tgz#aab1a1fa30d45f88dd321148875ac02c0b55e5b4" @@ -9260,6 +9229,19 @@ xml-name-validator@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-2.0.1.tgz#4d8b8f1eccd3419aa362061becef515e1e559635" +xml2js@0.4.17: + version "0.4.17" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.17.tgz#17be93eaae3f3b779359c795b419705a8817e868" + dependencies: + sax ">=0.6.0" + xmlbuilder "^4.1.0" + +xmlbuilder@4.2.1, xmlbuilder@^4.1.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-4.2.1.tgz#aa58a3041a066f90eaa16c2f5389ff19f3f461a5" + dependencies: + lodash "^4.0.0" + "xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"