Merge master, small refactor

This commit is contained in:
Tom Moor
2018-07-15 07:58:58 -07:00
179 changed files with 2521 additions and 1121 deletions

View File

@@ -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

View File

@@ -8,3 +8,4 @@ COPY . $APP_PATH
RUN yarn
RUN cp -r /opt/outline/node_modules /opt/node_modules
CMD yarn build && yarn start

View File

@@ -10,6 +10,7 @@
<img src="https://circleci.com/gh/outline/outline.svg?style=shield&amp;circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a" alt="" data-canonical-src="" style="max-width:100%;">
<a href="https://spectrum.chat/outline" rel="nofollow"><img src="https://withspectrum.github.io/badge/badge.svg" alt="Join the community on Spectrum"/></a>
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat"></a>
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg"></a>
</p>
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 youre looking for ways to get started, here's a list of ways to help us improve Outline:

View File

@@ -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;
`};
`;

View File

@@ -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;

View File

@@ -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;
`;

View File

@@ -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)};
}
`};
`;

View File

@@ -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;

View File

@@ -31,22 +31,21 @@ const Collaborators = ({ document }: Props) => {
return (
<Avatars>
<StyledTooltip tooltip={tooltip} placement="bottom">
{collaborators.map(user => (
<AvatarWrapper key={user.id}>
{collaborators.map(user => (
<Tooltip
tooltip={collaborators.length > 1 ? user.name : tooltip}
placement="bottom"
key={user.id}
>
<AvatarWrapper>
<Avatar src={user.avatarUrl} />
</AvatarWrapper>
))}
</StyledTooltip>
</Tooltip>
))}
</Avatars>
);
};
const StyledTooltip = styled(Tooltip)`
display: flex;
flex-direction: row-reverse;
`;
const AvatarWrapper = styled.div`
width: 24px;
height: 24px;

View File

@@ -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;
}
`;

View File

@@ -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 }) => (
<StarredIcon color={solid ? color.black : color.text} {...props} />
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
<StarredIcon color={solid ? theme.black : theme.text} {...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};
}
`;

View File

@@ -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<Props> {
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 (
<Container align="center">
{publishedAt === updatedAt ? (
<span>
{createdBy.name} published {timeAgo}
</span>
) : (
<React.Fragment>
{updatedBy.name}
{publishedAt ? (
<Modified highlight={modifiedSinceViewed}>
&nbsp;modified {timeAgo}
</Modified>
) : (
<span>&nbsp;saved {timeAgo}</span>
)}
</React.Fragment>
)}
{collection && (
<span>
&nbsp;in <strong>{collection.name}</strong>
</span>
)}
</Container>
);
}
return (
<Container align="center">
{publishedAt && publishedAt === updatedAt ? (
<span>
{updatedBy.name} published <Time dateTime={publishedAt} /> ago
</span>
) : (
<React.Fragment>
{updatedBy.name}
{publishedAt ? (
<Modified highlight={modifiedSinceViewed}>
&nbsp;modified <Time dateTime={updatedAt} /> ago
</Modified>
) : (
<span>
&nbsp;saved <Time dateTime={updatedAt} /> ago
</span>
)}
</React.Fragment>
)}
{collection && (
<span>
&nbsp;in <strong>{collection.name}</strong>
</span>
)}
</Container>
);
}
export default PublishingInfo;

View File

