diff --git a/.env.sample b/.env.sample index 8a79a2171..50c099999 100644 --- a/.env.sample +++ b/.env.sample @@ -20,6 +20,9 @@ SLACK_SECRET=d2dc414f9953226bad0a356cXXXXYYYY GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +# Comma separated list of domains to be allowed (optional) +# If not set, all Google apps domains are allowed by default +GOOGLE_ALLOWED_DOMAINS= # Third party credentials (optional) SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY diff --git a/Dockerfile b/Dockerfile index 086867eb7..6d7ee9023 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,3 +8,4 @@ COPY . $APP_PATH RUN yarn RUN cp -r /opt/outline/node_modules /opt/node_modules +CMD yarn build && yarn start \ No newline at end of file diff --git a/README.md b/README.md index 3cdacf430..5519af24a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Join the community on Spectrum +

This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com). @@ -106,7 +107,7 @@ yarn test:app Outline is still built and maintained by a small team – we'd love your help to fix bugs and add features! -However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in our [Spectrum community](https://spectrum.chat/outline). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster! +However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in our [Spectrum community](https://spectrum.chat/outline). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster! Take a look at our [roadmap](https://www.getoutline.com/share/3e6cb2b5-d68b-4ad8-8900-062476820311). If you’re looking for ways to get started, here's a list of ways to help us improve Outline: diff --git a/app/components/Actions/Actions.js b/app/components/Actions/Actions.js index 4ef55010d..3ac624b85 100644 --- a/app/components/Actions/Actions.js +++ b/app/components/Actions/Actions.js @@ -2,15 +2,15 @@ import styled from 'styled-components'; import breakpoint from 'styled-components-breakpoint'; import Flex from 'shared/components/Flex'; -import { layout, color } from 'shared/styles/constants'; export const Action = styled(Flex)` justify-content: center; align-items: center; padding: 0 0 0 12px; + font-size: 15px; a { - color: ${color.text}; + color: ${props => props.theme.text}; height: 24px; } `; @@ -19,7 +19,7 @@ export const Separator = styled.div` margin-left: 12px; width: 1px; height: 20px; - background: ${color.slateLight}; + background: ${props => props.theme.slateLight}; `; const Actions = styled(Flex)` @@ -29,7 +29,7 @@ const Actions = styled(Flex)` left: 0; border-radius: 3px; background: rgba(255, 255, 255, 0.9); - padding: 16px; + padding: 12px; -webkit-backdrop-filter: blur(20px); @media print { @@ -38,7 +38,7 @@ const Actions = styled(Flex)` ${breakpoint('tablet')` left: auto; - padding: ${layout.vpadding} ${layout.hpadding} 8px 8px; + padding: 24px; `}; `; diff --git a/app/components/Alert/Alert.js b/app/components/Alert/Alert.js index b99104e7c..eb69a09e1 100644 --- a/app/components/Alert/Alert.js +++ b/app/components/Alert/Alert.js @@ -3,7 +3,6 @@ import * as React from 'react'; import { observer } from 'mobx-react'; import Flex from 'shared/components/Flex'; import styled from 'styled-components'; -import { color } from 'shared/styles/constants'; type Props = { children: React.Node, @@ -31,7 +30,7 @@ const Container = styled(Flex)` font-size: 14px; line-height: 1; - background-color: ${({ type }) => color[type]}; + background-color: ${({ theme, type }) => theme.color[type]}; `; export default Alert; diff --git a/app/components/Avatar/Avatar.js b/app/components/Avatar/Avatar.js index c192e28d1..c0566bee1 100644 --- a/app/components/Avatar/Avatar.js +++ b/app/components/Avatar/Avatar.js @@ -3,7 +3,6 @@ import * as React from 'react'; import styled from 'styled-components'; import { observable } from 'mobx'; import { observer } from 'mobx-react'; -import { color } from 'shared/styles/constants'; import placeholder from './placeholder.png'; type Props = { @@ -38,7 +37,7 @@ const CircleImg = styled.img` width: ${props => props.size}px; height: ${props => props.size}px; border-radius: 50%; - border: 2px solid ${color.white}; + border: 2px solid ${props => props.theme.white}; flex-shrink: 0; `; diff --git a/app/components/Button/Button.js b/app/components/Button/Button.js index 5c89e8f34..1e455e654 100644 --- a/app/components/Button/Button.js +++ b/app/components/Button/Button.js @@ -1,7 +1,6 @@ // @flow import * as React from 'react'; import styled from 'styled-components'; -import { color } from 'shared/styles/constants'; import { darken } from 'polished'; const RealButton = styled.button` @@ -9,8 +8,8 @@ const RealButton = styled.button` margin: 0; padding: 0; border: 0; - background: ${color.primary}; - color: ${color.white}; + background: ${props => props.theme.primary}; + color: ${props => props.theme.white}; border-radius: 4px; font-size: 15px; height: 36px; @@ -24,7 +23,7 @@ const RealButton = styled.button` border: 0; } &:hover { - background: ${darken(0.05, color.primary)}; + background: ${props => darken(0.05, props.theme.primary)}; } svg { @@ -40,30 +39,30 @@ const RealButton = styled.button` ${props => props.light && ` - color: ${color.slate}; + color: ${props.theme.slate}; background: transparent; - border: 1px solid ${color.slate}; + border: 1px solid ${props.theme.slate}; &:hover { background: transparent; - color: ${color.slateDark}; - border: 1px solid ${color.slateDark}; + color: ${props.theme.slateDark}; + border: 1px solid ${props.theme.slateDark}; } `} ${props => props.neutral && ` - background: ${color.slate}; + background: ${props.theme.slate}; &:hover { - background: ${darken(0.05, color.slate)}; + background: ${darken(0.05, props.theme.slate)}; } `} ${props => props.danger && ` - background: ${color.danger}; + background: ${props.theme.danger}; &:hover { - background: ${darken(0.05, color.danger)}; + background: ${darken(0.05, props.theme.danger)}; } `}; `; diff --git a/app/components/ClickablePadding.js b/app/components/ClickablePadding.js new file mode 100644 index 000000000..dde8c6795 --- /dev/null +++ b/app/components/ClickablePadding.js @@ -0,0 +1,10 @@ +// @flow +import styled from 'styled-components'; + +const ClickablePadding = styled.div` + min-height: 50vh; + cursor: ${({ onClick }) => (onClick ? 'text' : 'default')}; + ${({ grow }) => grow && `flex-grow: 1;`}; +`; + +export default ClickablePadding; diff --git a/app/components/Collaborators/Collaborators.js b/app/components/Collaborators/Collaborators.js index 463e08c1a..0fbeaf1c2 100644 --- a/app/components/Collaborators/Collaborators.js +++ b/app/components/Collaborators/Collaborators.js @@ -31,22 +31,21 @@ const Collaborators = ({ document }: Props) => { return ( - - {collaborators.map(user => ( - + {collaborators.map(user => ( + 1 ? user.name : tooltip} + placement="bottom" + key={user.id} + > + - ))} - + + ))} ); }; -const StyledTooltip = styled(Tooltip)` - display: flex; - flex-direction: row-reverse; -`; - const AvatarWrapper = styled.div` width: 24px; height: 24px; diff --git a/app/components/ColorPicker/ColorPicker.js b/app/components/ColorPicker/ColorPicker.js index eda7ed52b..6b4f080c9 100644 --- a/app/components/ColorPicker/ColorPicker.js +++ b/app/components/ColorPicker/ColorPicker.js @@ -5,7 +5,6 @@ import { observer } from 'mobx-react'; import styled from 'styled-components'; import Flex from 'shared/components/Flex'; import { LabelText, Outline } from 'components/Input'; -import { color, fonts, fontWeight } from 'shared/styles/constants'; import { validateColorHex } from 'shared/utils/color'; const colors = [ @@ -165,7 +164,7 @@ const StyledOutline = styled(Outline)` const HexHash = styled.div` margin-left: 12px; padding-bottom: 0; - font-weight: ${fontWeight.medium}; + font-weight: 500; user-select: none; `; @@ -177,13 +176,13 @@ const CustomColorInput = styled.input` padding-bottom: 0; outline: none; background: none; - font-family: ${fonts.monospace}; - font-weight: ${fontWeight.medium}; + font-family: ${props => props.theme.monospaceFontFamily}; + font-weight: 500; &::placeholder { - color: ${color.slate}; - font-family: ${fonts.monospace}; - font-weight: ${fontWeight.medium}; + color: ${props => props.theme.slate}; + font-family: ${props => props.theme.monospaceFontFamily}; + font-weight: 500; } `; diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js index e5c303e7d..6f3ca44c3 100644 --- a/app/components/DocumentPreview/DocumentPreview.js +++ b/app/components/DocumentPreview/DocumentPreview.js @@ -3,8 +3,7 @@ import * as React from 'react'; import { observer } from 'mobx-react'; import { Link } from 'react-router-dom'; import Document from 'models/Document'; -import styled from 'styled-components'; -import { color } from 'shared/styles/constants'; +import styled, { withTheme } from 'styled-components'; import Flex from 'shared/components/Flex'; import Highlight from 'components/Highlight'; import { StarredIcon } from 'outline-icons'; @@ -15,11 +14,11 @@ type Props = { document: Document, highlight?: ?string, showCollection?: boolean, - innerRef?: Function, + innerRef?: *, }; -const StyledStar = styled(({ solid, ...props }) => ( - +const StyledStar = withTheme(styled(({ solid, theme, ...props }) => ( + ))` opacity: ${props => (props.solid ? '1 !important' : 0)}; transition: all 100ms ease-in-out; @@ -30,7 +29,7 @@ const StyledStar = styled(({ solid, ...props }) => ( &:active { transform: scale(0.95); } -`; +`); const StyledDocumentMenu = styled(DocumentMenu)` position: absolute; @@ -57,8 +56,8 @@ const DocumentLink = styled(Link)` &:hover, &:active, &:focus { - background: ${color.smokeLight}; - border: 2px solid ${color.smoke}; + background: ${props => props.theme.smokeLight}; + border: 2px solid ${props => props.theme.smoke}; outline: none; ${StyledStar}, ${StyledDocumentMenu} { @@ -71,7 +70,7 @@ const DocumentLink = styled(Link)` } &:focus { - border: 2px solid ${color.slateDark}; + border: 2px solid ${props => props.theme.slateDark}; } `; diff --git a/app/components/DocumentPreview/components/PublishingInfo.js b/app/components/DocumentPreview/components/PublishingInfo.js index d18140089..34ed11055 100644 --- a/app/components/DocumentPreview/components/PublishingInfo.js +++ b/app/components/DocumentPreview/components/PublishingInfo.js @@ -1,19 +1,19 @@ // @flow import * as React from 'react'; -import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; import styled from 'styled-components'; -import { color } from 'shared/styles/constants'; import Collection from 'models/Collection'; import Document from 'models/Document'; import Flex from 'shared/components/Flex'; +import Time from 'shared/components/Time'; const Container = styled(Flex)` - color: ${color.slate}; + color: ${props => props.theme.slate}; font-size: 13px; `; const Modified = styled.span` - color: ${props => (props.highlight ? color.slateDark : color.slate)}; + color: ${props => + props.highlight ? props.theme.slateDark : props.theme.slate}; font-weight: ${props => (props.highlight ? '600' : '400')}; `; @@ -23,46 +23,36 @@ type Props = { views?: number, }; -class PublishingInfo extends React.Component { - render() { - const { collection, document } = this.props; - const { - modifiedSinceViewed, - createdAt, - updatedAt, - createdBy, - updatedBy, - publishedAt, - } = document; +function PublishingInfo({ collection, document }: Props) { + const { modifiedSinceViewed, updatedAt, updatedBy, publishedAt } = document; - const timeAgo = `${distanceInWordsToNow(new Date(createdAt))} ago`; - - return ( - - {publishedAt === updatedAt ? ( - - {createdBy.name} published {timeAgo} - - ) : ( - - {updatedBy.name} - {publishedAt ? ( - -  modified {timeAgo} - - ) : ( -  saved {timeAgo} - )} - - )} - {collection && ( - -  in {collection.name} - - )} - - ); - } + return ( + + {publishedAt && publishedAt === updatedAt ? ( + + {updatedBy.name} published + ) : ( + + {updatedBy.name} + {publishedAt ? ( + +  modified + ) : ( + +  saved + )} + + )} + {collection && ( + +  in {collection.name} + + )} + + ); } export default PublishingInfo; diff --git a/app/components/DropToImport/DropToImport.js b/app/components/DropToImport/DropToImport.js index 77cb652d8..3c5fa8067 100644 --- a/app/components/DropToImport/DropToImport.js +++ b/app/components/DropToImport/DropToImport.js @@ -3,7 +3,6 @@ import * as React from 'react'; import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; import { injectGlobal } from 'styled-components'; -import { color } from 'shared/styles/constants'; import importFile from 'utils/importFile'; import invariant from 'invariant'; import _ from 'lodash'; @@ -25,12 +24,12 @@ type Props = { // eslint-disable-next-line injectGlobal` .activeDropZone { - background: ${color.slateDark}; - svg { fill: ${color.white}; } + background: ${props => props.theme.slateDark}; + svg { fill: ${props => props.theme.white}; } } .activeDropZone a { - color: ${color.white} !important; + color: ${props => props.theme.white} !important; } `; diff --git a/app/components/DropdownMenu/DropdownMenu.js b/app/components/DropdownMenu/DropdownMenu.js index e54938421..1989f76bb 100644 --- a/app/components/DropdownMenu/DropdownMenu.js +++ b/app/components/DropdownMenu/DropdownMenu.js @@ -6,7 +6,6 @@ import { observer } from 'mobx-react'; import styled from 'styled-components'; import { PortalWithState } from 'react-portal'; import Flex from 'shared/components/Flex'; -import { color } from 'shared/styles/constants'; import { fadeAndScaleIn } from 'shared/styles/animations'; type Props = { @@ -90,8 +89,8 @@ const Menu = styled.div` right: ${({ right }) => right}px; top: ${({ top }) => top}px; z-index: 1000; - border: ${color.slateLight}; - background: ${color.white}; + border: ${props => props.theme.slateLight}; + background: ${props => props.theme.white}; border-radius: 2px; padding: 0.5em 0; min-width: 160px; diff --git a/app/components/DropdownMenu/DropdownMenuItem.js b/app/components/DropdownMenu/DropdownMenuItem.js index 8e430f322..662d80274 100644 --- a/app/components/DropdownMenu/DropdownMenuItem.js +++ b/app/components/DropdownMenu/DropdownMenuItem.js @@ -1,11 +1,11 @@ // @flow import * as React from 'react'; import styled from 'styled-components'; -import { color } from 'shared/styles/constants'; type Props = { onClick?: (SyntheticEvent<*>) => *, children?: React.Node, + disabled?: boolean, }; const DropdownMenuItem = ({ onClick, children, ...rest }: Props) => { @@ -22,24 +22,32 @@ const MenuItem = styled.a` padding: 6px 12px; height: 32px; - color: ${color.slateDark}; + color: ${props => + props.disabled ? props.theme.slate : props.theme.slateDark}; justify-content: left; align-items: center; - cursor: pointer; font-size: 15px; + cursor: default; svg { margin-right: 8px; } + ${props => + props.disabled + ? '' + : ` + &:hover { - color: ${color.white}; - background: ${color.primary}; + color: ${props.theme.white}; + background: ${props.theme.primary}; + cursor: pointer; svg { - fill: ${color.white}; + fill: ${props.theme.white}; } } + `}; `; export default DropdownMenuItem; diff --git a/app/components/Empty/Empty.js b/app/components/Empty/Empty.js index 83c8d448a..c12ed2fb1 100644 --- a/app/components/Empty/Empty.js +++ b/app/components/Empty/Empty.js @@ -1,7 +1,6 @@ // @flow import * as React from 'react'; import styled from 'styled-components'; -import { color } from 'shared/styles/constants'; type Props = { children: string, @@ -14,7 +13,7 @@ const Empty = (props: Props) => { const Container = styled.div` display: flex; - color: ${color.slate}; + color: ${props => props.theme.slate}; text-align: center; `; diff --git a/app/components/HelpText/HelpText.js b/app/components/HelpText/HelpText.js index 4e728ed67..f096c9f20 100644 --- a/app/components/HelpText/HelpText.js +++ b/app/components/HelpText/HelpText.js @@ -1,10 +1,9 @@ // @flow import styled from 'styled-components'; -import { color } from 'shared/styles/constants'; const HelpText = styled.p` margin-top: 0; - color: ${color.slateDark}; + color: ${props => props.theme.slateDark}; `; export default HelpText; diff --git a/app/components/Highlight/Highlight.js b/app/components/Highlight/Highlight.js index e6e8421a9..0a87e1078 100644 --- a/app/components/Highlight/Highlight.js +++ b/app/components/Highlight/Highlight.js @@ -2,7 +2,6 @@ import * as React from 'react'; import replace from 'string-replace-to-array'; import styled from 'styled-components'; -import { color } from 'shared/styles/constants'; type Props = { highlight: ?string, @@ -28,7 +27,7 @@ function Highlight({ highlight, caseSensitive, text, ...rest }: Props) { } const Mark = styled.mark` - background: ${color.yellow}; + background: ${props => props.theme.yellow}; `; export default Highlight; diff --git a/app/components/Input/Input.js b/app/components/Input/Input.js index 0e2ed8688..d4d06fbc1 100644 --- a/app/components/Input/Input.js +++ b/app/components/Input/Input.js @@ -2,7 +2,6 @@ import * as React from 'react'; import styled from 'styled-components'; import Flex from 'shared/components/Flex'; -import { size, color } from 'shared/styles/constants'; const RealTextarea = styled.textarea` border: 0; @@ -13,7 +12,7 @@ const RealTextarea = styled.textarea` &:disabled, &::placeholder { - color: ${color.slate}; + color: ${props => props.theme.slate}; } `; @@ -26,7 +25,7 @@ const RealInput = styled.input` &:disabled, &::placeholder { - color: ${color.slate}; + color: ${props => props.theme.slate}; } `; @@ -37,16 +36,16 @@ const Wrapper = styled.div` export const Outline = styled(Flex)` display: flex; flex: 1; - margin: 0 0 ${size.large}; + margin: 0 0 16px; color: inherit; border-width: 1px; border-style: solid; - border-color: ${props => (props.hasError ? 'red' : color.slateLight)}; + border-color: ${props => (props.hasError ? 'red' : props.theme.slateLight)}; border-radius: 4px; font-weight: normal; &:focus { - border-color: ${color.slate}; + border-color: ${props => props.theme.slate}; } `; diff --git a/app/components/Key/key.js b/app/components/Key/key.js index 8c53c8db7..e24c195d1 100644 --- a/app/components/Key/key.js +++ b/app/components/Key/key.js @@ -1,6 +1,5 @@ // @flow import styled from 'styled-components'; -import { color } from 'shared/styles/constants'; const Key = styled.kbd` display: inline-block; @@ -8,13 +7,13 @@ const Key = styled.kbd` font: 11px 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; line-height: 10px; - color: ${color.text}; + color: ${props => props.theme.text}; vertical-align: middle; - background-color: ${color.smokeLight}; - border: solid 1px ${color.slateLight}; - border-bottom-color: ${color.slate}; + background-color: ${props => props.theme.smokeLight}; + border: solid 1px ${props => props.theme.slateLight}; + border-bottom-color: ${props => props.theme.slate}; border-radius: 3px; - box-shadow: inset 0 -1px 0 ${color.slate}; + box-shadow: inset 0 -1px 0 ${props => props.theme.slate}; `; export default Key; diff --git a/app/components/Labeled/Labeled.js b/app/components/Labeled/Labeled.js index 5fde0cba1..167e7d80f 100644 --- a/app/components/Labeled/Labeled.js +++ b/app/components/Labeled/Labeled.js @@ -3,7 +3,6 @@ import * as React from 'react'; import { observer } from 'mobx-react'; import Flex from 'shared/components/Flex'; import styled from 'styled-components'; -import { size } from 'shared/styles/constants'; type Props = { label: React.Node | string, @@ -18,7 +17,7 @@ const Labeled = ({ label, children, ...props }: Props) => ( ); export const Label = styled(Flex)` - margin-bottom: ${size.medium}; + margin-bottom: 8px; font-size: 13px; font-weight: 500; text-transform: uppercase; diff --git a/app/components/Layout/Layout.js b/app/components/Layout/Layout.js index 7ba9a146a..1c8ef6afa 100644 --- a/app/components/Layout/Layout.js +++ b/app/components/Layout/Layout.js @@ -9,7 +9,6 @@ import { observer, inject } from 'mobx-react'; import keydown from 'react-keydown'; import Analytics from 'shared/components/Analytics'; import Flex from 'shared/components/Flex'; -import { layout } from 'shared/styles/constants'; import { documentEditUrl, homeUrl, searchUrl } from 'utils/routeHelpers'; import { LoadingIndicatorBar } from 'components/LoadingIndicator'; @@ -39,7 +38,7 @@ type Props = { class Layout extends React.Component { scrollable: ?HTMLDivElement; - @keydown(['/', 't']) + @keydown(['/', 't', 'meta+k']) goToSearch(ev) { ev.preventDefault(); ev.stopPropagation(); @@ -114,14 +113,14 @@ const Container = styled(Flex)` const Content = styled(Flex)` margin: 0; - transition: margin-left 200ms ease-in-out; + transition: margin-left 100ms ease-out; @media print { margin: 0; } ${breakpoint('tablet')` - margin-left: ${props => (props.editMode ? 0 : layout.sidebarWidth)}; + margin-left: ${props => (props.editMode ? 0 : props.theme.sidebarWidth)}; `}; `; diff --git a/app/components/List/Item.js b/app/components/List/Item.js index 4f19f0e52..6b4036705 100644 --- a/app/components/List/Item.js +++ b/app/components/List/Item.js @@ -1,7 +1,6 @@ // @flow import * as React from 'react'; import styled from 'styled-components'; -import { color, fontSize } from 'shared/styles/constants'; type Props = { image?: React.Node, @@ -27,7 +26,7 @@ const Wrapper = styled.li` display: flex; padding: 12px 0; margin: 0; - border-bottom: 1px solid ${color.smokeDark}; + border-bottom: 1px solid ${props => props.theme.smokeDark}; `; const Image = styled.div` @@ -36,7 +35,7 @@ const Image = styled.div` `; const Heading = styled.h2` - font-size: ${fontSize.medium}; + font-size: 16px; margin: 0; `; @@ -46,8 +45,8 @@ const Content = styled.div` const Subtitle = styled.p` margin: 0; - font-size: ${fontSize.small}; - color: ${color.slate}; + font-size: 14px; + color: ${props => props.theme.slate}; `; const Actions = styled.div` diff --git a/app/components/LoadingPlaceholder/components/Mask.js b/app/components/LoadingPlaceholder/components/Mask.js index 3362e9979..d2978634a 100644 --- a/app/components/LoadingPlaceholder/components/Mask.js +++ b/app/components/LoadingPlaceholder/components/Mask.js @@ -2,7 +2,6 @@ import * as React from 'react'; import styled from 'styled-components'; import { pulsate } from 'shared/styles/animations'; -import { color } from 'shared/styles/constants'; import { randomInteger } from 'shared/random'; import Flex from 'shared/components/Flex'; @@ -26,7 +25,7 @@ const Redacted = styled(Flex)` width: ${props => (props.header ? props.width / 2 : props.width)}%; height: ${props => (props.header ? 28 : 18)}px; margin-bottom: ${props => (props.header ? 18 : 12)}px; - background-color: ${color.smokeDark}; + background-color: ${props => props.theme.smokeDark}; animation: ${pulsate} 1.3s infinite; &:last-child { diff --git a/app/components/Modal/Modal.js b/app/components/Modal/Modal.js index d19410b0b..52c9161ca 100644 --- a/app/components/Modal/Modal.js +++ b/app/components/Modal/Modal.js @@ -5,7 +5,6 @@ import styled, { injectGlobal } from 'styled-components'; import breakpoint from 'styled-components-breakpoint'; import ReactModal from 'react-modal'; import { CloseIcon } from 'outline-icons'; -import { color } from 'shared/styles/constants'; import { fadeAndScaleIn } from 'shared/styles/animations'; import Flex from 'shared/components/Flex'; @@ -84,7 +83,7 @@ const Close = styled.a` top: 16px; right: 16px; opacity: 0.5; - color: ${color.text}; + color: ${props => props.theme.text}; &:hover { opacity: 1; diff --git a/app/components/Modals/Modals.js b/app/components/Modals/Modals.js index d9a2c23c2..203a5d58f 100644 --- a/app/components/Modals/Modals.js +++ b/app/components/Modals/Modals.js @@ -6,6 +6,7 @@ import UiStore from 'stores/UiStore'; import CollectionNew from 'scenes/CollectionNew'; import CollectionEdit from 'scenes/CollectionEdit'; import CollectionDelete from 'scenes/CollectionDelete'; +import CollectionExport from 'scenes/CollectionExport'; import DocumentDelete from 'scenes/DocumentDelete'; import DocumentShare from 'scenes/DocumentShare'; import KeyboardShortcuts from 'scenes/KeyboardShortcuts'; @@ -45,6 +46,9 @@ class Modals extends React.Component { + + + diff --git a/app/components/ScrollToTop.js b/app/components/ScrollToTop.js index 490b432c9..a877045bf 100644 --- a/app/components/ScrollToTop.js +++ b/app/components/ScrollToTop.js @@ -5,9 +5,16 @@ import { withRouter } from 'react-router-dom'; class ScrollToTop extends React.Component<*> { componentDidUpdate(prevProps) { - if (this.props.location !== prevProps.location) { - window.scrollTo(0, 0); - } + if (this.props.location.pathname === prevProps.location.pathname) return; + + // exception for when entering or exiting document edit, scroll postion should not reset + if ( + this.props.location.pathname.match(/\/edit\/?$/) || + prevProps.location.pathname.match(/\/edit\/?$/) + ) + return; + + window.scrollTo(0, 0); } render() { diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js index 16fe19602..1b126e0b1 100644 --- a/app/components/Sidebar/Main.js +++ b/app/components/Sidebar/Main.js @@ -12,6 +12,7 @@ import Scrollable from 'components/Scrollable'; import Collections from './components/Collections'; import SidebarLink from './components/SidebarLink'; import HeaderBlock from './components/HeaderBlock'; +import Bubble from './components/Bubble'; import AuthStore from 'stores/AuthStore'; import DocumentsStore from 'stores/DocumentsStore'; @@ -27,6 +28,10 @@ type Props = { @observer class MainSidebar extends React.Component { + componentDidMount() { + this.props.documents.fetchDrafts(); + } + handleCreateCollection = () => { this.props.ui.setActiveModal('collection-new'); }; @@ -67,7 +72,7 @@ class MainSidebar extends React.Component { documents.active ? !documents.active.publishedAt : undefined } > - Drafts + Drafts
diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js index 60af185ad..8a38ba73f 100644 --- a/app/components/Sidebar/Settings.js +++ b/app/components/Sidebar/Settings.js @@ -2,6 +2,7 @@ import * as React from 'react'; import { observer, inject } from 'mobx-react'; import { + DocumentIcon, ProfileIcon, SettingsIcon, CodeIcon, @@ -74,6 +75,11 @@ class SettingsSidebar extends React.Component { Integrations )} + {user.isAdmin && ( + }> + Export Data + + )}
diff --git a/app/components/Sidebar/Sidebar.js b/app/components/Sidebar/Sidebar.js index 3f34e8214..f69ac1620 100644 --- a/app/components/Sidebar/Sidebar.js +++ b/app/components/Sidebar/Sidebar.js @@ -7,18 +7,13 @@ import breakpoint from 'styled-components-breakpoint'; import { observer, inject } from 'mobx-react'; import { CloseIcon, MenuIcon } from 'outline-icons'; import Flex from 'shared/components/Flex'; -import { color, layout } from 'shared/styles/constants'; -import AuthStore from 'stores/AuthStore'; -import DocumentsStore from 'stores/DocumentsStore'; import UiStore from 'stores/UiStore'; type Props = { children: React.Node, history: Object, location: Location, - auth: AuthStore, - documents: DocumentsStore, ui: UiStore, }; @@ -59,20 +54,36 @@ const Container = styled(Flex)` position: fixed; top: 0; bottom: 0; - left: ${props => (props.editMode ? `-${layout.sidebarWidth}` : 0)}; + left: ${props => (props.editMode ? `-${props.theme.sidebarWidth}` : 0)}; width: 100%; - background: ${color.smoke}; - transition: left 200ms ease-in-out; + background: ${props => props.theme.smoke}; + transition: left 100ms ease-out; margin-left: ${props => (props.mobileSidebarVisible ? 0 : '-100%')}; - z-index: 1; + z-index: 2; @media print { display: none; left: 0; } + &:before, + &:after { + content: ''; + background: ${props => props.theme.smoke}; + position: absolute; + top: -50vh; + left: 0; + width: 100%; + height: 50vh; + } + + &:after { + top: auto; + bottom: -50vh; + } + ${breakpoint('tablet')` - width: ${layout.sidebarWidth}; + width: ${props => props.theme.sidebarWidth}; margin: 0; `}; `; @@ -90,7 +101,7 @@ const Toggle = styled.a` left: ${props => (props.mobileSidebarVisible ? 'auto' : 0)}; right: ${props => (props.mobileSidebarVisible ? 0 : 'auto')}; z-index: 1; - margin: 16px; + margin: 12px; ${breakpoint('tablet')` display: none; diff --git a/app/components/Sidebar/components/Bubble.js b/app/components/Sidebar/components/Bubble.js new file mode 100644 index 000000000..03377d93d --- /dev/null +++ b/app/components/Sidebar/components/Bubble.js @@ -0,0 +1,31 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import { fadeAndScaleIn } from 'shared/styles/animations'; + +type Props = { + count: number, +}; + +const Bubble = ({ count }: Props) => { + return !!count && {count}; +}; + +const Wrapper = styled.div` + animation: ${fadeAndScaleIn} 200ms ease; + + border-radius: 100%; + color: ${props => props.theme.white}; + background: ${props => props.theme.slateDark}; + display: inline-block; + min-width: 15px; + padding: 0 5px; + font-size: 10px; + position: relative; + top: -2px; + left: 2px; +} + +`; + +export default Bubble; diff --git a/app/components/Sidebar/components/Header.js b/app/components/Sidebar/components/Header.js index 63cff780c..0e3e94815 100644 --- a/app/components/Sidebar/components/Header.js +++ b/app/components/Sidebar/components/Header.js @@ -1,13 +1,12 @@ // @flow import Flex from 'shared/components/Flex'; import styled from 'styled-components'; -import { color, fontWeight } from 'shared/styles/constants'; const Header = styled(Flex)` font-size: 11px; - font-weight: ${fontWeight.semiBold}; + font-weight: 600; text-transform: uppercase; - color: ${color.slateDark}; + color: ${props => props.theme.slateDark}; letter-spacing: 0.04em; margin-bottom: 4px; `; diff --git a/app/components/Sidebar/components/HeaderBlock.js b/app/components/Sidebar/components/HeaderBlock.js index dfde64198..3b97dc654 100644 --- a/app/components/Sidebar/components/HeaderBlock.js +++ b/app/components/Sidebar/components/HeaderBlock.js @@ -1,7 +1,6 @@ // @flow import * as React from 'react'; -import styled from 'styled-components'; -import { color } from 'shared/styles/constants'; +import styled, { withTheme } from 'styled-components'; import { ExpandedIcon } from 'outline-icons'; import Flex from 'shared/components/Flex'; import TeamLogo from './TeamLogo'; @@ -11,6 +10,7 @@ type Props = { subheading: string, showDisclosure?: boolean, logoUrl: string, + theme: Object, }; function HeaderBlock({ @@ -18,15 +18,16 @@ function HeaderBlock({ teamName, subheading, logoUrl, + theme, ...rest }: Props) { return (
- + {teamName}{' '} - {showDisclosure && } + {showDisclosure && } {subheading} @@ -45,7 +46,7 @@ const Subheading = styled.div` font-size: 11px; text-transform: uppercase; font-weight: 500; - color: ${color.slateDark}; + color: ${props => props.theme.slateDark}; `; const TeamName = styled.div` @@ -53,7 +54,7 @@ const TeamName = styled.div` padding-left: 10px; padding-right: 24px; font-weight: 600; - color: ${color.text}; + color: ${props => props.theme.text}; text-decoration: none; font-size: 16px; `; @@ -72,4 +73,4 @@ const Header = styled(Flex)` } `; -export default HeaderBlock; +export default withTheme(HeaderBlock); diff --git a/app/components/Sidebar/components/SidebarLink.js b/app/components/Sidebar/components/SidebarLink.js index 03d8c2a6f..55c882154 100644 --- a/app/components/Sidebar/components/SidebarLink.js +++ b/app/components/Sidebar/components/SidebarLink.js @@ -4,15 +4,9 @@ import { observable, action } from 'mobx'; import { observer } from 'mobx-react'; import { withRouter, NavLink } from 'react-router-dom'; import { CollapsedIcon } from 'outline-icons'; -import { color, fontWeight } from 'shared/styles/constants'; -import styled from 'styled-components'; +import styled, { withTheme } from 'styled-components'; import Flex from 'shared/components/Flex'; -const activeStyle = { - color: color.black, - fontWeight: fontWeight.medium, -}; - const StyledGoTo = styled(CollapsedIcon)` margin-bottom: -4px; margin-left: 1px; @@ -34,12 +28,12 @@ const StyledNavLink = styled(NavLink)` text-overflow: ellipsis; padding: 4px 0; margin-left: ${({ icon }) => (icon ? '-20px;' : '0')}; - color: ${color.slateDark}; + color: ${props => props.theme.slateDark}; font-size: 15px; cursor: pointer; &:hover { - color: ${color.text}; + color: ${props => props.theme.text}; } `; @@ -57,11 +51,22 @@ type Props = { hideExpandToggle?: boolean, iconColor?: string, active?: boolean, + theme: Object, }; @observer class SidebarLink extends React.Component { @observable expanded: boolean = false; + activeStyle: Object; + + constructor(props) { + super(props); + + this.activeStyle = { + color: props.theme.black, + fontWeight: 500, + }; + } componentDidMount() { if (this.props.expand) this.handleExpand(); @@ -104,8 +109,8 @@ class SidebarLink extends React.Component { props.theme.slate}; svg { opacity: 0.75; } @@ -158,4 +163,4 @@ const Content = styled.div` width: 100%; `; -export default withRouter(SidebarLink); +export default withRouter(withTheme(SidebarLink)); diff --git a/app/components/Sidebar/components/TeamLogo.js b/app/components/Sidebar/components/TeamLogo.js index f699b32a8..6d9f2b09f 100644 --- a/app/components/Sidebar/components/TeamLogo.js +++ b/app/components/Sidebar/components/TeamLogo.js @@ -1,13 +1,12 @@ // @flow import styled from 'styled-components'; -import { color } from 'shared/styles/constants'; const TeamLogo = styled.img` width: 38px; height: 38px; border-radius: 4px; - background: ${color.white}; - border: 1px solid ${color.slateLight}; + background: ${props => props.theme.white}; + border: 1px solid ${props => props.theme.slateLight}; `; export default TeamLogo; diff --git a/app/components/Subheading.js b/app/components/Subheading.js index 32cbe5be1..afe2bcb66 100644 --- a/app/components/Subheading.js +++ b/app/components/Subheading.js @@ -1,14 +1,13 @@ // @flow import styled from 'styled-components'; -import { color } from 'shared/styles/constants'; const Subheading = styled.h3` font-size: 11px; font-weight: 500; text-transform: uppercase; - color: ${color.slate}; + color: ${props => props.theme.slate}; letter-spacing: 0.04em; - border-bottom: 1px solid ${color.slateLight}; + border-bottom: 1px solid ${props => props.theme.slateLight}; padding-bottom: 8px; margin-top: 30px; margin-bottom: 10px; diff --git a/app/components/Toasts/Toasts.js b/app/components/Toasts/Toasts.js index efe25a437..cc5d4347d 100644 --- a/app/components/Toasts/Toasts.js +++ b/app/components/Toasts/Toasts.js @@ -2,7 +2,6 @@ import * as React from 'react'; import { observer } from 'mobx-react'; import styled from 'styled-components'; -import { layout } from 'shared/styles/constants'; import Toast from './components/Toast'; import UiStore from '../../stores/UiStore'; @@ -34,8 +33,8 @@ class Toasts extends React.Component { const List = styled.ol` position: fixed; - left: ${layout.hpadding}; - bottom: ${layout.vpadding}; + left: ${props => props.theme.hpadding}; + bottom: ${props => props.theme.vpadding}; list-style: none; margin: 0; padding: 0; diff --git a/app/components/Toasts/components/Toast.js b/app/components/Toasts/components/Toast.js index 04bbc84f6..e2b603551 100644 --- a/app/components/Toasts/components/Toast.js +++ b/app/components/Toasts/components/Toast.js @@ -2,7 +2,6 @@ import * as React from 'react'; import styled from 'styled-components'; import { darken } from 'polished'; -import { color } from 'shared/styles/constants'; import { fadeAndScaleIn } from 'shared/styles/animations'; import type { Toast as TToast } from '../../../types'; @@ -51,14 +50,14 @@ const Container = styled.li` animation: ${fadeAndScaleIn} 100ms ease; margin: 8px 0; padding: 8px; - color: ${color.white}; - background: ${props => color[props.type]}; + color: ${props => props.theme.white}; + background: ${props => props.theme[props.type]}; font-size: 15px; border-radius: 5px; cursor: default; &:hover { - background: ${props => darken(0.05, color[props.type])}; + background: ${props => darken(0.05, props.theme[props.type])}; } `; diff --git a/app/index.js b/app/index.js index c4443c7ea..8d64d53cf 100644 --- a/app/index.js +++ b/app/index.js @@ -2,6 +2,7 @@ import * as React from 'react'; import { render } from 'react-dom'; import { Provider } from 'mobx-react'; +import { ThemeProvider } from 'styled-components'; import { BrowserRouter as Router, Switch, @@ -10,6 +11,7 @@ import { } from 'react-router-dom'; import stores from 'stores'; +import theme from 'shared/styles/theme'; import globalStyles from 'shared/styles/globals'; import 'shared/styles/prism.css'; @@ -26,6 +28,7 @@ import People from 'scenes/Settings/People'; import Slack from 'scenes/Settings/Slack'; import Shares from 'scenes/Settings/Shares'; import Tokens from 'scenes/Settings/Tokens'; +import Export from 'scenes/Settings/Export'; import Error404 from 'scenes/Error404'; import ErrorBoundary from 'components/ErrorBoundary'; @@ -55,73 +58,92 @@ if (element) { render( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {DevTools && } , diff --git a/app/menus/CollectionMenu.js b/app/menus/CollectionMenu.js index 9c7c906a1..671e3dc67 100644 --- a/app/menus/CollectionMenu.js +++ b/app/menus/CollectionMenu.js @@ -61,6 +61,12 @@ class CollectionMenu extends React.Component { this.props.ui.setActiveModal('collection-delete', { collection }); }; + onExport = (ev: SyntheticEvent<*>) => { + ev.preventDefault(); + const { collection } = this.props; + this.props.ui.setActiveModal('collection-export', { collection }); + }; + render() { const { collection, label, onOpen, onClose } = this.props; @@ -87,6 +93,9 @@ class CollectionMenu extends React.Component {
Edit… + + Export… + )} Delete… diff --git a/app/menus/NewDocumentMenu.js b/app/menus/NewDocumentMenu.js new file mode 100644 index 000000000..22c607bac --- /dev/null +++ b/app/menus/NewDocumentMenu.js @@ -0,0 +1,53 @@ +// @flow +import * as React from 'react'; +import { withRouter } from 'react-router-dom'; +import { inject } from 'mobx-react'; +import { MoreIcon, CollectionIcon } from 'outline-icons'; + +import { newDocumentUrl } from 'utils/routeHelpers'; +import CollectionsStore from 'stores/CollectionsStore'; +import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; + +type Props = { + label?: React.Node, + history: Object, + collections: CollectionsStore, +}; + +class NewDocumentMenu extends React.Component { + handleNewDocument = collection => { + this.props.history.push(newDocumentUrl(collection)); + }; + + onOpen = () => { + const { collections } = this.props; + + if (collections.orderedData.length === 1) { + this.handleNewDocument(collections.orderedData[0]); + } + }; + + render() { + const { collections, label, history, ...rest } = this.props; + + return ( + } + onOpen={this.onOpen} + {...rest} + > + Choose a collection… + {collections.orderedData.map(collection => ( + this.handleNewDocument(collection)} + > + {collection.name} + + ))} + + ); + } +} + +export default withRouter(inject('collections')(NewDocumentMenu)); diff --git a/app/models/Collection.js b/app/models/Collection.js index e9616fca3..91881f2e2 100644 --- a/app/models/Collection.js +++ b/app/models/Collection.js @@ -133,6 +133,11 @@ class Collection extends BaseModel { return false; }; + @action + export = async () => { + await client.post('/collections.export', { id: this.id }); + }; + @action updateData(data: Object = {}) { this.data = data; diff --git a/app/models/Document.js b/app/models/Document.js index 2b895017f..9b240c0bd 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -6,8 +6,9 @@ import { client } from 'utils/ApiClient'; import stores from 'stores'; import UiStore from 'stores/UiStore'; import parseTitle from '../../shared/utils/parseTitle'; +import unescape from '../../shared/utils/unescape'; -import type { User } from 'types'; +import type { NavigationNode, User } from 'types'; import BaseModel from './BaseModel'; import Collection from './Collection'; @@ -51,17 +52,11 @@ class Document extends BaseModel { } @computed - get pathToDocument(): Array<{ id: string, title: string }> { + get pathToDocument(): NavigationNode[] { let path; const traveler = (nodes, previousPath) => { nodes.forEach(childNode => { - const newPath = [ - ...previousPath, - { - id: childNode.id, - title: childNode.title, - }, - ]; + const newPath = [...previousPath, childNode]; if (childNode.id === this.id) { path = newPath; return; @@ -180,19 +175,12 @@ class Document extends BaseModel { if (this.isSaving) return this; const wasDraft = !this.publishedAt; + const isCreating = !this.id; this.isSaving = true; try { let res; - if (this.id) { - res = await client.post('/documents.update', { - id: this.id, - title: this.title, - text: this.text, - lastRevision: this.revision, - ...options, - }); - } else { + if (isCreating) { const data = { parentDocument: undefined, collection: this.collection.id, @@ -204,25 +192,36 @@ class Document extends BaseModel { data.parentDocument = this.parentDocument; } res = await client.post('/documents.create', data); - if (res && res.data) this.emit('documents.create', res.data); + } else { + res = await client.post('/documents.update', { + id: this.id, + title: this.title, + text: this.text, + lastRevision: this.revision, + ...options, + }); } runInAction('Document#save', () => { invariant(res && res.data, 'Data should be available'); this.updateData(res.data); this.hasPendingChanges = false; - }); - this.emit('documents.update', { - document: this, - collectionId: this.collection.id, - }); + if (isCreating) { + this.emit('documents.create', this); + } - if (wasDraft && this.publishedAt) { - this.emit('documents.publish', { - id: this.id, + this.emit('documents.update', { + document: this, collectionId: this.collection.id, }); - } + + if (wasDraft && this.publishedAt) { + this.emit('documents.publish', { + id: this.id, + collectionId: this.collection.id, + }); + } + }); } catch (e) { this.ui.showToast('Document failed to save'); } finally { @@ -270,15 +269,16 @@ class Document extends BaseModel { this.emit('documents.duplicate', this); }; - download() { - const a = window.document.createElement('a'); - a.textContent = 'download'; + download = async () => { + await this.fetch(); + + const blob = new Blob([unescape(this.text)], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `${this.title}.md`; - a.href = `data:text/markdown;charset=UTF-8,${encodeURIComponent( - this.text - )}`; a.click(); - } + }; updateData(data: Object = {}, dirty: boolean = false) { if (data.text) { diff --git a/app/scenes/Collection/Collection.js b/app/scenes/Collection.js similarity index 100% rename from app/scenes/Collection/Collection.js rename to app/scenes/Collection.js diff --git a/app/scenes/Collection/index.js b/app/scenes/Collection/index.js deleted file mode 100644 index c25fbb860..000000000 --- a/app/scenes/Collection/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import Collection from './Collection'; -export default Collection; diff --git a/app/scenes/CollectionDelete/CollectionDelete.js b/app/scenes/CollectionDelete.js similarity index 100% rename from app/scenes/CollectionDelete/CollectionDelete.js rename to app/scenes/CollectionDelete.js diff --git a/app/scenes/CollectionDelete/index.js b/app/scenes/CollectionDelete/index.js deleted file mode 100644 index e86f58891..000000000 --- a/app/scenes/CollectionDelete/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import CollectionDelete from './CollectionDelete'; -export default CollectionDelete; diff --git a/app/scenes/CollectionEdit/CollectionEdit.js b/app/scenes/CollectionEdit.js similarity index 100% rename from app/scenes/CollectionEdit/CollectionEdit.js rename to app/scenes/CollectionEdit.js diff --git a/app/scenes/CollectionEdit/index.js b/app/scenes/CollectionEdit/index.js deleted file mode 100644 index 205ea2546..000000000 --- a/app/scenes/CollectionEdit/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import CollectionEdit from './CollectionEdit'; -export default CollectionEdit; diff --git a/app/scenes/CollectionExport.js b/app/scenes/CollectionExport.js new file mode 100644 index 000000000..8bfa656ed --- /dev/null +++ b/app/scenes/CollectionExport.js @@ -0,0 +1,55 @@ +// @flow +import * as React from 'react'; +import { observable } from 'mobx'; +import { inject, observer } from 'mobx-react'; +import Button from 'components/Button'; +import Flex from 'shared/components/Flex'; +import HelpText from 'components/HelpText'; +import Collection from 'models/Collection'; +import AuthStore from 'stores/AuthStore'; +import UiStore from 'stores/UiStore'; + +type Props = { + collection: Collection, + auth: AuthStore, + ui: UiStore, + onSubmit: () => void, +}; + +@observer +class CollectionExport extends React.Component { + @observable isLoading: boolean = false; + + handleSubmit = async (ev: SyntheticEvent<*>) => { + ev.preventDefault(); + + this.isLoading = true; + await this.props.collection.export(); + this.isLoading = false; + + this.props.ui.showToast('Export in progress…', 'success'); + this.props.onSubmit(); + }; + + render() { + const { collection, auth } = this.props; + if (!auth.user) return; + + return ( + +
+ + Exporting the collection {collection.name} may take + a few minutes. We’ll put together a zip file of your documents in + Markdown format and email it to {auth.user.email}. + + +
+
+ ); + } +} + +export default inject('ui', 'auth')(CollectionExport); diff --git a/app/scenes/CollectionNew/CollectionNew.js b/app/scenes/CollectionNew.js similarity index 100% rename from app/scenes/CollectionNew/CollectionNew.js rename to app/scenes/CollectionNew.js diff --git a/app/scenes/CollectionNew/index.js b/app/scenes/CollectionNew/index.js deleted file mode 100644 index 651d8d5c1..000000000 --- a/app/scenes/CollectionNew/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import CollectionNew from './CollectionNew'; -export default CollectionNew; diff --git a/app/scenes/Dashboard/Dashboard.js b/app/scenes/Dashboard.js similarity index 55% rename from app/scenes/Dashboard/Dashboard.js rename to app/scenes/Dashboard.js index a641f2d7d..9e3dffcb6 100644 --- a/app/scenes/Dashboard/Dashboard.js +++ b/app/scenes/Dashboard.js @@ -2,8 +2,11 @@ import * as React from 'react'; import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; +import { NewDocumentIcon } from 'outline-icons'; +import NewDocumentMenu from 'menus/NewDocumentMenu'; import DocumentsStore from 'stores/DocumentsStore'; +import Actions, { Action } from 'components/Actions'; import CenteredContent from 'components/CenteredContent'; import DocumentList from 'components/DocumentList'; import PageTitle from 'components/PageTitle'; @@ -42,22 +45,33 @@ class Dashboard extends React.Component {

Home

{showContent ? ( - - {hasRecentlyViewed && [ - Recently viewed, - , - ]} - {hasRecentlyEdited && [ - Recently edited, - , - ]} - + + {hasRecentlyViewed && ( + + Recently viewed + + + )} + {hasRecentlyEdited && ( + + Recently edited + + + )} + + + } /> + + + ) : ( )} diff --git a/app/scenes/Dashboard/index.js b/app/scenes/Dashboard/index.js deleted file mode 100644 index d204b5e43..000000000 --- a/app/scenes/Dashboard/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import Dashboard from './Dashboard'; -export default Dashboard; diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/Document.js index c5edb5b63..7a934fc0d 100644 --- a/app/scenes/Document/Document.js +++ b/app/scenes/Document/Document.js @@ -18,10 +18,11 @@ import { matchDocumentMove, } from 'utils/routeHelpers'; import { uploadFile } from 'utils/uploadFile'; +import { emojiToUrl } from 'utils/emoji'; import isInternalUrl from 'utils/isInternalUrl'; import Document from 'models/Document'; -import Actions from './components/Actions'; +import Header from './components/Header'; import DocumentMove from './components/DocumentMove'; import UiStore from 'stores/UiStore'; import AuthStore from 'stores/AuthStore'; @@ -40,6 +41,10 @@ const DISCARD_CHANGES = ` You have unsaved changes. Are you sure you want to discard them? `; +const UPLOADING_WARNING = ` +Image are still uploading. +Are you sure you want to discard them? +`; type Props = { match: Object, @@ -51,25 +56,6 @@ type Props = { ui: UiStore, }; -function toCodePoint(unicodeSurrogates, sep) { - var r = [], - c = 0, - p = 0, - i = 0; - while (i < unicodeSurrogates.length) { - c = unicodeSurrogates.charCodeAt(i++); - if (p) { - r.push((0x10000 + ((p - 0xd800) << 10) + (c - 0xdc00)).toString(16)); - p = 0; - } else if (0xd800 <= c && c <= 0xdbff) { - p = c; - } else { - r.push(c.toString(16)); - } - } - return r.join(sep || '-'); -} - @observer class DocumentScene extends React.Component { savedTimeout: TimeoutID; @@ -79,7 +65,7 @@ class DocumentScene extends React.Component { @observable editCache: ?string; @observable document: ?Document; @observable newDocument: ?Document; - @observable isLoading = false; + @observable isUploading = false; @observable isSaving = false; @observable isPublishing = false; @observable notFound = false; @@ -155,7 +141,7 @@ class DocumentScene extends React.Component { }; loadEditor = async () => { - const EditorImport = await import('rich-markdown-editor'); + const EditorImport = await import('./components/Editor'); this.editorComponent = EditorImport.default; }; @@ -199,11 +185,11 @@ class DocumentScene extends React.Component { }, AUTOSAVE_INTERVAL); onImageUploadStart = () => { - this.isLoading = true; + this.isUploading = true; }; onImageUploadStop = () => { - this.isLoading = false; + this.isUploading = false; }; onChange = text => { @@ -249,7 +235,14 @@ class DocumentScene extends React.Component { onClickLink = (href: string) => { if (isInternalUrl(href)) { - this.props.history.push(href); + // relative + if (href[0] === '/') { + this.props.history.push(href); + } + + // absolute + const url = new URL(href); + this.props.history.push(url.pathname); } else { window.open(href, '_blank'); } @@ -260,8 +253,6 @@ class DocumentScene extends React.Component { const Editor = this.editorComponent; const isMoving = match.path === matchDocumentMove; const document = this.document; - const titleFromState = location.state ? location.state.title : ''; - const titleText = document ? document.title : titleFromState; const isShare = match.params.shareId; if (this.notFound) { @@ -276,82 +267,82 @@ class DocumentScene extends React.Component { ); } - let favicon; - if (document && document.emoji) { - favicon = `https://twemoji.maxcdn.com/2/72x72/${toCodePoint( - document.emoji - )}.png`; - } - - return ( - - {isMoving && document && } - {titleText && ( - - )} - {(this.isLoading || this.isSaving) && } - {!document || !Editor ? ( + if (!document || !Editor) { + return ( + + - ) : ( - - {this.isEditing && ( + + ); + } + + return ( + + {isMoving && } + + {(this.isUploading || this.isSaving) && } + + + {this.isEditing && ( + - )} - - - - {document && - !isShare && ( - - )} - - )} + + + )} + {!isShare && ( +
+ )} + + + + ); } } const MaxWidth = styled(Flex)` - padding: 0 20px; + padding: 0 16px; max-width: 100vw; + width: 100%; height: 100%; ${breakpoint('tablet')` padding: 0; - margin: 60px; + margin: 12px auto; max-width: 46em; `}; `; @@ -361,7 +352,7 @@ const Container = styled(Flex)` `; const LoadingState = styled(LoadingPlaceholder)` - margin: 90px 0; + margin: 40px 0; `; export default withRouter(inject('ui', 'auth', 'documents')(DocumentScene)); diff --git a/app/scenes/Document/components/Actions.js b/app/scenes/Document/components/Actions.js deleted file mode 100644 index 96768a9af..000000000 --- a/app/scenes/Document/components/Actions.js +++ /dev/null @@ -1,132 +0,0 @@ -// @flow -import * as React from 'react'; -import styled from 'styled-components'; -import { NewDocumentIcon } from 'outline-icons'; - -import { color } from 'shared/styles/constants'; -import Document from 'models/Document'; -import { documentEditUrl, documentNewUrl } from 'utils/routeHelpers'; - -import DocumentMenu from 'menus/DocumentMenu'; -import Collaborators from 'components/Collaborators'; -import Actions, { Action, Separator } from 'components/Actions'; - -type Props = { - document: Document, - isDraft: boolean, - isEditing: boolean, - isSaving: boolean, - isPublishing: boolean, - savingIsDisabled: boolean, - onDiscard: () => *, - onSave: ({ - done?: boolean, - publish?: boolean, - autosave?: boolean, - }) => *, - history: Object, -}; - -class DocumentActions extends React.Component { - handleNewDocument = () => { - this.props.history.push(documentNewUrl(this.props.document)); - }; - - handleEdit = () => { - this.props.history.push(documentEditUrl(this.props.document)); - }; - - handleSave = () => { - this.props.onSave({ done: true }); - }; - - handlePublish = () => { - this.props.onSave({ done: true, publish: true }); - }; - - render() { - const { - document, - isEditing, - isDraft, - isPublishing, - isSaving, - savingIsDisabled, - } = this.props; - - return ( - - {!isDraft && !isEditing && } - {isDraft && ( - - - {isPublishing ? 'Publishing…' : 'Publish'} - - - )} - {isEditing && ( - - - - {isSaving && !isPublishing ? 'Saving…' : 'Save'} - - - {isDraft && } - - )} - {!isEditing && ( - - Edit - - )} - {isEditing && ( - - - {document.hasPendingChanges ? 'Discard' : 'Done'} - - - )} - {!isEditing && ( - - - - )} - {!isEditing && - !isDraft && ( - - - - - - - - - )} - - ); - } -} - -const Link = styled.a` - display: flex; - align-items: center; - font-weight: ${props => (props.highlight ? 500 : 'inherit')}; - color: ${props => - props.highlight ? `${color.primary} !important` : 'inherit'}; - opacity: ${props => (props.disabled ? 0.5 : 1)}; - pointer-events: ${props => (props.disabled ? 'none' : 'auto')}; - cursor: ${props => (props.disabled ? 'default' : 'pointer')}; -`; - -export default DocumentActions; diff --git a/app/scenes/Document/components/Breadcrumb.js b/app/scenes/Document/components/Breadcrumb.js new file mode 100644 index 000000000..b41d808c1 --- /dev/null +++ b/app/scenes/Document/components/Breadcrumb.js @@ -0,0 +1,76 @@ +// @flow +import * as React from 'react'; +import { observer, inject } from 'mobx-react'; +import breakpoint from 'styled-components-breakpoint'; +import styled from 'styled-components'; +import { Link } from 'react-router-dom'; +import { CollectionIcon, GoToIcon } from 'outline-icons'; + +import Document from 'models/Document'; +import CollectionsStore from 'stores/CollectionsStore'; +import { collectionUrl } from 'utils/routeHelpers'; +import Flex from 'shared/components/Flex'; + +type Props = { + document: Document, + collections: CollectionsStore, +}; + +const Breadcrumb = observer(({ document, collections }: Props) => { + const path = document.pathToDocument.slice(0, -1); + const collection = + collections.getById(document.collection.id) || document.collection; + + return ( + + + {' '} + {collection.name} + + {path.map(n => ( + + {n.title} + + ))} + + ); +}); + +const Wrapper = styled(Flex)` + width: 33.3%; + display: none; + + ${breakpoint('tablet')` + display: flex; + `}; +`; + +const Slash = styled(GoToIcon)` + flex-shrink: 0; + opacity: 0.25; +`; + +const Crumb = styled(Link)` + color: ${props => props.theme.text}; + font-size: 15px; + height: 24px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + &:hover { + text-decoration: underline; + } +`; + +const CollectionName = styled(Link)` + display: flex; + flex-shrink: 0; + color: ${props => props.theme.text}; + font-size: 15px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; +`; + +export default inject('collections')(Breadcrumb); diff --git a/app/scenes/Document/components/DocumentMove/DocumentMove.js b/app/scenes/Document/components/DocumentMove/DocumentMove.js index 10e50bed9..8a61d5633 100644 --- a/app/scenes/Document/components/DocumentMove/DocumentMove.js +++ b/app/scenes/Document/components/DocumentMove/DocumentMove.js @@ -8,7 +8,6 @@ import { Search } from 'js-search'; import ArrowKeyNavigation from 'boundless-arrow-key-navigation'; import _ from 'lodash'; import styled from 'styled-components'; -import { size } from 'shared/styles/constants'; import Modal from 'components/Modal'; import Input from 'components/Input'; @@ -181,7 +180,7 @@ class DocumentMove extends React.Component { } const Section = styled(Flex)` - margin-bottom: ${size.huge}; + margin-bottom: 24px; `; const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)` diff --git a/app/scenes/Document/components/DocumentMove/components/PathToDocument.js b/app/scenes/Document/components/DocumentMove/components/PathToDocument.js index 8b8b67470..d24c56e64 100644 --- a/app/scenes/Document/components/DocumentMove/components/PathToDocument.js +++ b/app/scenes/Document/components/DocumentMove/components/PathToDocument.js @@ -4,8 +4,6 @@ import { observer } from 'mobx-react'; import invariant from 'invariant'; import styled from 'styled-components'; import { GoToIcon } from 'outline-icons'; - -import { color } from 'shared/styles/constants'; import Flex from 'shared/components/Flex'; import Document from 'models/Document'; @@ -14,7 +12,7 @@ const ResultWrapper = styled.div` display: flex; margin-bottom: 10px; - color: ${color.text}; + color: ${props => props.theme.text}; cursor: default; `; @@ -30,13 +28,13 @@ const ResultWrapperLink = ResultWrapper.withComponent('a').extend` &:focus { margin-left: 0px; border-radius: 2px; - background: ${color.black}; - color: ${color.smokeLight}; + background: ${props => props.theme.black}; + color: ${props => props.theme.smokeLight}; outline: none; cursor: pointer; ${StyledGoToIcon} { - fill: ${color.white}; + fill: ${props => props.theme.white}; } } `; diff --git a/app/scenes/Document/components/Editor.js b/app/scenes/Document/components/Editor.js new file mode 100644 index 000000000..c45fc6040 --- /dev/null +++ b/app/scenes/Document/components/Editor.js @@ -0,0 +1,111 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import { Block, Change, Node, Mark, Text } from 'slate'; +import RichMarkdownEditor, { Placeholder, schema } from 'rich-markdown-editor'; +import ClickablePadding from 'components/ClickablePadding'; + +type Props = { + titlePlaceholder: string, + bodyPlaceholder: string, + readOnly: boolean, +}; + +// add rules to the schema to ensure the first node is a heading +schema.document.nodes.unshift({ types: ['heading1'], min: 1, max: 1 }); +schema.document.normalize = ( + change: Change, + reason: string, + { + node, + child, + mark, + index, + }: { node: Node, mark?: Mark, child: Node, index: number } +) => { + switch (reason) { + case 'child_type_invalid': { + return change.setNodeByKey( + child.key, + index === 0 ? 'heading1' : 'paragraph' + ); + } + case 'child_required': { + const block = Block.create(index === 0 ? 'heading1' : 'paragraph'); + return change.insertNodeByKey(node.key, index, block); + } + default: + } +}; + +class Editor extends React.Component { + editor: *; + + setEditorRef = (ref: RichMarkdownEditor) => { + this.editor = ref; + }; + + focusAtEnd = () => { + if (this.editor) this.editor.focusAtEnd(); + }; + + renderPlaceholder = (props: *) => { + const { editor, node } = props; + + if (editor.state.isComposing) return; + if (node.object !== 'block') return; + if (!Text.isTextList(node.nodes)) return; + if (node.text !== '') return; + + const index = editor.value.document.getBlocks().indexOf(node); + if (index > 1) return; + + const text = + index === 0 ? this.props.titlePlaceholder : this.props.bodyPlaceholder; + + return {editor.props.readOnly ? '' : text}; + }; + + render() { + const { readOnly } = this.props; + + return ( + + + + + ); + } +} + +// additional styles account for placeholder nodes not always re-rendering +const StyledEditor = styled(RichMarkdownEditor)` + display: flex; + flex: 0; + + ${Placeholder} { + visibility: hidden; + } + + h1:first-of-type { + ${Placeholder} { + visibility: visible; + } + } + + p:nth-child(2):last-child { + ${Placeholder} { + visibility: visible; + } + } +`; + +export default Editor; diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js new file mode 100644 index 000000000..3ca4132ae --- /dev/null +++ b/app/scenes/Document/components/Header.js @@ -0,0 +1,232 @@ +// @flow +import * as React from 'react'; +import { throttle } from 'lodash'; +import { observable } from 'mobx'; +import { observer } from 'mobx-react'; +import styled from 'styled-components'; +import breakpoint from 'styled-components-breakpoint'; +import { NewDocumentIcon } from 'outline-icons'; +import Document from 'models/Document'; +import { documentEditUrl, documentNewUrl } from 'utils/routeHelpers'; + +import Flex from 'shared/components/Flex'; +import Breadcrumb from './Breadcrumb'; +import DocumentMenu from 'menus/DocumentMenu'; +import Collaborators from 'components/Collaborators'; +import { Action, Separator } from 'components/Actions'; + +type Props = { + document: Document, + isDraft: boolean, + isEditing: boolean, + isSaving: boolean, + isPublishing: boolean, + savingIsDisabled: boolean, + onDiscard: () => *, + onSave: ({ + done?: boolean, + publish?: boolean, + autosave?: boolean, + }) => *, + history: Object, +}; + +@observer +class Header extends React.Component { + @observable isScrolled = false; + + componentDidMount() { + window.addEventListener('scroll', this.handleScroll); + } + + componentWillUnmount() { + window.removeEventListener('scroll', this.handleScroll); + } + + updateIsScrolled = () => { + this.isScrolled = window.scrollY > 75; + }; + + handleScroll = throttle(this.updateIsScrolled, 50); + + handleNewDocument = () => { + this.props.history.push(documentNewUrl(this.props.document)); + }; + + handleEdit = () => { + this.props.history.push(documentEditUrl(this.props.document)); + }; + + handleSave = () => { + this.props.onSave({ done: true }); + }; + + handlePublish = () => { + this.props.onSave({ done: true, publish: true }); + }; + + handleClickTitle = () => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }; + + render() { + const { + document, + isEditing, + isDraft, + isPublishing, + isSaving, + savingIsDisabled, + } = this.props; + + return ( + + + + {document.title} + + + {!isDraft && !isEditing && } + {isSaving && + !isPublishing && ( + + Saving… + + )} + {isDraft && ( + + + {isPublishing ? 'Publishing…' : 'Publish'} + + + )} + {isEditing && ( + + + + {isDraft ? 'Save Draft' : 'Done'} + + + + )} + {!isEditing && ( + + Edit + + )} + {isEditing && + !isSaving && + document.hasPendingChanges && ( + + Discard + + )} + {!isEditing && ( + + + + )} + {!isEditing && + !isDraft && ( + + + + + + + + + )} + + + ); + } +} + +const Status = styled.div` + color: ${props => props.theme.slate}; +`; + +const Wrapper = styled(Flex)` + width: 100%; + align-self: flex-end; + + ${breakpoint('tablet')` + width: 33.3%; + `}; +`; + +const Actions = styled(Flex)` + position: sticky; + top: 0; + right: 0; + left: 0; + z-index: 1; + background: rgba(255, 255, 255, 0.9); + border-bottom: 1px solid + ${props => (props.isCompact ? props.theme.smoke : 'transparent')}; + padding: 12px; + transition: all 100ms ease-out; + transform: translate3d(0, 0, 0); + -webkit-backdrop-filter: blur(20px); + + @media print { + display: none; + } + + ${breakpoint('tablet')` + padding: ${props => (props.isCompact ? '12px' : `24px 24px 0`)}; + `}; +`; + +const Title = styled.div` + font-size: 16px; + font-weight: 600; + text-align: center; + justify-content: center; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + transition: opacity 100ms ease-in-out; + opacity: ${props => (props.isHidden ? '0' : '1')}; + cursor: ${props => (props.isHidden ? 'default' : 'pointer')}; + display: none; + width: 0; + + ${breakpoint('tablet')` + display: block; + width: 33.3%; + `}; +`; + +const Link = styled.a` + display: flex; + align-items: center; + font-weight: ${props => (props.highlight ? 500 : 'inherit')}; + color: ${props => + props.highlight ? `${props.theme.primary} !important` : 'inherit'}; + opacity: ${props => (props.disabled ? 0.5 : 1)}; + pointer-events: ${props => (props.disabled ? 'none' : 'auto')}; + cursor: ${props => (props.disabled ? 'default' : 'pointer')}; +`; + +export default Header; diff --git a/app/scenes/Document/components/LoadingPlaceholder.js b/app/scenes/Document/components/LoadingPlaceholder.js index 3cffaa92c..b7897e491 100644 --- a/app/scenes/Document/components/LoadingPlaceholder.js +++ b/app/scenes/Document/components/LoadingPlaceholder.js @@ -2,7 +2,6 @@ import * as React from 'react'; import styled from 'styled-components'; import { pulsate } from 'shared/styles/animations'; -import { color } from 'shared/styles/constants'; import Flex from 'shared/components/Flex'; import Fade from 'components/Fade'; @@ -29,7 +28,7 @@ const LoadingPlaceholder = (props: Object) => { const Mask = styled(Flex)` height: ${props => (props.header ? 28 : 18)}px; margin-bottom: ${props => (props.header ? 32 : 14)}px; - background-color: ${color.smoke}; + background-color: ${props => props.theme.smoke}; animation: ${pulsate} 1.3s infinite; `; diff --git a/app/scenes/DocumentDelete/DocumentDelete.js b/app/scenes/DocumentDelete.js similarity index 100% rename from app/scenes/DocumentDelete/DocumentDelete.js rename to app/scenes/DocumentDelete.js diff --git a/app/scenes/DocumentDelete/index.js b/app/scenes/DocumentDelete/index.js deleted file mode 100644 index 90e2e1113..000000000 --- a/app/scenes/DocumentDelete/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import DocumentDelete from './DocumentDelete'; -export default DocumentDelete; diff --git a/app/scenes/DocumentShare/DocumentShare.js b/app/scenes/DocumentShare.js similarity index 100% rename from app/scenes/DocumentShare/DocumentShare.js rename to app/scenes/DocumentShare.js diff --git a/app/scenes/DocumentShare/index.js b/app/scenes/DocumentShare/index.js deleted file mode 100644 index c480add32..000000000 --- a/app/scenes/DocumentShare/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import DocumentShare from './DocumentShare'; -export default DocumentShare; diff --git a/app/scenes/Drafts/Drafts.js b/app/scenes/Drafts.js similarity index 100% rename from app/scenes/Drafts/Drafts.js rename to app/scenes/Drafts.js diff --git a/app/scenes/Drafts/index.js b/app/scenes/Drafts/index.js deleted file mode 100644 index 708627878..000000000 --- a/app/scenes/Drafts/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import Drafts from './Drafts'; -export default Drafts; diff --git a/app/scenes/ErrorSuspended/ErrorSuspended.js b/app/scenes/ErrorSuspended.js similarity index 100% rename from app/scenes/ErrorSuspended/ErrorSuspended.js rename to app/scenes/ErrorSuspended.js diff --git a/app/scenes/ErrorSuspended/index.js b/app/scenes/ErrorSuspended/index.js deleted file mode 100644 index 9406c3935..000000000 --- a/app/scenes/ErrorSuspended/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import ErrorSuspended from './ErrorSuspended'; -export default ErrorSuspended; diff --git a/app/scenes/Home/Home.js b/app/scenes/Home.js similarity index 100% rename from app/scenes/Home/Home.js rename to app/scenes/Home.js diff --git a/app/scenes/Home/index.js b/app/scenes/Home/index.js deleted file mode 100644 index 6726c7545..000000000 --- a/app/scenes/Home/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import Home from './Home'; -export default Home; diff --git a/app/scenes/KeyboardShortcuts/KeyboardShortcuts.js b/app/scenes/KeyboardShortcuts.js similarity index 100% rename from app/scenes/KeyboardShortcuts/KeyboardShortcuts.js rename to app/scenes/KeyboardShortcuts.js diff --git a/app/scenes/KeyboardShortcuts/index.js b/app/scenes/KeyboardShortcuts/index.js deleted file mode 100644 index ad40fe588..000000000 --- a/app/scenes/KeyboardShortcuts/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import KeyboardShortcuts from './KeyboardShortcuts'; -export default KeyboardShortcuts; diff --git a/app/scenes/Search/components/SearchField.js b/app/scenes/Search/components/SearchField.js index 2e4158be7..934db77e4 100644 --- a/app/scenes/Search/components/SearchField.js +++ b/app/scenes/Search/components/SearchField.js @@ -1,13 +1,12 @@ // @flow import * as React from 'react'; -import styled from 'styled-components'; +import styled, { withTheme } from 'styled-components'; import { SearchIcon } from 'outline-icons'; - import Flex from 'shared/components/Flex'; -import { color } from 'shared/styles/constants'; type Props = { onChange: string => *, + theme: Object, }; class SearchField extends React.Component { @@ -31,7 +30,7 @@ class SearchField extends React.Component { props.theme.slateLight}; } :-moz-placeholder { - color: ${color.slateLight}; + color: ${props => props.theme.slateLight}; } ::-moz-placeholder { - color: ${color.slateLight}; + color: ${props => props.theme.slateLight}; } :-ms-input-placeholder { - color: ${color.slateLight}; + color: ${props => props.theme.slateLight}; } `; @@ -74,4 +73,4 @@ const StyledIcon = styled(SearchIcon)` top: 4px; `; -export default SearchField; +export default withTheme(SearchField); diff --git a/app/scenes/Settings/Details.js b/app/scenes/Settings/Details.js index 4d5766e11..4854e4800 100644 --- a/app/scenes/Settings/Details.js +++ b/app/scenes/Settings/Details.js @@ -3,7 +3,6 @@ import * as React from 'react'; import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; import styled from 'styled-components'; -import { color, size } from 'shared/styles/constants'; import AuthStore from 'stores/AuthStore'; import UiStore from 'stores/UiStore'; @@ -120,7 +119,7 @@ class Details extends React.Component { } const ProfilePicture = styled(Flex)` - margin-bottom: ${size.huge}; + margin-bottom: 24px; `; const avatarStyles = ` @@ -133,7 +132,7 @@ const AvatarContainer = styled(Flex)` ${avatarStyles}; position: relative; box-shadow: 0 0 0 1px #dae1e9; - background: ${color.white}; + background: ${props => props.theme.white}; div div { ${avatarStyles}; @@ -150,7 +149,7 @@ const AvatarContainer = styled(Flex)` &:hover div { opacity: 1; background: rgba(0, 0, 0, 0.75); - color: ${color.white}; + color: ${props => props.theme.white}; } `; diff --git a/app/scenes/Settings/Export.js b/app/scenes/Settings/Export.js new file mode 100644 index 000000000..402a811f1 --- /dev/null +++ b/app/scenes/Settings/Export.js @@ -0,0 +1,71 @@ +// @flow +import * as React from 'react'; +import { observable } from 'mobx'; +import { observer, inject } from 'mobx-react'; +import AuthStore from 'stores/AuthStore'; +import CollectionsStore from 'stores/CollectionsStore'; +import UiStore from 'stores/UiStore'; + +import CenteredContent from 'components/CenteredContent'; +import PageTitle from 'components/PageTitle'; +import HelpText from 'components/HelpText'; +import Button from 'components/Button'; + +type Props = { + auth: AuthStore, + collections: CollectionsStore, + ui: UiStore, +}; + +@observer +class Export extends React.Component { + @observable isLoading: boolean = false; + @observable isExporting: boolean = false; + + handleSubmit = async (ev: SyntheticEvent<*>) => { + ev.preventDefault(); + this.isLoading = true; + + const success = await this.props.collections.export(); + + if (success) { + this.isExporting = true; + this.props.ui.showToast('Export in progress…', 'success'); + } + this.isLoading = false; + }; + + render() { + const { auth } = this.props; + if (!auth.user) return; + + return ( + + +

Export Data

+ + Exporting your teams documents may take a little time depending on the + size of your knowledgebase. Consider exporting a single document or + collection instead. + + + Still want to export everything in your wiki? We’ll put together a zip + file of your collections and documents in Markdown format and email it + to {auth.user.email}. + + +
+ ); + } +} + +export default inject('auth', 'ui', 'collections')(Export); diff --git a/app/scenes/Settings/People.js b/app/scenes/Settings/People.js index 0f00999f6..838d5507d 100644 --- a/app/scenes/Settings/People.js +++ b/app/scenes/Settings/People.js @@ -32,9 +32,8 @@ class People extends React.Component {

People

- Everyone that has signed in to your Outline appears here. It's - possible that there are other people who have access but haven't - signed in yet. + Everyone that has signed in to your Outline appear here. It’s possible + that there are other people who have access but haven’t signed in yet. @@ -42,7 +41,7 @@ class People extends React.Component { ))} diff --git a/app/scenes/Settings/Profile.js b/app/scenes/Settings/Profile.js index 11cbe2062..7a24e14aa 100644 --- a/app/scenes/Settings/Profile.js +++ b/app/scenes/Settings/Profile.js @@ -3,7 +3,6 @@ import * as React from 'react'; import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; import styled from 'styled-components'; -import { color, size } from 'shared/styles/constants'; import AuthStore from 'stores/AuthStore'; import UiStore from 'stores/UiStore'; @@ -12,6 +11,7 @@ import Input, { LabelText } from 'components/Input'; import Button from 'components/Button'; import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; +import UserDelete from 'scenes/UserDelete'; import Flex from 'shared/components/Flex'; type Props = { @@ -26,6 +26,7 @@ class Profile extends React.Component { @observable name: string; @observable avatarUrl: ?string; + @observable showDeleteModal: boolean = false; componentDidMount() { if (this.props.auth.user) { @@ -59,6 +60,10 @@ class Profile extends React.Component { this.props.ui.showToast(error || 'Unable to upload new avatar'); }; + toggleDeleteAccount = () => { + this.showDeleteModal = !this.showDeleteModal; + }; + get isValid() { return this.form && this.form.checkValidity(); } @@ -98,13 +103,30 @@ class Profile extends React.Component { {isSaving ? 'Saving…' : 'Save'} + + + Delete Account +

+ You may delete your account at any time, note that this is + unrecoverable.{' '} + Delete account. +

+
+ {this.showDeleteModal && ( + + )} ); } } +const DangerZone = styled.div` + position: absolute; + bottom: 16px; +`; + const ProfilePicture = styled(Flex)` - margin-bottom: ${size.huge}; + margin-bottom: 24px; `; const avatarStyles = ` @@ -132,7 +154,7 @@ const AvatarContainer = styled(Flex)` &:hover div { opacity: 1; background: rgba(0, 0, 0, 0.75); - color: ${color.white}; + color: ${props => props.theme.white}; } `; diff --git a/app/scenes/Settings/components/ShareListItem.js b/app/scenes/Settings/components/ShareListItem.js index c78b950c1..c05104e9b 100644 --- a/app/scenes/Settings/components/ShareListItem.js +++ b/app/scenes/Settings/components/ShareListItem.js @@ -1,8 +1,8 @@ // @flow import * as React from 'react'; -import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; import ShareMenu from 'menus/ShareMenu'; import ListItem from 'components/List/Item'; +import Time from 'shared/components/Time'; import type { Share } from '../../../types'; type Props = { @@ -16,11 +16,8 @@ const ShareListItem = ({ share }: Props) => { title={share.documentTitle} subtitle={ - Shared{' '} - {' '} - ago by {share.createdBy.name} + Shared } actions={} diff --git a/app/scenes/Settings/components/SlackButton.js b/app/scenes/Settings/components/SlackButton.js index cffdd5652..259aa5d76 100644 --- a/app/scenes/Settings/components/SlackButton.js +++ b/app/scenes/Settings/components/SlackButton.js @@ -1,21 +1,18 @@ // @flow import * as React from 'react'; import styled from 'styled-components'; -import { inject } from 'mobx-react'; import { slackAuth } from 'shared/utils/routeHelpers'; -import Button from 'components/Button'; import SlackLogo from 'shared/components/SlackLogo'; -import AuthStore from 'stores/AuthStore'; +import Button from 'components/Button'; type Props = { - auth: AuthStore, scopes?: string[], - redirectUri?: string, + redirectUri: string, state: string, label?: string, }; -function SlackButton({ auth, state, label, scopes, redirectUri }: Props) { +function SlackButton({ state, scopes, redirectUri, label }: Props) { const handleClick = () => (window.location.href = slackAuth(state, scopes, redirectUri)); @@ -36,4 +33,4 @@ const SpacedSlackLogo = styled(SlackLogo)` padding-right: 4px; `; -export default inject('auth')(SlackButton); +export default SlackButton; diff --git a/app/scenes/Settings/components/UserListItem.js b/app/scenes/Settings/components/UserListItem.js index c7de8a56b..4d90191f1 100644 --- a/app/scenes/Settings/components/UserListItem.js +++ b/app/scenes/Settings/components/UserListItem.js @@ -1,19 +1,19 @@ // @flow import * as React from 'react'; import styled from 'styled-components'; -import { color } from 'shared/styles/constants'; import UserMenu from 'menus/UserMenu'; import Avatar from 'components/Avatar'; import ListItem from 'components/List/Item'; +import Time from 'shared/components/Time'; import type { User } from '../../../types'; type Props = { user: User, - isCurrentUser: boolean, + showMenu: boolean, }; -const UserListItem = ({ user, isCurrentUser }: Props) => { +const UserListItem = ({ user, showMenu }: Props) => { return ( { image={} subtitle={ - {user.username ? user.username : user.email} + {user.email ? `${user.email} · ` : undefined} + Joined } - actions={isCurrentUser ? undefined : } + actions={showMenu ? : undefined} /> ); }; @@ -34,8 +35,9 @@ const UserListItem = ({ user, isCurrentUser }: Props) => { const Badge = styled.span` margin-left: 10px; padding: 2px 6px 3px; - background-color: ${({ admin }) => (admin ? color.primary : color.smokeDark)}; - color: ${({ admin }) => (admin ? color.white : color.text)}; + background-color: ${({ admin, theme }) => + admin ? theme.primary : theme.smokeDark}; + color: ${({ admin, theme }) => (admin ? theme.white : theme.text)}; border-radius: 2px; font-size: 11px; text-transform: uppercase; diff --git a/app/scenes/Starred/Starred.js b/app/scenes/Starred.js similarity index 100% rename from app/scenes/Starred/Starred.js rename to app/scenes/Starred.js diff --git a/app/scenes/Starred/index.js b/app/scenes/Starred/index.js deleted file mode 100644 index 7d5e1f4a3..000000000 --- a/app/scenes/Starred/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import Starred from './Starred'; -export default Starred; diff --git a/app/scenes/UserDelete.js b/app/scenes/UserDelete.js new file mode 100644 index 000000000..00f332441 --- /dev/null +++ b/app/scenes/UserDelete.js @@ -0,0 +1,62 @@ +// @flow +import * as React from 'react'; +import { observable } from 'mobx'; +import { inject, observer } from 'mobx-react'; +import Button from 'components/Button'; +import Flex from 'shared/components/Flex'; +import HelpText from 'components/HelpText'; +import Modal from 'components/Modal'; +import AuthStore from 'stores/AuthStore'; + +type Props = { + auth: AuthStore, + onRequestClose: () => *, +}; + +@observer +class UserDelete extends React.Component { + @observable isDeleting: boolean; + + handleSubmit = async (ev: SyntheticEvent<*>) => { + ev.preventDefault(); + this.isDeleting = true; + + try { + const success = await this.props.auth.deleteUser(); + + if (success) { + this.props.auth.logout(); + } + } finally { + this.isDeleting = false; + } + }; + + render() { + const { auth, ...rest } = this.props; + + return ( + + +
+ + Are you sure? Deleting your account will destory identifying data + associated with your user and cannot be undone. You will be + immediately logged out of Outline and all your API tokens will be + revoked. + + + Note: Signing back in will cause a new account to + be automatically reprovisioned. + + +
+
+
+ ); + } +} + +export default inject('auth')(UserDelete); diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js index 526210254..ba9cd30d7 100644 --- a/app/stores/AuthStore.js +++ b/app/stores/AuthStore.js @@ -50,6 +50,17 @@ class AuthStore { } }; + @action + deleteUser = async () => { + await client.post(`/user.delete`, { confirmation: true }); + + runInAction('AuthStore#updateUser', () => { + this.user = null; + this.team = null; + this.token = null; + }); + }; + @action updateUser = async (params: { name: string, avatarUrl: ?string }) => { this.isSaving = true; diff --git a/app/stores/CollectionsStore.js b/app/stores/CollectionsStore.js index d23f610e1..59db057c0 100644 --- a/app/stores/CollectionsStore.js +++ b/app/stores/CollectionsStore.js @@ -137,6 +137,16 @@ class CollectionsStore extends BaseStore { } }; + @action + export = async () => { + try { + await client.post('/collections.exportAll'); + return true; + } catch (err) { + throw err; + } + }; + @action add = (collection: Collection): void => { this.data.set(collection.id, collection); diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index ca91c62c5..0a33a1c3d 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -217,7 +217,7 @@ class DocumentsStore extends BaseStore { if (res && res.data) { const duped = res.data; - this.emit('documents.create', duped); + this.emit('documents.create', new Document(duped)); this.emit('documents.publish', { id: duped.id, collectionId: duped.collection.id, @@ -255,7 +255,7 @@ class DocumentsStore extends BaseStore { this.remove(data.id); }); this.on('documents.create', (data: Document) => { - this.add(new Document(data)); + this.add(data); }); this.on('documents.duplicate', (data: Document) => { this.duplicate(data); diff --git a/app/stores/SharesStore.js b/app/stores/SharesStore.js index dcabd8837..64f35caeb 100644 --- a/app/stores/SharesStore.js +++ b/app/stores/SharesStore.js @@ -38,7 +38,7 @@ class SharesStore { @action revoke = async (share: Share) => { try { - await client.post('/shares.delete', { id: share.id }); + await client.post('/shares.revoke', { id: share.id }); runInAction('revoke', () => { this.data.delete(share.id); }); diff --git a/app/types/index.js b/app/types/index.js index 8b629d967..4cb937d55 100644 --- a/app/types/index.js +++ b/app/types/index.js @@ -7,6 +7,7 @@ export type User = { username: string, isAdmin?: boolean, isSuspended?: boolean, + createdAt: string, }; export type Toast = { @@ -36,11 +37,11 @@ export type NavigationNode = { id: string, title: string, url: string, - children: Array, + children: NavigationNode[], }; export type Document = { - collaborators: Array, + collaborators: User[], collection: Object, createdAt: string, createdBy: User, diff --git a/app/utils/emoji.js b/app/utils/emoji.js new file mode 100644 index 000000000..818991869 --- /dev/null +++ b/app/utils/emoji.js @@ -0,0 +1,24 @@ +// @flow + +export function toCodePoint(unicodeSurrogates, sep) { + var r = [], + c = 0, + p = 0, + i = 0; + while (i < unicodeSurrogates.length) { + c = unicodeSurrogates.charCodeAt(i++); + if (p) { + r.push((0x10000 + ((p - 0xd800) << 10) + (c - 0xdc00)).toString(16)); + p = 0; + } else if (0xd800 <= c && c <= 0xdbff) { + p = c; + } else { + r.push(c.toString(16)); + } + } + return r.join(sep || '-'); +} + +export function emojiToUrl(string: text) { + return `https://twemoji.maxcdn.com/2/72x72/${toCodePoint(string)}.png`; +} diff --git a/flow-typed/npm/bcrypt_v1.x.x.js b/flow-typed/npm/bcrypt_v1.x.x.js deleted file mode 100644 index 88a9d3279..000000000 --- a/flow-typed/npm/bcrypt_v1.x.x.js +++ /dev/null @@ -1,37 +0,0 @@ -// flow-typed signature: 96d9e6596558a201899e45822d93e38d -// flow-typed version: da30fe6876/bcrypt_v1.x.x/flow_>=v0.25.x - -declare module bcrypt { - declare function genSaltSync(rounds?: number): string; - declare function genSalt(rounds: number): Promise; - declare function genSalt(): Promise; - declare function genSalt(callback: (err: Error, salt: string) => void): void; - declare function genSalt( - rounds: number, - callback: (err: Error, salt: string) => void - ): void; - declare function hashSync(data: string, salt: string): string; - declare function hashSync(data: string, rounds: number): string; - declare function hash( - data: string, - saltOrRounds: string | number - ): Promise; - declare function hash( - data: string, - rounds: number, - callback: (err: Error, encrypted: string) => void - ): void; - declare function hash( - data: string, - salt: string, - callback: (err: Error, encrypted: string) => void - ): void; - declare function compareSync(data: string, encrypted: string): boolean; - declare function compare(data: string, encrypted: string): Promise; - declare function compare( - data: string, - encrypted: string, - callback: (err: Error, same: boolean) => void - ): void; - declare function getRounds(encrypted: string): number; -} diff --git a/package.json b/package.json index 4280a5ca6..89f27d6c0 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,6 @@ "babel-preset-react": "6.11.1", "babel-preset-react-hmre": "1.1.1", "babel-regenerator-runtime": "6.5.0", - "bcrypt": "1.0.3", "boundless-arrow-key-navigation": "^1.0.4", "boundless-popover": "^1.0.4", "bugsnag": "^1.7.0", @@ -112,6 +111,7 @@ "js-search": "^1.4.2", "json-loader": "0.5.4", "jsonwebtoken": "7.0.1", + "jszip": "3.1.5", "koa": "^2.2.0", "koa-bodyparser": "4.2.0", "koa-compress": "2.0.0", @@ -134,7 +134,7 @@ "nodemailer": "^4.4.0", "normalize.css": "^7.0.0", "normalizr": "2.0.1", - "outline-icons": "^1.2.0", + "outline-icons": "^1.3.2", "oy-vey": "^0.10.0", "pg": "^6.1.5", "pg-hstore": "2.3.2", @@ -157,7 +157,7 @@ "react-waypoint": "^7.3.1", "redis": "^2.6.2", "redis-lock": "^0.1.0", - "rich-markdown-editor": "1.2.0", + "rich-markdown-editor": "2.0.4", "safestart": "1.1.0", "sequelize": "4.28.6", "sequelize-cli": "^2.7.0", @@ -170,6 +170,7 @@ "styled-components-breakpoint": "^1.0.1", "styled-components-grid": "^1.0.0-preview.15", "styled-normalize": "^2.2.1", + "tmp": "0.0.33", "uglifyjs-webpack-plugin": "1.2.5", "url-loader": "^0.6.2", "uuid": "2.0.2", diff --git a/public/favicon-16.png b/public/favicon-16.png index 9fa7ae5be..622a8bf3d 100644 Binary files a/public/favicon-16.png and b/public/favicon-16.png differ diff --git a/public/favicon-32.png b/public/favicon-32.png index 763503409..59fc4a62b 100644 Binary files a/public/favicon-32.png and b/public/favicon-32.png differ diff --git a/public/fonts/AtlasGrotesk-Black-Web.woff b/public/fonts/AtlasGrotesk-Black-Web.woff deleted file mode 100644 index ccdb3e0fa..000000000 Binary files a/public/fonts/AtlasGrotesk-Black-Web.woff and /dev/null differ diff --git a/public/fonts/AtlasGrotesk-BlackItalic-Web.woff b/public/fonts/AtlasGrotesk-BlackItalic-Web.woff deleted file mode 100644 index 5187478c9..000000000 Binary files a/public/fonts/AtlasGrotesk-BlackItalic-Web.woff and /dev/null differ diff --git a/public/fonts/AtlasGrotesk-Bold-Web.woff b/public/fonts/AtlasGrotesk-Bold-Web.woff deleted file mode 100644 index d730123df..000000000 Binary files a/public/fonts/AtlasGrotesk-Bold-Web.woff and /dev/null differ diff --git a/public/fonts/AtlasGrotesk-BoldItalic-Web.woff b/public/fonts/AtlasGrotesk-BoldItalic-Web.woff deleted file mode 100644 index 772297800..000000000 Binary files a/public/fonts/AtlasGrotesk-BoldItalic-Web.woff and /dev/null differ diff --git a/public/fonts/AtlasGrotesk-Light-Web.woff b/public/fonts/AtlasGrotesk-Light-Web.woff deleted file mode 100644 index 462eef4d6..000000000 Binary files a/public/fonts/AtlasGrotesk-Light-Web.woff and /dev/null differ diff --git a/public/fonts/AtlasGrotesk-LightItalic-Web.woff b/public/fonts/AtlasGrotesk-LightItalic-Web.woff deleted file mode 100644 index 56cbf058c..000000000 Binary files a/public/fonts/AtlasGrotesk-LightItalic-Web.woff and /dev/null differ diff --git a/public/fonts/AtlasGrotesk-Medium-Web.woff b/public/fonts/AtlasGrotesk-Medium-Web.woff deleted file mode 100644 index 9ffcb647c..000000000 Binary files a/public/fonts/AtlasGrotesk-Medium-Web.woff and /dev/null differ diff --git a/public/fonts/AtlasGrotesk-MediumItalic-Web.woff b/public/fonts/AtlasGrotesk-MediumItalic-Web.woff deleted file mode 100644 index 0f5772674..000000000 Binary files a/public/fonts/AtlasGrotesk-MediumItalic-Web.woff and /dev/null differ diff --git a/public/fonts/AtlasGrotesk-Regular-Web.woff b/public/fonts/AtlasGrotesk-Regular-Web.woff deleted file mode 100644 index 3559d95ef..000000000 Binary files a/public/fonts/AtlasGrotesk-Regular-Web.woff and /dev/null differ diff --git a/public/fonts/AtlasGrotesk-RegularItalic-Web (1).woff b/public/fonts/AtlasGrotesk-RegularItalic-Web (1).woff deleted file mode 100644 index 29747cd97..000000000 Binary files a/public/fonts/AtlasGrotesk-RegularItalic-Web (1).woff and /dev/null differ diff --git a/public/fonts/AtlasGrotesk-RegularItalic-Web.woff b/public/fonts/AtlasGrotesk-RegularItalic-Web.woff deleted file mode 100644 index 29747cd97..000000000 Binary files a/public/fonts/AtlasGrotesk-RegularItalic-Web.woff and /dev/null differ diff --git a/public/fonts/AtlasGrotesk-Thin-Web.woff b/public/fonts/AtlasGrotesk-Thin-Web.woff deleted file mode 100644 index 13fb2f298..000000000 Binary files a/public/fonts/AtlasGrotesk-Thin-Web.woff and /dev/null differ diff --git a/public/fonts/AtlasGrotesk-ThinItalic-Web.woff b/public/fonts/AtlasGrotesk-ThinItalic-Web.woff deleted file mode 100644 index 48e881794..000000000 Binary files a/public/fonts/AtlasGrotesk-ThinItalic-Web.woff and /dev/null differ diff --git a/public/fonts/AtlasTypewriterMedium.woff b/public/fonts/AtlasTypewriterMedium.woff deleted file mode 100644 index d1ec16578..000000000 Binary files a/public/fonts/AtlasTypewriterMedium.woff and /dev/null differ diff --git a/public/fonts/AtlasTypewriterRegular.woff b/public/fonts/AtlasTypewriterRegular.woff deleted file mode 100644 index 0b22f747a..000000000 Binary files a/public/fonts/AtlasTypewriterRegular.woff and /dev/null differ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 000000000..35c78f28e Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 000000000..3b31990dd Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 000000000..63f35b601 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,20 @@ +{ + "short_name": "Outline", + "name": "Outline", + "icons": [ + { + "src": "/icon-192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "/icon-512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": "?source=pwa", + "background_color": "#FFFFFF", + "display": "standalone", + "theme_color": "#FFFFFF" +} \ No newline at end of file diff --git a/public/screenshot.png b/public/screenshot.png index 11fb8c365..6a0ef50dc 100644 Binary files a/public/screenshot.png and b/public/screenshot.png differ diff --git a/public/screenshot@2x.png b/public/screenshot@2x.png index fe214876e..da2cb8156 100644 Binary files a/public/screenshot@2x.png and b/public/screenshot@2x.png differ diff --git a/server/__snapshots__/mailer.test.js.snap b/server/__snapshots__/mailer.test.js.snap index 78fb41518..fbdca8c9b 100644 --- a/server/__snapshots__/mailer.test.js.snap +++ b/server/__snapshots__/mailer.test.js.snap @@ -2,6 +2,7 @@ exports[`Mailer #welcome 1`] = ` Object { + "attachments": undefined, "from": "hello@example.com", "html": " diff --git a/server/api/__snapshots__/collections.test.js.snap b/server/api/__snapshots__/collections.test.js.snap index 5002b47e7..92266fad2 100644 --- a/server/api/__snapshots__/collections.test.js.snap +++ b/server/api/__snapshots__/collections.test.js.snap @@ -18,6 +18,24 @@ Object { } `; +exports[`#collections.export should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#collections.exportAll should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + exports[`#collections.info should require authentication 1`] = ` Object { "error": "authentication_required", diff --git a/server/api/__snapshots__/shares.test.js.snap b/server/api/__snapshots__/shares.test.js.snap index 77cc5d4df..439b96bb9 100644 --- a/server/api/__snapshots__/shares.test.js.snap +++ b/server/api/__snapshots__/shares.test.js.snap @@ -17,3 +17,12 @@ Object { "status": 401, } `; + +exports[`#shares.revoke should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; diff --git a/server/api/__snapshots__/team.test.js.snap b/server/api/__snapshots__/team.test.js.snap index eac4cccf3..a92a594e2 100644 --- a/server/api/__snapshots__/team.test.js.snap +++ b/server/api/__snapshots__/team.test.js.snap @@ -5,12 +5,14 @@ Object { "data": Array [ Object { "avatarUrl": "http://example.com/avatar.png", + "createdAt": "2018-01-01T00:00:00.000Z", "id": "fa952cff-fa64-4d42-a6ea-6955c9689046", "name": "Admin User", "username": "admin", }, Object { "avatarUrl": "http://example.com/avatar.png", + "createdAt": "2018-01-01T00:00:00.000Z", "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", "name": "User 1", "username": "user1", @@ -31,15 +33,7 @@ Object { "data": Array [ Object { "avatarUrl": "http://example.com/avatar.png", - "email": "admin@example.com", - "id": "fa952cff-fa64-4d42-a6ea-6955c9689046", - "isAdmin": true, - "isSuspended": false, - "name": "Admin User", - "username": "admin", - }, - Object { - "avatarUrl": "http://example.com/avatar.png", + "createdAt": "2018-01-01T00:00:00.000Z", "email": "user1@example.com", "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", "isAdmin": false, @@ -47,6 +41,16 @@ Object { "name": "User 1", "username": "user1", }, + Object { + "avatarUrl": "http://example.com/avatar.png", + "createdAt": "2018-01-01T00:00:00.000Z", + "email": "admin@example.com", + "id": "fa952cff-fa64-4d42-a6ea-6955c9689046", + "isAdmin": true, + "isSuspended": false, + "name": "Admin User", + "username": "admin", + }, ], "ok": true, "pagination": Object { diff --git a/server/api/__snapshots__/user.test.js.snap b/server/api/__snapshots__/user.test.js.snap index 40a7610de..ea9827a22 100644 --- a/server/api/__snapshots__/user.test.js.snap +++ b/server/api/__snapshots__/user.test.js.snap @@ -4,6 +4,7 @@ exports[`#user.activate should activate a suspended user 1`] = ` Object { "data": Object { "avatarUrl": "http://example.com/avatar.png", + "createdAt": "2018-01-01T00:00:00.000Z", "email": "user1@example.com", "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", "isAdmin": false, @@ -25,10 +26,20 @@ Object { } `; +exports[`#user.delete should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + exports[`#user.demote should demote an admin 1`] = ` Object { "data": Object { "avatarUrl": "http://example.com/avatar.png", + "createdAt": "2018-01-01T00:00:00.000Z", "email": "user1@example.com", "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", "isAdmin": false, @@ -59,32 +70,11 @@ Object { } `; -exports[`#user.info should require authentication 1`] = ` -Object { - "error": "authentication_required", - "message": "Authentication required", - "ok": false, - "status": 401, -} -`; - -exports[`#user.info should return known user 1`] = ` -Object { - "data": Object { - "avatarUrl": "http://example.com/avatar.png", - "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", - "name": "User 1", - "username": "user1", - }, - "ok": true, - "status": 200, -} -`; - exports[`#user.promote should promote a new admin 1`] = ` Object { "data": Object { "avatarUrl": "http://example.com/avatar.png", + "createdAt": "2018-01-01T00:00:00.000Z", "email": "user1@example.com", "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", "isAdmin": true, @@ -119,6 +109,7 @@ exports[`#user.suspend should suspend an user 1`] = ` Object { "data": Object { "avatarUrl": "http://example.com/avatar.png", + "createdAt": "2018-01-01T00:00:00.000Z", "email": "user1@example.com", "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", "isAdmin": false, @@ -153,6 +144,7 @@ exports[`#user.update should update user profile information 1`] = ` Object { "data": Object { "avatarUrl": "http://example.com/avatar.png", + "createdAt": "2018-01-01T00:00:00.000Z", "email": "user1@example.com", "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", "isAdmin": false, diff --git a/server/api/collections.js b/server/api/collections.js index 85ed0b0b4..a15942674 100644 --- a/server/api/collections.js +++ b/server/api/collections.js @@ -4,8 +4,9 @@ import Router from 'koa-router'; import auth from '../middlewares/authentication'; import pagination from './middlewares/pagination'; import { presentCollection } from '../presenters'; -import { Collection } from '../models'; +import { Collection, Team } from '../models'; import { ValidationError } from '../errors'; +import { exportCollection, exportCollections } from '../logistics'; import policy from '../policies'; const { authorize } = policy; @@ -46,6 +47,35 @@ router.post('collections.info', auth(), async ctx => { }; }); +router.post('collections.export', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertPresent(id, 'id is required'); + + const user = ctx.state.user; + const collection = await Collection.findById(id); + authorize(user, 'export', collection); + + // async operation to create zip archive and email user + exportCollection(id, user.email); + + ctx.body = { + success: true, + }; +}); + +router.post('collections.exportAll', auth(), async ctx => { + const user = ctx.state.user; + const team = await Team.findById(user.teamId); + authorize(user, 'export', team); + + // async operation to create zip archive and email user + exportCollections(user.teamId, user.email); + + ctx.body = { + success: true, + }; +}); + router.post('collections.update', auth(), async ctx => { const { id, name, color } = ctx.body; ctx.assertPresent(name, 'name is required'); diff --git a/server/api/collections.test.js b/server/api/collections.test.js index fcc7248ac..70d25f2bd 100644 --- a/server/api/collections.test.js +++ b/server/api/collections.test.js @@ -31,6 +31,52 @@ describe('#collections.list', async () => { }); }); +describe('#collections.export', async () => { + it('should require authentication', async () => { + const res = await server.post('/api/collections.export'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should return success', async () => { + const { user, collection } = await seed(); + const res = await server.post('/api/collections.export', { + body: { token: user.getJwtToken(), id: collection.id }, + }); + + expect(res.status).toEqual(200); + }); +}); + +describe('#collections.exportAll', async () => { + it('should require authentication', async () => { + const res = await server.post('/api/collections.exportAll'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should require authorization', async () => { + const user = await buildUser(); + const res = await server.post('/api/collections.exportAll', { + body: { token: user.getJwtToken() }, + }); + expect(res.status).toEqual(403); + }); + + it('should return success', async () => { + const { admin } = await seed(); + const res = await server.post('/api/collections.exportAll', { + body: { token: admin.getJwtToken() }, + }); + + expect(res.status).toEqual(200); + }); +}); + describe('#collections.info', async () => { it('should return collection', async () => { const { user, collection } = await seed(); diff --git a/server/api/documents.js b/server/api/documents.js index 0813df7e7..f06e655f8 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -165,7 +165,12 @@ router.post('documents.info', auth({ required: false }), async ctx => { let document; if (shareId) { - const share = await Share.findById(shareId, { + const share = await Share.find({ + where: { + // $FlowFixMe + revokedAt: { [Op.eq]: null }, + id: shareId, + }, include: [ { model: Document, diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 770fc4555..7abd0547f 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -36,7 +36,7 @@ describe('#documents.info', async () => { expect(body.data.id).toEqual(document.id); }); - it('should return redacted documents from shareId without token', async () => { + it('should return redacted document from shareId without token', async () => { const { document } = await seed(); const share = await buildShare({ documentId: document.id, @@ -55,6 +55,20 @@ describe('#documents.info', async () => { expect(body.data.updatedBy).toEqual(undefined); }); + it('should not return document from revoked shareId', async () => { + const { document, user } = await seed(); + const share = await buildShare({ + documentId: document.id, + teamId: document.teamId, + }); + await share.revoke(user.id); + + const res = await server.post('/api/documents.info', { + body: { shareId: share.id }, + }); + expect(res.status).toEqual(400); + }); + it('should return documents from shareId with token', async () => { const { user, document, collection } = await seed(); const share = await buildShare({ diff --git a/server/api/shares.js b/server/api/shares.js index 677b0222f..15caf93ba 100644 --- a/server/api/shares.js +++ b/server/api/shares.js @@ -1,11 +1,13 @@ // @flow import Router from 'koa-router'; +import Sequelize from 'sequelize'; import auth from '../middlewares/authentication'; import pagination from './middlewares/pagination'; import { presentShare } from '../presenters'; import { Document, User, Share } from '../models'; import policy from '../policies'; +const Op = Sequelize.Op; const { authorize } = policy; const router = new Router(); @@ -14,7 +16,12 @@ router.post('shares.list', auth(), pagination(), async ctx => { if (direction !== 'ASC') direction = 'DESC'; const user = ctx.state.user; - const where = { teamId: user.teamId, userId: user.id }; + const where = { + teamId: user.teamId, + userId: user.id, + // $FlowFixMe + revokedAt: { [Op.eq]: null }, + }; if (user.isAdmin) delete where.userId; @@ -68,15 +75,15 @@ router.post('shares.create', auth(), async ctx => { }; }); -router.post('shares.delete', auth(), async ctx => { +router.post('shares.revoke', auth(), async ctx => { const { id } = ctx.body; ctx.assertPresent(id, 'id is required'); const user = ctx.state.user; const share = await Share.findById(id); - authorize(user, 'delete', share); + authorize(user, 'revoke', share); - await share.destroy(); + await share.revoke(user.id); ctx.body = { success: true, diff --git a/server/api/shares.test.js b/server/api/shares.test.js index 730007779..c768f42ae 100644 --- a/server/api/shares.test.js +++ b/server/api/shares.test.js @@ -32,6 +32,24 @@ describe('#shares.list', async () => { expect(body.data[0].documentTitle).toBe(document.title); }); + it('should not return revoked shares', async () => { + const { user, document } = await seed(); + const share = await buildShare({ + documentId: document.id, + teamId: user.teamId, + userId: user.id, + }); + await share.revoke(user.id); + + const res = await server.post('/api/shares.list', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); + it('admins should only return shares created by all users', async () => { const { admin, document } = await seed(); const share = await buildShare({ @@ -106,3 +124,58 @@ describe('#shares.create', async () => { expect(res.status).toEqual(403); }); }); + +describe('#shares.revoke', async () => { + it('should allow author to revoke a share', async () => { + const { user, document } = await seed(); + const share = await buildShare({ + documentId: document.id, + teamId: user.teamId, + userId: user.id, + }); + + const res = await server.post('/api/shares.revoke', { + body: { token: user.getJwtToken(), id: share.id }, + }); + expect(res.status).toEqual(200); + }); + + it('should allow admin to revoke a share', async () => { + const { user, admin, document } = await seed(); + const share = await buildShare({ + documentId: document.id, + teamId: user.teamId, + userId: user.id, + }); + + const res = await server.post('/api/shares.revoke', { + body: { token: admin.getJwtToken(), id: share.id }, + }); + expect(res.status).toEqual(200); + }); + + it('should require authentication', async () => { + const { user, document } = await seed(); + const share = await buildShare({ + documentId: document.id, + teamId: user.teamId, + userId: user.id, + }); + const res = await server.post('/api/shares.revoke', { + body: { id: share.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should require authorization', async () => { + const { document } = await seed(); + const user = await buildUser(); + const res = await server.post('/api/shares.create', { + body: { token: user.getJwtToken(), documentId: document.id }, + }); + expect(res.status).toEqual(403); + }); +}); diff --git a/server/api/user.js b/server/api/user.js index 0ab1d9a7c..ad1fcb1a9 100644 --- a/server/api/user.js +++ b/server/api/user.js @@ -164,4 +164,22 @@ router.post('user.activate', auth(), async ctx => { }; }); +router.post('user.delete', auth(), async ctx => { + const { confirmation } = ctx.body; + ctx.assertPresent(confirmation, 'confirmation is required'); + + const user = ctx.state.user; + authorize(user, 'delete', user); + + try { + await user.destroy(); + } catch (err) { + throw new ValidationError(err.message); + } + + ctx.body = { + success: true, + }; +}); + export default router; diff --git a/server/api/user.test.js b/server/api/user.test.js index f714eac69..f2f7a5a08 100644 --- a/server/api/user.test.js +++ b/server/api/user.test.js @@ -3,6 +3,7 @@ import TestServer from 'fetch-test-server'; import app from '..'; import { flushdb, seed } from '../test/support'; +import { buildUser } from '../test/factories'; const server = new TestServer(app.callback()); @@ -11,19 +12,60 @@ afterAll(server.close); describe('#user.info', async () => { it('should return known user', async () => { - const { user } = await seed(); + const user = await buildUser(); const res = await server.post('/api/user.info', { body: { token: user.getJwtToken() }, }); const body = await res.json(); expect(res.status).toEqual(200); - expect(body).toMatchSnapshot(); + expect(body.data.id).toEqual(user.id); + expect(body.data.name).toEqual(user.name); }); it('should require authentication', async () => { - await seed(); const res = await server.post('/api/user.info'); + expect(res.status).toEqual(401); + }); +}); + +describe('#user.delete', async () => { + it('should not allow deleting without confirmation', async () => { + const user = await buildUser(); + const res = await server.post('/api/user.delete', { + body: { token: user.getJwtToken() }, + }); + expect(res.status).toEqual(400); + }); + + it('should allow deleting last admin if only user', async () => { + const user = await buildUser({ isAdmin: true }); + const res = await server.post('/api/user.delete', { + body: { token: user.getJwtToken(), confirmation: true }, + }); + expect(res.status).toEqual(200); + }); + + it('should not allow deleting last admin if many users', async () => { + const user = await buildUser({ isAdmin: true }); + await buildUser({ teamId: user.teamId, isAdmin: false }); + + const res = await server.post('/api/user.delete', { + body: { token: user.getJwtToken(), confirmation: true }, + }); + expect(res.status).toEqual(400); + }); + + it('should allow deleting user account with confirmation', async () => { + const user = await buildUser(); + const res = await server.post('/api/user.delete', { + body: { token: user.getJwtToken(), confirmation: true }, + }); + expect(res.status).toEqual(200); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/user.delete'); const body = await res.json(); expect(res.status).toEqual(401); @@ -44,7 +86,6 @@ describe('#user.update', async () => { }); it('should require authentication', async () => { - await seed(); const res = await server.post('/api/user.update'); const body = await res.json(); @@ -67,7 +108,7 @@ describe('#user.promote', async () => { }); it('should require admin', async () => { - const { user } = await seed(); + const user = await buildUser(); const res = await server.post('/api/user.promote', { body: { token: user.getJwtToken(), id: user.id }, }); @@ -96,7 +137,7 @@ describe('#user.demote', async () => { }); it("shouldn't demote admins if only one available ", async () => { - const { admin } = await seed(); + const admin = await buildUser({ isAdmin: true }); const res = await server.post('/api/user.demote', { body: { @@ -111,7 +152,7 @@ describe('#user.demote', async () => { }); it('should require admin', async () => { - const { user } = await seed(); + const user = await buildUser(); const res = await server.post('/api/user.promote', { body: { token: user.getJwtToken(), id: user.id }, }); @@ -139,8 +180,7 @@ describe('#user.suspend', async () => { }); it("shouldn't allow suspending the user themselves", async () => { - const { admin } = await seed(); - + const admin = await buildUser({ isAdmin: true }); const res = await server.post('/api/user.suspend', { body: { token: admin.getJwtToken(), @@ -154,7 +194,7 @@ describe('#user.suspend', async () => { }); it('should require admin', async () => { - const { user } = await seed(); + const user = await buildUser(); const res = await server.post('/api/user.suspend', { body: { token: user.getJwtToken(), id: user.id }, }); @@ -187,7 +227,7 @@ describe('#user.activate', async () => { }); it('should require admin', async () => { - const { user } = await seed(); + const user = await buildUser(); const res = await server.post('/api/user.activate', { body: { token: user.getJwtToken(), id: user.id }, }); diff --git a/server/auth/google.js b/server/auth/google.js index 7553fe52f..c8f28f51e 100644 --- a/server/auth/google.js +++ b/server/auth/google.js @@ -12,6 +12,7 @@ const client = new OAuth2Client( process.env.GOOGLE_CLIENT_SECRET, `${process.env.URL}/auth/google.callback` ); +const allowedDomainsEnv = process.env.GOOGLE_ALLOWED_DOMAINS; // start the oauth process and redirect user to Google router.get('google', async ctx => { @@ -43,6 +44,13 @@ router.get('google.callback', async ctx => { return; } + // allow all domains by default if the env is not set + const allowedDomains = allowedDomainsEnv && allowedDomainsEnv.split(','); + if (allowedDomains && !allowedDomains.includes(profile.data.hd)) { + ctx.redirect('/?notice=hd-not-allowed'); + return; + } + const googleId = profile.data.hd; const teamName = capitalize(profile.data.hd.split('.')[0]); diff --git a/server/emails/ExportEmail.js b/server/emails/ExportEmail.js new file mode 100644 index 000000000..f3d50c352 --- /dev/null +++ b/server/emails/ExportEmail.js @@ -0,0 +1,36 @@ +// @flow +import * as React from 'react'; +import EmailTemplate from './components/EmailLayout'; +import Body from './components/Body'; +import Button from './components/Button'; +import Heading from './components/Heading'; +import Header from './components/Header'; +import Footer from './components/Footer'; +import EmptySpace from './components/EmptySpace'; + +export const exportEmailText = ` +Your Data Export + +Your requested data export is attached as a zip file to this email. +`; + +export const ExportEmail = () => { + return ( + +
+ + + Your Data Export +

+ Your requested data export is attached as a zip file to this email. +

+ +

+ +

+ + +
+ + ); +}; diff --git a/server/emails/WelcomeEmail.js b/server/emails/WelcomeEmail.js index 8d0b27b40..e70594268 100644 --- a/server/emails/WelcomeEmail.js +++ b/server/emails/WelcomeEmail.js @@ -27,7 +27,6 @@ export const WelcomeEmail = () => { Welcome to Outline! -

Outline is a place for your team to build and share knowledge.

To get started, head to your dashboard and try creating a collection @@ -38,9 +37,7 @@ export const WelcomeEmail = () => { You can also import existing Markdown document by drag and dropping them to your collections

- -

- + {releases.map(release => (
@@ -39,14 +39,14 @@ function Changelog({ releases }: { releases: Release[] }) {
))} -
+ ); } const Heading = styled.h1` a { - color: ${color.text}; + color: ${props => props.theme.text}; } a:hover { text-decoration: underline; @@ -54,20 +54,13 @@ const Heading = styled.h1` `; const Time = styled.time` - color: ${color.slateDark}; + color: ${props => props.theme.slateDark}; margin-top: -16px; display: block; `; -const Container = styled.div` - width: 100%; - max-width: 720px; - margin: 0 auto; - padding: 0 2em; -`; - const Article = styled.div` - border-bottom: 1px solid ${color.slateLight}; + border-bottom: 1px solid ${props => props.theme.slateLight}; padding-bottom: 2em; &:last-child { diff --git a/server/pages/Home.js b/server/pages/Home.js index da4eb5e7b..685198b97 100644 --- a/server/pages/Home.js +++ b/server/pages/Home.js @@ -6,12 +6,15 @@ import Grid from 'styled-components-grid'; import breakpoint from 'styled-components-breakpoint'; import Notice from '../../shared/components/Notice'; import Hero from './components/Hero'; +import Centered from './components/Centered'; import SigninButtons from './components/SigninButtons'; -import { developers, githubUrl } from '../../shared/utils/routeHelpers'; -import { color } from '../../shared/styles/constants'; +import SlackLogo from '../../shared/components/SlackLogo'; +import GithubLogo from '../../shared/components/GithubLogo'; +import Flex from '../../shared/components/Flex'; +import { githubUrl, slackAppStoreUrl } from '../../shared/utils/routeHelpers'; type Props = { - notice?: 'google-hd' | 'auth-error', + notice?: 'google-hd' | 'auth-error' | 'hd-not-allowed', lastSignedIn: string, googleSigninEnabled: boolean, slackSigninEnabled: boolean, @@ -21,10 +24,10 @@ function Home(props: Props) { return ( - Outline - Team wiki & documentation + Outline - Team wiki & knowledge base - +

Your team’s knowledge base

Team wiki, documentation, meeting notes, playbooks, onboarding, work @@ -39,6 +42,12 @@ function Home(props: Props) { try signing in with your company Google account. )} + {props.notice === 'hd-not-allowed' && ( + + Sorry, your Google apps domain is not allowed. Please try again + with an allowed company domain. + + )} {props.notice === 'auth-error' && ( Authentication failed - we were unable to sign you in at this @@ -46,75 +55,107 @@ function Home(props: Props) { )}
- - - -

Blazing Fast Wiki

+ + + + + + +

Improve Communication

+

+ Easily structure your teams information in one central, + structured location. No more hunting through folders or + scanning pages of search results and chat conversations. +

+
+ +

Safe & Secure

+

+ Outline provides a secure place for your teams + documentation on our hosted platform, stored in portable + Markdown format. Or, you can run your own copy – it’s open + source! +

+
+
+ + + +
+
+
+
+ + + +

Blazing Fast

Outline is fast, really fast. We’ve worked hard to ensure - millisecond response times, documents load instantly, search is + millisecond response times – documents load instantly, search is speedy and navigating the UI is delightful.

- -

# Markdown Support

+ + +

Markdown Support

- Outline stores, imports and exports all documents in plain - Markdown. Shortcuts are also built right into the editor so you - can easily format using **markdown syntax** if - you like. + Documents are stored in plain Markdown making editing, import + and export painless. Shortcuts are also built right into the + editor so you can easily format using **markdown syntax** if you + like.

-
- - - -
- - -

Slack integration

-

- Keep your team up to date and informed with Slack notifications - about newly published documents. You can also search Outline - directly within Slack using /outline <keyword>{' '} - command. -

-
- -

Open Source

-

- Outline is open source, so the community can help improve it too. - You get new features, interface improvements, and bug fixes for - free. -

-

- GitHub -

-
- -

Integrations & API

-

- All of Outline’s functionality is available through the API. - Migrating Markdown documents or setting up automations is a breeze - with a few lines of code. -

-

- Documentation -

-
-
+ +

+  Slack & API +

+

+ Get Slack notifications about changes and search Outline + directly within Slack using the{' '} + /outline <keyword> command. Access your + information programatically through the modern API. +

+

+ Slack App +

+
+ + +

+  Open Source +

+

+ Outline is open source, so the community can help improve it + too. You get new features, interface improvements, bug fixes, + and a transparent roadmap for free. +

+

+ GitHub +

+
+
+
-

Create an account

-

- On the same page as us? Create a free account to give Outline a try. -

- - - + + + +

Create an account

+

+ On the same page as us? Create a free account to give Outline + a try with your team. +

+
+ + + + + +
+
@@ -128,52 +169,75 @@ const Screenshot = styled.img` border-radius: 5px; ${breakpoint('desktop')` - width: 150%; + margin-top: -120px; + margin-left: 120px; + width: 135%; `}; `; -const Highlights = styled(Grid)` - background: ${color.yellow}; - margin: 0 1em; - padding: 0 1em; +const Mask = styled.div` + width: 100%; + overflow: hidden; + padding: 8em 0; `; -const Features = styled(Grid)` +const Features = styled.div` + background: hsl(180, 58%, 85%); padding: 0 2em; - overflow: hidden; + width: 100%; `; const Feature = styled(Grid.Unit)` - padding: 4em 3em; + padding: 2em 0; + + p { + font-weight: 500; + opacity: 0.8; + } h2 { + display: flex; + font-size: 1.8em; + align-items: center; margin-top: 0; } a { - color: ${color.black}; + color: ${props => props.theme.black}; text-decoration: underline; text-transform: uppercase; font-weight: 500; font-size: 14px; } + + ${breakpoint('tablet')` + padding: 4em 0; + `}; `; const Footer = styled.div` - text-align: center; + background: hsl(127, 58%, 85%); + text-align: left; width: 100%; - padding: 6em 4em; -`; + padding: 4em 2em; -const FooterCTA = styled.p` - padding-top: 1em; + h2 { + font-size: 1.8em; + margin-top: 0; + } + + ${breakpoint('tablet')` + margin: 2em 0; + padding: 6em 4em; + `}; `; const HeroText = styled.p` - font-size: 18px; + font-size: 22px; + color: #666; + font-weight: 500; + text-align: left; max-width: 600px; - margin-left: auto; - margin-right: auto; margin-bottom: 2em; `; diff --git a/server/pages/Privacy.js b/server/pages/Privacy.js index 68ea41f5e..429e73a12 100644 --- a/server/pages/Privacy.js +++ b/server/pages/Privacy.js @@ -9,10 +9,11 @@ export default function Privacy() { return ( - Privacy policy + Privacy Policy
-

Privacy policy

+

Privacy Policy

+

How we collect and use your information

diff --git a/server/pages/components/Centered.js b/server/pages/components/Centered.js new file mode 100644 index 000000000..62cdd3c71 --- /dev/null +++ b/server/pages/components/Centered.js @@ -0,0 +1,9 @@ +// @flow +import styled from 'styled-components'; + +const Centered = styled.div` + margin: 0 auto; + max-width: 1000px; +`; + +export default Centered; diff --git a/server/pages/components/Content.js b/server/pages/components/Content.js index fd449e555..587298a2c 100644 --- a/server/pages/components/Content.js +++ b/server/pages/components/Content.js @@ -3,9 +3,9 @@ import styled from 'styled-components'; export default styled.div` width: 100%; - max-width: 720px; + max-width: 1000px; margin: 0 auto 2em; - padding: 0 2em; + font-size: 1.1em; li { padding: 0.2em 0; diff --git a/server/pages/components/Header.js b/server/pages/components/Header.js index 73ebbf8af..64a22b45d 100644 --- a/server/pages/components/Header.js +++ b/server/pages/components/Header.js @@ -1,21 +1,36 @@ // @flow +import * as React from 'react'; import styled from 'styled-components'; -import { color } from '../../../shared/styles/constants'; +import Centered from './Centered'; -const Header = styled.div` +type Props = { + children: React.Node, +}; + +const Header = ({ children }: Props) => { + return ( + + {children} + + ); +}; + +const Wrapper = styled.div` width: 100%; - padding: 0 2em 2em; - text-align: center; - background: ${color.slateLight}; + padding: 2em; + background: ${props => props.theme.contentHeaderBackground}; margin-bottom: 2em; p { - max-width: 720px; - margin: 0 auto; + font-size: 22px; + font-weight: 500; + color: rgba(0, 0, 0, 0.6); + margin: 0; } h1 { - font-size: 2.5em; + font-size: 3.5em; + margin: 0 0 0.1em; } `; diff --git a/server/pages/components/Hero.js b/server/pages/components/Hero.js index 65552d7ff..c1cc4116c 100644 --- a/server/pages/components/Hero.js +++ b/server/pages/components/Hero.js @@ -1,16 +1,15 @@ // @flow import styled from 'styled-components'; +import Centered from './Centered'; -const Hero = styled.div` +const Hero = styled(Centered)` width: 100%; - height: 70vh; - min-height: 400px; - max-height: 600px; - padding: 6em 2em 0; - text-align: center; + min-height: 500px; + padding: 4em 0 0; h1 { - font-size: 2.5em; + font-size: 3.5em; + line-height: 1em; } `; diff --git a/server/pages/components/Layout.js b/server/pages/components/Layout.js index c6e9a0d8a..97d04ce5e 100644 --- a/server/pages/components/Layout.js +++ b/server/pages/components/Layout.js @@ -1,10 +1,11 @@ // @flow import * as React from 'react'; import { Helmet } from 'react-helmet'; +import styled from 'styled-components'; +import breakpoint from 'styled-components-breakpoint'; import { TopNavigation, BottomNavigation } from './Navigation'; import Analytics from '../../../shared/components/Analytics'; import globalStyles from '../../../shared/styles/globals'; -import { color } from '../../../shared/styles/constants'; import prefetchTags from '../../utils/prefetchTags'; export const title = 'Outline'; @@ -16,7 +17,7 @@ type Props = { children?: React.Node, }; -export default function Layout({ children }: Props) { +function Layout({ children }: Props) { globalStyles(); return ( @@ -29,6 +30,7 @@ export default function Layout({ children }: Props) { + @@ -43,7 +45,7 @@ export default function Layout({ children }: Props) { - + - + {children} - + ); } + +const Body = styled.body` + padding: 0 30px; + + ${breakpoint('tablet')` + padding: 0; + `}; +`; + +export default Layout; diff --git a/server/pages/components/Navigation.js b/server/pages/components/Navigation.js index 508eca63d..7458563bf 100644 --- a/server/pages/components/Navigation.js +++ b/server/pages/components/Navigation.js @@ -2,8 +2,8 @@ import * as React from 'react'; import styled from 'styled-components'; import breakpoint from 'styled-components-breakpoint'; +import Centered from './Centered'; import { - signin, developers, changelog, about, @@ -11,9 +11,7 @@ import { githubUrl, twitterUrl, spectrumUrl, - blogUrl, } from '../../../shared/utils/routeHelpers'; -import { color } from '../../../shared/styles/constants'; function TopNavigation() { return ( @@ -27,16 +25,16 @@ function TopNavigation() { About - Twitter + Changelog - Changelog + Twitter API - Sign In + Sign In @@ -55,9 +53,6 @@ function BottomNavigation() {

-
- Medium -
@@ -65,16 +60,16 @@ function BottomNavigation() { ); } -const MenuLinkStyle = ` +const MenuLinkStyle = props => ` font-size: 15px; font-weight: 500; a { - color: ${color.slate}; + color: ${props.theme.slate}; } a:hover { - color: ${color.slateDark}; + color: ${props.theme.slateDark}; text-decoration: underline; } `; @@ -104,9 +99,9 @@ const MenuItemDesktop = styled(MenuItem)` `}; `; -const Nav = styled.nav` +const Nav = styled(Centered)` display: flex; - padding: 20px 30px; + padding: 20px 0; align-items: center; justify-content: space-between; `; @@ -116,7 +111,7 @@ const BottomNav = styled.nav` justify-content: center; align-items: center; flex-direction: column; - margin-bottom: 40px; + margin: 4em 0; > div { display: flex; @@ -126,6 +121,7 @@ const BottomNav = styled.nav` ${breakpoint('tablet')` flex-direction: row; + margin: 0 0 4em; > div { margin: 0 0 0 40px; @@ -141,7 +137,7 @@ const Brand = styled.a` font-weight: 600; font-size: 20px; text-decoration: none; - color: ${color.black}; + color: ${props => props.theme.black}; `; export { TopNavigation, BottomNavigation }; diff --git a/server/pages/components/SigninButtons.js b/server/pages/components/SigninButtons.js index acd84f0d3..74ae660db 100644 --- a/server/pages/components/SigninButtons.js +++ b/server/pages/components/SigninButtons.js @@ -5,7 +5,7 @@ import { signin } from '../../../shared/utils/routeHelpers'; import Flex from '../../../shared/components/Flex'; import GoogleLogo from '../../../shared/components/GoogleLogo'; import SlackLogo from '../../../shared/components/SlackLogo'; -import { color } from '../../../shared/styles/constants'; +import breakpoint from 'styled-components-breakpoint'; type Props = { lastSignedIn: string, @@ -19,9 +19,9 @@ const SigninButtons = ({ googleSigninEnabled, }: Props) => { return ( - + {slackSigninEnabled && ( - +