@@ -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;
}
`;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
`;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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};
}
`;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<Props> {
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)};
`};
`;

View File

@@ -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`

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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<Props> {
<Modal name="collection-delete" title="Delete collection">
<CollectionDelete onSubmit={this.handleClose} />
</Modal>
<Modal name="collection-export" title="Export collection">
<CollectionExport onSubmit={this.handleClose} />
</Modal>
<Modal name="document-share" title="Share document">
<DocumentShare onSubmit={this.handleClose} />
</Modal>

View File

@@ -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() {

View File

@@ -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<Props> {
componentDidMount() {
this.props.documents.fetchDrafts();
}
handleCreateCollection = () => {
this.props.ui.setActiveModal('collection-new');
};
@@ -67,7 +72,7 @@ class MainSidebar extends React.Component<Props> {
documents.active ? !documents.active.publishedAt : undefined
}
>
Drafts
Drafts <Bubble count={documents.drafts.length} />
</SidebarLink>
</Section>
<Section>

View File

@@ -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<Props> {
Integrations
</SidebarLink>
)}
{user.isAdmin && (
<SidebarLink to="/settings/export" icon={<DocumentIcon />}>
Export Data
</SidebarLink>
)}
</Section>
</Scrollable>
</Flex>

View File

@@ -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;

View File

@@ -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 && <Wrapper>{count}</Wrapper>;
};
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;

View File

@@ -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;
`;

View File

@@ -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 (
<Header justify="flex-start" align="center" {...rest}>
<TeamLogo src={logoUrl} />
<TeamLogo alt={`${teamName} logo`} src={logoUrl} />
<Flex align="flex-start" column>
<TeamName showDisclosure>
{teamName}{' '}
{showDisclosure && <StyledExpandedIcon color={color.text} />}
{showDisclosure && <StyledExpandedIcon color={theme.text} />}
</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
@@ -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);

View File

@@ -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<Props> {
@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> {
<Wrapper menuOpen={menuOpen} column>
<Component
icon={showExpandIcon}
activeStyle={activeStyle}
style={active ? activeStyle : undefined}
activeStyle={this.activeStyle}
style={active ? this.activeStyle : undefined}
onClick={onClick}
to={to}
exact
@@ -128,7 +133,7 @@ const Action = styled.span`
position: absolute;
right: 0;
top: 2px;
color: ${color.slate};
color: ${props => 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));

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<Props> {
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;

View File

@@ -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])};
}
`;

View File

@@ -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(
<React.Fragment>
<ErrorBoundary>
<Provider {...stores}>
<Router>
<ScrollToTop>
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/share/:shareId" component={Document} />
<Auth>
<Layout>
<Switch>
<Route exact path="/dashboard" component={Dashboard} />
<Route exact path="/starred" component={Starred} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/settings" component={Settings} />
<Route
exact
path="/settings/details"
component={Details}
/>
<Route exact path="/settings/people" component={People} />
<Route exact path="/settings/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} />
<Route
exact
path="/settings/integrations/slack"
component={Slack}
/>
<Route
exact
path="/collections/:id"
component={Collection}
/>
<Route
exact
path={`/d/${matchDocumentSlug}`}
component={RedirectDocument}
/>
<Route
exact
path={`/doc/${matchDocumentSlug}`}
component={Document}
/>
<Route
exact
path={`/doc/${matchDocumentSlug}/move`}
component={Document}
/>
<Route exact path="/search" component={Search} />
<Route exact path="/search/:query" component={Search} />
<Route path="/404" component={Error404} />
<RouteSidebarHidden
exact
path={`/doc/${matchDocumentSlug}/edit`}
component={Document}
/>
<RouteSidebarHidden
exact
path="/collections/:id/new"
component={DocumentNew}
/>
<Route component={notFoundSearch} />
</Switch>
</Layout>
</Auth>
</Switch>
</ScrollToTop>
</Router>
</Provider>
<ThemeProvider theme={theme}>
<Provider {...stores}>
<Router>
<ScrollToTop>
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/share/:shareId" component={Document} />
<Auth>
<Layout>
<Switch>
<Route exact path="/dashboard" component={Dashboard} />
<Route exact path="/starred" component={Starred} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/settings" component={Settings} />
<Route
exact
path="/settings/details"
component={Details}
/>
<Route
exact
path="/settings/people"
component={People}
/>
<Route
exact
path="/settings/shares"
component={Shares}
/>
<Route
exact
path="/settings/tokens"
component={Tokens}
/>
<Route
exact
path="/settings/integrations/slack"
component={Slack}
/>
<Route
exact
path="/settings/export"
component={Export}
/>
<Route
exact
path="/collections/:id"
component={Collection}
/>
<Route
exact
path={`/d/${matchDocumentSlug}`}
component={RedirectDocument}
/>
<Route
exact
path={`/doc/${matchDocumentSlug}`}
component={Document}
/>
<Route
exact
path={`/doc/${matchDocumentSlug}/move`}
component={Document}
/>
<Route exact path="/search" component={Search} />
<Route exact path="/search/:query" component={Search} />
<Route path="/404" component={Error404} />
<RouteSidebarHidden
exact
path={`/doc/${matchDocumentSlug}/edit`}
component={Document}
/>
<RouteSidebarHidden
exact
path="/collections/:id/new"
component={DocumentNew}
/>
<Route component={notFoundSearch} />
</Switch>
</Layout>
</Auth>
</Switch>
</ScrollToTop>
</Router>
</Provider>
</ThemeProvider>
</ErrorBoundary>
{DevTools && <DevTools position={{ bottom: 0, right: 0 }} />}
</React.Fragment>,

View File

@@ -61,6 +61,12 @@ class CollectionMenu extends React.Component<Props> {
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<Props> {
</DropdownMenuItem>
<hr />
<DropdownMenuItem onClick={this.onEdit}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={this.onExport}>
Export
</DropdownMenuItem>
</React.Fragment>
)}
<DropdownMenuItem onClick={this.onDelete}>Delete</DropdownMenuItem>

View File

@@ -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<Props> {
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 (
<DropdownMenu
label={label || <MoreIcon />}
onOpen={this.onOpen}
{...rest}
>
<DropdownMenuItem disabled>Choose a collection</DropdownMenuItem>
{collections.orderedData.map(collection => (
<DropdownMenuItem
key={collection.id}
onClick={() => this.handleNewDocument(collection)}
>
<CollectionIcon color={collection.color} /> {collection.name}
</DropdownMenuItem>
))}
</DropdownMenu>
);
}
}
export default withRouter(inject('collections')(NewDocumentMenu));

View File

@@ -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;

View File

@@ -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) {

View File

@@ -1,3 +0,0 @@
// @flow
import Collection from './Collection';
export default Collection;

View File

@@ -1,3 +0,0 @@
// @flow
import CollectionDelete from './CollectionDelete';
export default CollectionDelete;

View File

@@ -1,3 +0,0 @@
// @flow
import CollectionEdit from './CollectionEdit';
export default CollectionEdit;

View File

@@ -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<Props> {
@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 (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Exporting the collection <strong>{collection.name}</strong> may take
a few minutes. Well put together a zip file of your documents in
Markdown format and email it to <strong>{auth.user.email}</strong>.
</HelpText>
<Button type="submit" disabled={this.isLoading} primary>
{this.isLoading ? 'Requesting Export…' : 'Export Collection'}
</Button>
</form>
</Flex>
);
}
}
export default inject('ui', 'auth')(CollectionExport);

View File

@@ -1,3 +0,0 @@
// @flow
import CollectionNew from './CollectionNew';
export default CollectionNew;

View File

@@ -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<Props> {
<PageTitle title="Home" />
<h1>Home</h1>
{showContent ? (
<span>
{hasRecentlyViewed && [
<Subheading key="viewed">Recently viewed</Subheading>,
<DocumentList
key="viewedDocuments"
documents={documents.recentlyViewed}
/>,
]}
{hasRecentlyEdited && [
<Subheading key="edited">Recently edited</Subheading>,
<DocumentList
key="editedDocuments"
documents={documents.recentlyEdited}
/>,
]}
</span>
<React.Fragment>
{hasRecentlyViewed && (
<React.Fragment>
<Subheading key="viewed">Recently viewed</Subheading>
<DocumentList
key="viewedDocuments"
documents={documents.recentlyViewed}
showCollection
/>
</React.Fragment>
)}
{hasRecentlyEdited && (
<React.Fragment>
<Subheading key="edited">Recently edited</Subheading>
<DocumentList
key="editedDocuments"
documents={documents.recentlyEdited}
showCollection
/>
</React.Fragment>
)}
<Actions align="center" justify="flex-end">
<Action>
<NewDocumentMenu label={<NewDocumentIcon />} />
</Action>
</Actions>
</React.Fragment>
) : (
<ListPlaceholder count={5} />
)}

View File

@@ -1,3 +0,0 @@
// @flow
import Dashboard from './Dashboard';
export default Dashboard;

View File

@@ -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<Props> {
savedTimeout: TimeoutID;
@@ -79,7 +65,7 @@ class DocumentScene extends React.Component<Props> {
@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<Props> {
};
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<Props> {
}, 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<Props> {
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<Props> {
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<Props> {
);
}
let favicon;
if (document && document.emoji) {
favicon = `https://twemoji.maxcdn.com/2/72x72/${toCodePoint(
document.emoji
)}.png`;
}
return (
<Container key={document ? document.id : undefined} column auto>
{isMoving && document && <DocumentMove document={document} />}
{titleText && (
<PageTitle
title={titleText.replace(document.emoji, '')}
favicon={favicon}
/>
)}
{(this.isLoading || this.isSaving) && <LoadingIndicator />}
{!document || !Editor ? (
if (!document || !Editor) {
return (
<Container column auto>
<PageTitle title={location.state ? location.state.title : ''} />
<CenteredContent>
<LoadingState />
</CenteredContent>
) : (
<Flex justify="center" auto>
{this.isEditing && (
</Container>
);
}
return (
<Container key={document.id} column auto>
{isMoving && <DocumentMove document={document} />}
<PageTitle
title={document.title.replace(document.emoji, '')}
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
/>
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
<Container justify="center" column auto>
{this.isEditing && (
<React.Fragment>
<Prompt
when={document.hasPendingChanges}
message={DISCARD_CHANGES}
/>
)}
<MaxWidth column auto>
<Editor
titlePlaceholder="Start with a title…"
bodyPlaceholder="…the rest is your canvas"
defaultValue={document.text}
pretitle={document.emoji}
uploadImage={this.onUploadImage}
onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop}
onSearchLink={this.onSearchLink}
onClickLink={this.onClickLink}
onChange={this.onChange}
onSave={this.onSave}
onCancel={this.onDiscard}
readOnly={!this.isEditing}
toc
/>
</MaxWidth>
{document &&
!isShare && (
<Actions
document={document}
isDraft={!document.publishedAt}
isEditing={this.isEditing}
isSaving={this.isSaving}
isPublishing={this.isPublishing}
savingIsDisabled={!document.allowSave}
history={this.props.history}
onDiscard={this.onDiscard}
onSave={this.onSave}
/>
)}
</Flex>
)}
<Prompt when={this.isUploading} message={UPLOADING_WARNING} />
</React.Fragment>
)}
{!isShare && (
<Header
document={document}
isDraft={!document.publishedAt}
isEditing={this.isEditing}
isSaving={this.isSaving}
isPublishing={this.isPublishing}
savingIsDisabled={!document.allowSave}
history={this.props.history}
onDiscard={this.onDiscard}
onSave={this.onSave}
/>
)}
<MaxWidth column auto>
<Editor
titlePlaceholder="Start with a title…"
bodyPlaceholder="…the rest is your canvas"
defaultValue={document.text}
pretitle={document.emoji}
uploadImage={this.onUploadImage}
onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop}
onSearchLink={this.onSearchLink}
onClickLink={this.onClickLink}
onChange={this.onChange}
onSave={this.onSave}
onCancel={this.onDiscard}
readOnly={!this.isEditing}
toc
/>
</MaxWidth>
</Container>
</Container>
);
}
}
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));

View File

@@ -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<Props> {
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 (
<Actions align="center" justify="flex-end" readOnly={!isEditing}>
{!isDraft && !isEditing && <Collaborators document={document} />}
{isDraft && (
<Action>
<Link
onClick={this.handlePublish}
title="Publish document (Cmd+Enter)"
disabled={savingIsDisabled}
highlight
>
{isPublishing ? 'Publishing…' : 'Publish'}
</Link>
</Action>
)}
{isEditing && (
<React.Fragment>
<Action>
<Link
onClick={this.handleSave}
title="Save changes (Cmd+Enter)"
disabled={savingIsDisabled}
isSaving={isSaving}
highlight={!isDraft}
>
{isSaving && !isPublishing ? 'Saving…' : 'Save'}
</Link>
</Action>
{isDraft && <Separator />}
</React.Fragment>
)}
{!isEditing && (
<Action>
<a onClick={this.handleEdit}>Edit</a>
</Action>
)}
{isEditing && (
<Action>
<a onClick={this.props.onDiscard}>
{document.hasPendingChanges ? 'Discard' : 'Done'}
</a>
</Action>
)}
{!isEditing && (
<Action>
<DocumentMenu document={document} showPrint />
</Action>
)}
{!isEditing &&
!isDraft && (
<React.Fragment>
<Separator />
<Action>
<a onClick={this.handleNewDocument}>
<NewDocumentIcon />
</a>
</Action>
</React.Fragment>
)}
</Actions>
);
}
}
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;

View File

@@ -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 (
<Wrapper justify="flex-start" align="center">
<CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon color={collection.color} expanded />{' '}
<span>{collection.name}</span>
</CollectionName>
{path.map(n => (
<React.Fragment key={n.id}>
<Slash /> <Crumb to={n.url}>{n.title}</Crumb>
</React.Fragment>
))}
</Wrapper>
);
});
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);

View File

@@ -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<Props> {
}
const Section = styled(Flex)`
margin-bottom: ${size.huge};
margin-bottom: 24px;
`;
const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`

View File

@@ -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};
}
}
`;

View File

@@ -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<Props> {
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 <Placeholder>{editor.props.readOnly ? '' : text}</Placeholder>;
};
render() {
const { readOnly } = this.props;
return (
<React.Fragment>
<StyledEditor
innerRef={this.setEditorRef}
renderPlaceholder={this.renderPlaceholder}
schema={schema}
{...this.props}
/>
<ClickablePadding
onClick={!readOnly ? this.focusAtEnd : undefined}
grow
/>
</React.Fragment>
);
}
}
// 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;

View File

@@ -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<Props> {
@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 (
<Actions
align="center"
justify="space-between"
readOnly={!isEditing}
isCompact={this.isScrolled}
>
<Breadcrumb document={document} />
<Title isHidden={!this.isScrolled} onClick={this.handleClickTitle}>
{document.title}
</Title>
<Wrapper align="center" justify="flex-end">
{!isDraft && !isEditing && <Collaborators document={document} />}
{isSaving &&
!isPublishing && (
<Action>
<Status>Saving</Status>
</Action>
)}
{isDraft && (
<Action>
<Link
onClick={this.handlePublish}
title="Publish document (Cmd+Enter)"
disabled={savingIsDisabled}
highlight
>
{isPublishing ? 'Publishing…' : 'Publish'}
</Link>
</Action>
)}
{isEditing && (
<React.Fragment>
<Action>
<Link
onClick={this.handleSave}
title="Save changes (Cmd+Enter)"
disabled={savingIsDisabled}
isSaving={isSaving}
highlight={!isDraft}
>
{isDraft ? 'Save Draft' : 'Done'}
</Link>
</Action>
</React.Fragment>
)}
{!isEditing && (
<Action>
<Link onClick={this.handleEdit}>Edit</Link>
</Action>
)}
{isEditing &&
!isSaving &&
document.hasPendingChanges && (
<Action>
<Link onClick={this.props.onDiscard}>Discard</Link>
</Action>
)}
{!isEditing && (
<Action>
<DocumentMenu document={document} showPrint />
</Action>
)}
{!isEditing &&
!isDraft && (
<React.Fragment>
<Separator />
<Action>
<a onClick={this.handleNewDocument}>
<NewDocumentIcon />
</a>
</Action>
</React.Fragment>
)}
</Wrapper>
</Actions>
);
}
}
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;

View File

@@ -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;
`;

View File

@@ -1,3 +0,0 @@
// @flow
import DocumentDelete from './DocumentDelete';
export default DocumentDelete;

View File

@@ -1,3 +0,0 @@
// @flow
import DocumentShare from './DocumentShare';
export default DocumentShare;

View File

@@ -1,3 +0,0 @@
// @flow
import Drafts from './Drafts';
export default Drafts;

View File

@@ -1,3 +0,0 @@
// @flow
import ErrorSuspended from './ErrorSuspended';
export default ErrorSuspended;

View File

@@ -1,3 +0,0 @@
// @flow
import Home from './Home';
export default Home;

View File

@@ -1,3 +0,0 @@
// @flow
import KeyboardShortcuts from './KeyboardShortcuts';
export default KeyboardShortcuts;

View File

@@ -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<Props> {
@@ -31,7 +30,7 @@ class SearchField extends React.Component<Props> {
<StyledIcon
type="Search"
size={46}
color={color.slateLight}
color={this.props.theme.slateLight}
onClick={this.focusInput}
/>
<StyledInput
@@ -56,16 +55,16 @@ const StyledInput = styled.input`
border: 0;
::-webkit-input-placeholder {
color: ${color.slateLight};
color: ${props => 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);

View File

@@ -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<Props> {
}
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};
}
`;

View File

@@ -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<Props> {
@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 (
<CenteredContent>
<PageTitle title="Export Data" />
<h1>Export Data</h1>
<HelpText>
Exporting your teams documents may take a little time depending on the
size of your knowledgebase. Consider exporting a single document or
collection instead.
</HelpText>
<HelpText>
Still want to export everything in your wiki? Well put together a zip
file of your collections and documents in Markdown format and email it
to <strong>{auth.user.email}</strong>.
</HelpText>
<Button
type="submit"
onClick={this.handleSubmit}
disabled={this.isLoading || this.isExporting}
primary
>
{this.isExporting
? 'Export Requested'
: this.isLoading ? 'Requesting Export…' : 'Export All Data'}
</Button>
</CenteredContent>
);
}
}
export default inject('auth', 'ui', 'collections')(Export);

View File

@@ -32,9 +32,8 @@ class People extends React.Component<Props> {
<PageTitle title="People" />
<h1>People</h1>
<HelpText>
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. Its possible
that there are other people who have access but havent signed in yet.
</HelpText>
<List>
@@ -42,7 +41,7 @@ class People extends React.Component<Props> {
<UserListItem
key={user.id}
user={user}
isCurrentUser={currentUser.id === user.id}
showMenu={!!currentUser.isAdmin && currentUser.id !== user.id}
/>
))}
</List>

View File

@@ -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<Props> {
@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<Props> {
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<Props> {
{isSaving ? 'Saving…' : 'Save'}
</Button>
</form>
<DangerZone>
<LabelText>Delete Account</LabelText>
<p>
You may delete your account at any time, note that this is
unrecoverable.{' '}
<a onClick={this.toggleDeleteAccount}>Delete account</a>.
</p>
</DangerZone>
{this.showDeleteModal && (
<UserDelete onRequestClose={this.toggleDeleteAccount} />
)}
</CenteredContent>
);
}
}
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};
}
`;

View File

@@ -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={
<React.Fragment>
Shared{' '}
<time dateTime={share.createdAt}>
{distanceInWordsToNow(new Date(share.createdAt))}
</time>{' '}
ago by {share.createdBy.name}
Shared <Time dateTime={share.createdAt} /> ago by{' '}
{share.createdBy.name}
</React.Fragment>
}
actions={<ShareMenu share={share} />}

View File

@@ -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;

View File

@@ -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 (
<ListItem
key={user.id}
@@ -21,12 +21,13 @@ const UserListItem = ({ user, isCurrentUser }: Props) => {
image={<Avatar src={user.avatarUrl} size={40} />}
subtitle={
<React.Fragment>
{user.username ? user.username : user.email}
{user.email ? `${user.email} · ` : undefined}
Joined <Time dateTime={user.createdAt} /> ago
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
{user.isSuspended && <Badge>Suspended</Badge>}
</React.Fragment>
}
actions={isCurrentUser ? undefined : <UserMenu user={user} />}
actions={showMenu ? <UserMenu user={user} /> : 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;

View File

@@ -1,3 +0,0 @@
// @flow
import Starred from './Starred';
export default Starred;

62
app/scenes/UserDelete.js Normal file
View File

@@ -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<Props> {
@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 (
<Modal isOpen title="Delete Account" {...rest}>
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
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.
</HelpText>
<HelpText>
<strong>Note:</strong> Signing back in will cause a new account to
be automatically reprovisioned.
</HelpText>
<Button type="submit" danger>
{this.isDeleting ? 'Deleting…' : 'Delete My Account'}
</Button>
</form>
</Flex>
</Modal>
);
}
}
export default inject('auth')(UserDelete);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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<NavigationNode>,
children: NavigationNode[],
};
export type Document = {
collaborators: Array<User>,
collaborators: User[],
collection: Object,
createdAt: string,
createdBy: User,

24
app/utils/emoji.js Normal file
View File

@@ -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`;
}

View File

@@ -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<string>;
declare function genSalt(): Promise<string>;
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<string>;
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<boolean>;
declare function compare(
data: string,
encrypted: string,
callback: (err: Error, same: boolean) => void
): void;
declare function getRounds(encrypted: string): number;
}

View File

@@ -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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 735 B

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 569 B

Some files were not shown because too many files have changed in this diff Show More