Merge master

This commit is contained in:
Tom Moor
2017-07-08 22:30:20 -07:00
58 changed files with 828 additions and 524 deletions

View File

@ -1,4 +1,4 @@
require('localenv'); require('dotenv').config({ silent: true });
var path = require('path'); var path = require('path');

View File

@ -1,5 +1,7 @@
# Atlas # Atlas
![](https://circleci.com/gh/jorilallo/atlas.svg?style=shield&circle-token=c0c4c2f39990e277385d5c1ae96169c409eb887a)
## Installation ## Installation
1. Install dependencies with `yarn` 1. Install dependencies with `yarn`

24
circle.yml Normal file
View File

@ -0,0 +1,24 @@
machine:
node:
version: 7.6
services:
- redis
environment:
ENVIRONMENT: test
PATH: "${PATH}:${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin"
SEQUELIZE_SECRET: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
DATABASE_URL_TEST: postgres://ubuntu@localhost:5432/circle_test
DATABASE_URL: postgres://ubuntu@localhost:5432/circle_test
dependencies:
override:
- yarn
cache_directories:
- ~/.cache/yarn
test:
pre:
- sequelize db:migrate --url postgres://ubuntu@localhost:5432/circle_test
override:
- yarn test
- yarn lint

View File

@ -1,6 +1,6 @@
// @flow // @flow
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Flex } from 'reflexbox'; import Flex from 'components/Flex';
import classNames from 'classnames/bind'; import classNames from 'classnames/bind';
import styles from './Alert.scss'; import styles from './Alert.scss';

View File

@ -4,8 +4,6 @@ import styled from 'styled-components';
type Props = { type Props = {
children?: React.Element<any>, children?: React.Element<any>,
style?: Object,
maxWidth?: string,
}; };
const Container = styled.div` const Container = styled.div`
@ -13,20 +11,17 @@ const Container = styled.div`
margin: 40px 20px; margin: 40px 20px;
`; `;
const CenteredContent = ({ const Content = styled.div`
children, max-width: 740px;
maxWidth = '740px', margin: 0 auto;
style, `;
...rest
}: Props) => {
const styles = {
maxWidth,
...style,
};
const CenteredContent = ({ children, ...rest }: Props) => {
return ( return (
<Container style={styles} {...rest}> <Container {...rest}>
{children} <Content>
{children}
</Content>
</Container> </Container>
); );
}; };

View File

@ -18,7 +18,7 @@ class DocumentViewersStore {
this.isFetching = true; this.isFetching = true;
try { try {
const res = await client.get( const res = await client.post(
'/views.list', '/views.list',
{ {
id: this.documentId, id: this.documentId,

View File

@ -5,7 +5,7 @@ import Popover from 'components/Popover';
import styled from 'styled-components'; import styled from 'styled-components';
import DocumentViewers from './components/DocumentViewers'; import DocumentViewers from './components/DocumentViewers';
import DocumentViewersStore from './DocumentViewersStore'; import DocumentViewersStore from './DocumentViewersStore';
import { Flex } from 'reflexbox'; import Flex from 'components/Flex';
const Container = styled(Flex)` const Container = styled(Flex)`
font-size: 13px; font-size: 13px;

View File

@ -1,6 +1,6 @@
// @flow // @flow
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Flex } from 'reflexbox'; import Flex from 'components/Flex';
import styled from 'styled-components'; import styled from 'styled-components';
import map from 'lodash/map'; import map from 'lodash/map';
import Avatar from 'components/Avatar'; import Avatar from 'components/Avatar';

View File

@ -0,0 +1,108 @@
// @flow
import React, { Component } from 'react';
import { inject } from 'mobx-react';
import invariant from 'invariant';
import _ from 'lodash';
import Dropzone from 'react-dropzone';
import Document from 'models/Document';
import DocumentsStore from 'stores/DocumentsStore';
import LoadingIndicator from 'components/LoadingIndicator';
class DropToImport extends Component {
state: {
isImporting: boolean,
};
props: {
children?: React$Element<any>,
collectionId: string,
documentId?: string,
activeClassName?: string,
rejectClassName?: string,
documents: DocumentsStore,
history: Object,
};
state = {
isImporting: false,
};
importFile = async ({ file, documentId, collectionId, redirect }) => {
const reader = new FileReader();
reader.onload = async ev => {
const text = ev.target.result;
let data = {
parentDocument: undefined,
collection: { id: collectionId },
text,
};
if (documentId) {
data.parentDocument = {
id: documentId,
};
}
let document = new Document(data);
document = await document.save();
this.props.documents.add(document);
if (redirect && this.props.history) {
this.props.history.push(document.url);
}
};
reader.readAsText(file);
};
onDropAccepted = async (files = []) => {
this.setState({ isImporting: true });
try {
let collectionId = this.props.collectionId;
const documentId = this.props.documentId;
const redirect = files.length === 1;
if (documentId && !collectionId) {
const document = await this.props.documents.fetch(documentId);
invariant(document, 'Document not available');
collectionId = document.collection.id;
}
for (const file of files) {
await this.importFile({ file, documentId, collectionId, redirect });
}
} catch (err) {
// TODO: show error alert.
} finally {
this.setState({ isImporting: false });
}
};
render() {
const props = _.omit(
this.props,
'history',
'documentId',
'collectionId',
'documents'
);
return (
<Dropzone
accept="text/markdown, text/plain"
onDropAccepted={this.onDropAccepted}
style={{}}
disableClick
disablePreview
multiple
{...props}
>
<span>
{this.state.isImporting && <LoadingIndicator />}
{this.props.children}
</span>
</Dropzone>
);
}
}
export default inject('documents')(DropToImport);

View File

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

View File

@ -61,6 +61,12 @@ type KeyData = {
} }
} }
componentDidUpdate(prevProps: Props) {
if (prevProps.readOnly && !this.props.readOnly) {
this.focusAtEnd();
}
}
getChildContext() { getChildContext() {
return { starred: this.props.starred }; return { starred: this.props.starred };
} }

View File

@ -0,0 +1,41 @@
// @flow
import React from 'react';
import styled from 'styled-components';
type JustifyValues =
| 'center'
| 'space-around'
| 'space-between'
| 'flex-start'
| 'flex-end';
type AlignValues =
| 'stretch'
| 'center'
| 'baseline'
| 'flex-start'
| 'flex-end';
type Props = {
column?: ?boolean,
align?: AlignValues,
justify?: JustifyValues,
auto?: ?boolean,
className?: string,
children?: React.Element<any>,
};
const Flex = (props: Props) => {
const { children, ...restProps } = props;
return <Container {...restProps}>{children}</Container>;
};
const Container = styled.div`
display: flex;
flex: ${({ auto }) => (auto ? '1 1 auto' : 'initial')};
flex-direction: ${({ column }) => (column ? 'column' : 'row')};
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
`;
export default Flex;

View File

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

View File

@ -1,7 +1,7 @@
// @flow // @flow
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { Flex } from 'reflexbox'; import Flex from 'components/Flex';
import { size } from 'styles/constants'; import { size } from 'styles/constants';
const RealTextarea = styled.textarea` const RealTextarea = styled.textarea`

View File

@ -6,11 +6,13 @@ import styled from 'styled-components';
import { observer, inject } from 'mobx-react'; import { observer, inject } from 'mobx-react';
import _ from 'lodash'; import _ from 'lodash';
import keydown from 'react-keydown'; import keydown from 'react-keydown';
import { Flex } from 'reflexbox'; import Flex from 'components/Flex';
import { textColor } from 'styles/constants.scss'; import { color, layout } from 'styles/constants';
import DropdownMenu, { MenuItem } from 'components/DropdownMenu'; import DropdownMenu, { MenuItem } from 'components/DropdownMenu';
import { LoadingIndicatorBar } from 'components/LoadingIndicator'; import { LoadingIndicatorBar } from 'components/LoadingIndicator';
import Scrollable from 'components/Scrollable';
import Avatar from 'components/Avatar';
import SidebarCollection from './components/SidebarCollection'; import SidebarCollection from './components/SidebarCollection';
import SidebarCollectionList from './components/SidebarCollectionList'; import SidebarCollectionList from './components/SidebarCollectionList';
@ -101,21 +103,24 @@ type Props = {
</Header> </Header>
<Flex column> <Flex column>
<LinkSection> <Scrollable>
<SidebarLink to="/search">Search</SidebarLink> <LinkSection>
</LinkSection> <SidebarLink to="/search">Search</SidebarLink>
<LinkSection> </LinkSection>
<SidebarLink to="/dashboard">Home</SidebarLink> <LinkSection>
<SidebarLink to="/starred">Starred</SidebarLink> <SidebarLink to="/dashboard">Home</SidebarLink>
</LinkSection> <SidebarLink to="/starred">Starred</SidebarLink>
<LinkSection> </LinkSection>
{ui.activeCollection <LinkSection>
? <SidebarCollection {ui.activeCollection
document={ui.activeDocument} ? <SidebarCollection
collection={ui.activeCollection} document={ui.activeDocument}
/> collection={ui.activeCollection}
: <SidebarCollectionList />} history={this.props.history}
</LinkSection> />
: <SidebarCollectionList history={this.props.history} />}
</LinkSection>
</Scrollable>
</Flex> </Flex>
</Sidebar>} </Sidebar>}
@ -135,22 +140,16 @@ const Container = styled(Flex)`
`; `;
const LogoLink = styled(Link)` const LogoLink = styled(Link)`
margin-top: 5px; margin-top: 15px;
font-family: 'Atlas Grotesk'; font-family: 'Atlas Grotesk';
font-weight: bold; font-weight: bold;
color: ${textColor}; color: ${color.text};
text-decoration: none; text-decoration: none;
font-size: 16px; font-size: 16px;
`; `;
const Avatar = styled.img`
width: 24px;
height: 24px;
border-radius: 50%;
`;
const MenuLink = styled(Link)` const MenuLink = styled(Link)`
color: ${textColor}; color: ${color.text};
`; `;
const Content = styled(Flex)` const Content = styled(Flex)`
@ -159,26 +158,27 @@ const Content = styled(Flex)`
top: 0; top: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
left: ${props => (props.editMode ? 0 : '250px')}; left: ${props => (props.editMode ? 0 : layout.sidebarWidth)};
transition: left 200ms ease-in-out; transition: left 200ms ease-in-out;
`; `;
const Sidebar = styled(Flex)` const Sidebar = styled(Flex)`
width: 250px; width: ${layout.sidebarWidth};
margin-left: ${props => (props.editMode ? '-250px' : 0)}; margin-left: ${props => (props.editMode ? `-${layout.sidebarWidth}` : 0)};
padding: 10px 20px;
background: rgba(250, 251, 252, 0.71); background: rgba(250, 251, 252, 0.71);
border-right: 1px solid #eceff3; border-right: 1px solid #eceff3;
transition: margin-left 200ms ease-in-out; transition: margin-left 200ms ease-in-out;
`; `;
const Header = styled(Flex)` const Header = styled(Flex)`
margin-bottom: 20px; flex-shrink: 0;
padding: ${layout.padding};
padding-bottom: 10px;
`; `;
const LinkSection = styled(Flex)` const LinkSection = styled(Flex)`
margin-bottom: 20px;
flex-direction: column; flex-direction: column;
padding: 10px 0;
`; `;
export default withRouter(inject('user', 'auth', 'ui', 'collections')(Layout)); export default withRouter(inject('user', 'auth', 'ui', 'collections')(Layout));

View File

@ -1,35 +1,51 @@
// @flow // @flow
import React from 'react'; import React from 'react';
import { observer } from 'mobx-react'; import Flex from 'components/Flex';
import { Flex } from 'reflexbox';
import styled from 'styled-components'; import styled from 'styled-components';
import { layout } from 'styles/constants';
import SidebarLink from '../SidebarLink'; import SidebarLink from '../SidebarLink';
import DropToImport from 'components/DropToImport';
import Collection from 'models/Collection'; import Collection from 'models/Collection';
import Document from 'models/Document'; import Document from 'models/Document';
import type { NavigationNode } from 'types';
type Props = { type Props = {
collection: ?Collection, collection: ?Collection,
document: ?Document, document: ?Document,
history: Object,
};
const activeStyle = {
color: '#000',
background: '#E1E1E1',
}; };
class SidebarCollection extends React.Component { class SidebarCollection extends React.Component {
props: Props; props: Props;
renderDocuments(documentList) { renderDocuments(documentList: Array<NavigationNode>, depth: number = 0) {
const { document } = this.props; const { document, history } = this.props;
const canDropToImport = depth === 0;
if (document) { if (document) {
return documentList.map(doc => ( return documentList.map(doc => (
<Flex column key={doc.id}> <Flex column key={doc.id}>
<SidebarLink key={doc.id} to={doc.url}> {canDropToImport &&
{doc.title} <DropToImport
</SidebarLink> history={history}
documentId={doc.id}
activeStyle={activeStyle}
>
<SidebarLink to={doc.url}>{doc.title}</SidebarLink>
</DropToImport>}
{!canDropToImport &&
<SidebarLink to={doc.url}>{doc.title}</SidebarLink>}
{(document.pathToDocument.includes(doc.id) || {(document.pathToDocument.includes(doc.id) ||
document.id === doc.id) && document.id === doc.id) &&
<Children> <Children column>
{doc.children && this.renderDocuments(doc.children)} {doc.children && this.renderDocuments(doc.children, depth + 1)}
</Children>} </Children>}
</Flex> </Flex>
)); ));
@ -57,10 +73,11 @@ const Header = styled(Flex)`
text-transform: uppercase; text-transform: uppercase;
color: #9FA6AB; color: #9FA6AB;
letter-spacing: 0.04em; letter-spacing: 0.04em;
padding: 0 ${layout.hpadding};
`; `;
const Children = styled(Flex)` const Children = styled(Flex)`
margin-left: 20px; margin-left: 20px;
`; `;
export default observer(SidebarCollection); export default SidebarCollection;

View File

@ -1,25 +1,39 @@
// @flow // @flow
import React from 'react'; import React from 'react';
import { observer, inject } from 'mobx-react'; import { observer, inject } from 'mobx-react';
import { Flex } from 'reflexbox'; import Flex from 'components/Flex';
import styled from 'styled-components'; import styled from 'styled-components';
import { layout } from 'styles/constants';
import SidebarLink from '../SidebarLink'; import SidebarLink from '../SidebarLink';
import DropToImport from 'components/DropToImport';
import CollectionsStore from 'stores/CollectionsStore'; import CollectionsStore from 'stores/CollectionsStore';
type Props = { type Props = {
history: Object,
collections: CollectionsStore, collections: CollectionsStore,
}; };
const SidebarCollectionList = observer(({ collections }: Props) => { const activeStyle = {
color: '#000',
background: '#E1E1E1',
};
const SidebarCollectionList = observer(({ history, collections }: Props) => {
return ( return (
<Flex column> <Flex column>
<Header>Collections</Header> <Header>Collections</Header>
{collections.data.map(collection => ( {collections.data.map(collection => (
<SidebarLink key={collection.id} to={collection.entryUrl}> <DropToImport
{collection.name} history={history}
</SidebarLink> collectionId={collection.id}
activeStyle={activeStyle}
>
<SidebarLink key={collection.id} to={collection.entryUrl}>
{collection.name}
</SidebarLink>
</DropToImport>
))} ))}
</Flex> </Flex>
); );
@ -31,6 +45,7 @@ const Header = styled(Flex)`
text-transform: uppercase; text-transform: uppercase;
color: #9FA6AB; color: #9FA6AB;
letter-spacing: 0.04em; letter-spacing: 0.04em;
padding: 0 ${layout.hpadding};
`; `;
export default inject('collections')(SidebarCollectionList); export default inject('collections')(SidebarCollectionList);

View File

@ -1,35 +1,26 @@
// @flow // @flow
import React from 'react'; import React from 'react';
import { observer } from 'mobx-react'; import { NavLink } from 'react-router-dom';
import { NavLink, withRouter } from 'react-router-dom'; import { layout, color } from 'styles/constants';
import { Flex } from 'reflexbox'; import { darken } from 'polished';
import styled from 'styled-components'; import styled from 'styled-components';
const activeStyle = { const activeStyle = {
color: '#000000', color: '#000000',
}; };
@observer class SidebarLink extends React.Component { function SidebarLink(props: Object) {
shouldComponentUpdate(nextProps) { return <StyledNavLink exact {...props} activeStyle={activeStyle} />;
// Navlink is having issues updating, forcing update on URL changes
return this.props.match !== nextProps.match;
}
render() {
return (
<LinkContainer>
<NavLink exact {...this.props} activeStyle={activeStyle} />
</LinkContainer>
);
}
} }
const LinkContainer = styled(Flex)` const StyledNavLink = styled(NavLink)`
padding: 5px 0; display: block;
padding: 5px ${layout.hpadding};
a { color: ${color.slateDark};
color: #848484;
&:hover {
color: ${darken(0.1, color.slateDark)};
} }
`; `;
export default withRouter(SidebarLink); export default SidebarLink;

View File

@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import styled, { keyframes } from 'styled-components'; import styled, { keyframes } from 'styled-components';
import { Flex } from 'reflexbox'; import Flex from 'components/Flex';
import { randomInteger } from 'utils/random'; import { randomInteger } from 'utils/random';
@ -11,7 +11,7 @@ const randomValues = Array.from(
() => `${randomInteger(85, 100)}%` () => `${randomInteger(85, 100)}%`
); );
export default () => { export default (props: {}) => {
return ( return (
<ReactCSSTransitionGroup <ReactCSSTransitionGroup
transitionName="fadeIn" transitionName="fadeIn"
@ -22,7 +22,7 @@ export default () => {
transitionEnterTimeout={0} transitionEnterTimeout={0}
transitionLeaveTimeout={0} transitionLeaveTimeout={0}
> >
<Flex column auto> <Flex column auto {...props}>
<Mask style={{ width: randomValues[0] }} header /> <Mask style={{ width: randomValues[0] }} header />
<Mask style={{ width: randomValues[1] }} /> <Mask style={{ width: randomValues[1] }} />
<Mask style={{ width: randomValues[2] }} /> <Mask style={{ width: randomValues[2] }} />

View File

@ -3,7 +3,7 @@ import React, { Component } from 'react';
import moment from 'moment'; import moment from 'moment';
import styled from 'styled-components'; import styled from 'styled-components';
import type { User } from 'types'; import type { User } from 'types';
import { Flex } from 'reflexbox'; import Flex from 'components/Flex';
const Container = styled(Flex)` const Container = styled(Flex)`
justify-content: space-between; justify-content: space-between;
@ -51,7 +51,6 @@ class PublishingInfo extends Component {
<Avatar key={user.id} src={user.avatarUrl} title={user.name} /> <Avatar key={user.id} src={user.avatarUrl} title={user.name} />
))} ))}
</Avatars>} </Avatars>}
{createdAt === updatedAt {createdAt === updatedAt
? <span> ? <span>
{createdBy.name} {createdBy.name}

View File

@ -0,0 +1,18 @@
// @flow
// based on: https://reacttraining.com/react-router/web/guides/scroll-restoration
import { Component } from 'react';
import { withRouter } from 'react-router';
class ScrollToTop extends Component {
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
window.scrollTo(0, 0);
}
}
render() {
return this.props.children;
}
}
export default withRouter(ScrollToTop);

View File

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

View File

@ -0,0 +1,25 @@
// @flow
import { Component } from 'react';
import { inject } from 'mobx-react';
import UiStore from 'stores/UiStore';
class SidebarHidden extends Component {
props: {
ui: UiStore,
children: React$Element<any>,
};
componentDidMount() {
this.props.ui.enableEditMode();
}
componentWillUnmount() {
this.props.ui.disableEditMode();
}
render() {
return this.props.children;
}
}
export default inject('ui')(SidebarHidden);

View File

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

View File

@ -8,7 +8,7 @@ import {
Route, Route,
Redirect, Redirect,
} from 'react-router-dom'; } from 'react-router-dom';
import { Flex } from 'reflexbox'; import Flex from 'components/Flex';
import stores from 'stores'; import stores from 'stores';
import DocumentsStore from 'stores/DocumentsStore'; import DocumentsStore from 'stores/DocumentsStore';
@ -33,7 +33,9 @@ import Flatpage from 'scenes/Flatpage';
import ErrorAuth from 'scenes/ErrorAuth'; import ErrorAuth from 'scenes/ErrorAuth';
import Error404 from 'scenes/Error404'; import Error404 from 'scenes/Error404';
import ScrollToTop from 'components/ScrollToTop';
import Layout from 'components/Layout'; import Layout from 'components/Layout';
import SidebarHidden from 'components/SidebarHidden';
import flatpages from 'static/flatpages'; import flatpages from 'static/flatpages';
@ -85,52 +87,73 @@ const KeyboardShortcuts = () => (
); );
const Api = () => <Flatpage title="API" content={flatpages.api} />; const Api = () => <Flatpage title="API" content={flatpages.api} />;
const DocumentNew = () => <Document newDocument />; const DocumentNew = () => <Document newDocument />;
const DocumentNewChild = () => <Document newChildDocument />; const RedirectDocument = ({ match }: { match: Object }) => (
<Redirect to={`/doc/${match.params.documentSlug}`} />
);
const matchDocumentSlug = ':documentSlug([0-9a-zA-Z-]*-[a-zA-z0-9]{10,15})';
render( render(
<div style={{ display: 'flex', flex: 1, height: '100%' }}> <div style={{ display: 'flex', flex: 1, height: '100%' }}>
<Provider {...stores}> <Provider {...stores}>
<Router> <Router>
<Switch> <ScrollToTop>
<Route exact path="/" component={Home} /> <Switch>
<Route exact path="/" component={Home} />
<Route exact path="/auth/slack" component={SlackAuth} /> <Route exact path="/auth/slack" component={SlackAuth} />
<Route exact path="/auth/slack/commands" component={SlackAuth} /> <Route exact path="/auth/slack/commands" component={SlackAuth} />
<Route exact path="/auth/error" component={ErrorAuth} /> <Route exact path="/auth/error" component={ErrorAuth} />
<Auth> <Auth>
<Layout> <Layout>
<Switch> <Switch>
<Route exact path="/dashboard" component={Dashboard} /> <Route exact path="/dashboard" component={Dashboard} />
<Route exact path="/starred" component={Starred} /> <Route exact path="/starred" component={Starred} />
<Route exact path="/collections/:id" component={Collection} /> <Route exact path="/collections/:id" component={Collection} />
<Route exact path="/d/:id" component={Document} /> <Route
exact
path={`/d/${matchDocumentSlug}`}
component={RedirectDocument}
/>
<Route
exact
path={`/doc/${matchDocumentSlug}`}
component={Document}
/>
<SidebarHidden>
<Switch>
<Route
exact
path={`/doc/${matchDocumentSlug}/:edit`}
component={Document}
/>
<Route
exact
path="/collections/:id/new"
component={DocumentNew}
/>
</Switch>
</SidebarHidden>
<Route exact path="/d/:id/:edit" component={Document} /> <Route exact path="/search" component={Search} />
<Route <Route exact path="/search/:query" component={Search} />
exact <Route exact path="/settings" component={Settings} />
path="/collections/:id/new"
component={DocumentNew}
/>
<Route exact path="/d/:id/new" component={DocumentNewChild} />
<Route exact path="/search" component={Search} /> <Route
<Route exact path="/search/:query" component={Search} /> exact
<Route exact path="/settings" component={Settings} /> path="/keyboard-shortcuts"
component={KeyboardShortcuts}
/>
<Route exact path="/developers" component={Api} />
<Route <Route path="/404" component={Error404} />
exact <Route component={notFoundSearch} />
path="/keyboard-shortcuts" </Switch>
component={KeyboardShortcuts} </Layout>
/> </Auth>
<Route exact path="/developers" component={Api} /> </Switch>
</ScrollToTop>
<Route path="/404" component={Error404} />
<Route component={notFoundSearch} />
</Switch>
</Layout>
</Auth>
</Switch>
</Router> </Router>
</Provider> </Provider>
{DevTools && <DevTools position={{ bottom: 0, right: 0 }} />} {DevTools && <DevTools position={{ bottom: 0, right: 0 }} />}

View File

@ -10,25 +10,25 @@ import type { User } from 'types';
import Collection from './Collection'; import Collection from './Collection';
const parseHeader = text => { const parseHeader = text => {
const firstLine = text.split(/\r?\n/)[0]; const firstLine = text.trim().split(/\r?\n/)[0];
return firstLine.replace(/^#/, '').trim(); return firstLine.replace(/^#/, '').trim();
}; };
class Document { class Document {
isSaving: boolean; isSaving: boolean = false;
hasPendingChanges: boolean = false; hasPendingChanges: boolean = false;
errors: ErrorsStore; errors: ErrorsStore;
collaborators: Array<User>; collaborators: Array<User>;
collection: Collection; collection: $Shape<Collection>;
createdAt: string; createdAt: string;
createdBy: User; createdBy: User;
html: string; html: string;
id: string; id: string;
private: boolean;
starred: boolean;
team: string; team: string;
text: string; private: boolean = false;
starred: boolean = false;
text: string = '';
title: string = 'Untitled document'; title: string = 'Untitled document';
updatedAt: string; updatedAt: string;
updatedBy: User; updatedBy: User;
@ -83,9 +83,9 @@ class Document {
}; };
@action view = async () => { @action view = async () => {
this.views++;
try { try {
await client.post('/views.create', { id: this.id }); await client.post('/views.create', { id: this.id });
this.views++;
} catch (e) { } catch (e) {
this.errors.add('Document failed to record view'); this.errors.add('Document failed to record view');
} }
@ -113,7 +113,7 @@ class Document {
}; };
@action save = async () => { @action save = async () => {
if (this.isSaving) return; if (this.isSaving) return this;
this.isSaving = true; this.isSaving = true;
try { try {
@ -125,28 +125,38 @@ class Document {
text: this.text, text: this.text,
}); });
} else { } else {
res = await client.post('/documents.create', { const data = {
parentDocument: undefined,
collection: this.collection.id, collection: this.collection.id,
title: this.title, title: this.title,
text: this.text, text: this.text,
}); };
if (this.parentDocument) {
data.parentDocument = this.parentDocument.id;
}
res = await client.post('/documents.create', data);
} }
invariant(res && res.data, 'Data should be available'); invariant(res && res.data, 'Data should be available');
this.hasPendingChanges = false; this.updateData({
...res.data,
hasPendingChanges: false,
});
} catch (e) { } catch (e) {
this.errors.add('Document failed saving'); this.errors.add('Document failed saving');
} finally { } finally {
this.isSaving = false; this.isSaving = false;
} }
return this;
}; };
updateData(data: Object | Document) { updateData(data: Object | Document) {
data.title = parseHeader(data.text); if (data.text) data.title = parseHeader(data.text);
extendObservable(this, data); extendObservable(this, data);
} }
constructor(document: Document) { constructor(document?: Object = {}) {
this.updateData(document); this.updateData(document);
this.errors = stores.errors; this.errors = stores.errors;
} }

View File

@ -5,8 +5,7 @@ describe('Document model', () => {
test('should initialize with data', () => { test('should initialize with data', () => {
const document = new Document({ const document = new Document({
id: 123, id: 123,
title: 'Onboarding', text: '# Onboarding\nSome body text',
text: 'Some body text'
}); });
expect(document.title).toBe('Onboarding'); expect(document.title).toBe('Onboarding');
}); });

View File

@ -4,16 +4,18 @@ import get from 'lodash/get';
import styled from 'styled-components'; import styled from 'styled-components';
import { observer, inject } from 'mobx-react'; import { observer, inject } from 'mobx-react';
import { withRouter, Prompt } from 'react-router'; import { withRouter, Prompt } from 'react-router';
import { Flex } from 'reflexbox'; import Flex from 'components/Flex';
import { layout } from 'styles/constants';
import Document from 'models/Document';
import UiStore from 'stores/UiStore'; import UiStore from 'stores/UiStore';
import DocumentsStore from 'stores/DocumentsStore'; import DocumentsStore from 'stores/DocumentsStore';
import Menu from './components/Menu'; import Menu from './components/Menu';
import Editor from 'components/Editor'; import Editor from 'components/Editor';
import DropToImport from 'components/DropToImport';
import { HeaderAction, SaveAction } from 'components/Layout'; import { HeaderAction, SaveAction } from 'components/Layout';
import LoadingIndicator from 'components/LoadingIndicator'; import LoadingIndicator from 'components/LoadingIndicator';
import PublishingInfo from 'components/PublishingInfo'; import PublishingInfo from 'components/PublishingInfo';
import AuthorInfo from 'components/AuthorInfo';
import PreviewLoading from 'components/PreviewLoading'; import PreviewLoading from 'components/PreviewLoading';
import CenteredContent from 'components/CenteredContent'; import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle'; import PageTitle from 'components/PageTitle';
@ -28,15 +30,19 @@ type Props = {
history: Object, history: Object,
keydown: Object, keydown: Object,
documents: DocumentsStore, documents: DocumentsStore,
newChildDocument?: boolean, newDocument?: boolean,
ui: UiStore, ui: UiStore,
}; };
@observer class Document extends Component { @observer class DocumentScene extends Component {
props: Props; props: Props;
state: {
newDocument?: Document,
};
state = { state = {
isDragging: false,
isLoading: false, isLoading: false,
newDocument: undefined,
}; };
componentDidMount() { componentDidMount() {
@ -44,7 +50,10 @@ type Props = {
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
if (nextProps.match.params.id !== this.props.match.params.id) { if (
nextProps.match.params.documentSlug !==
this.props.match.params.documentSlug
) {
this.loadDocument(nextProps); this.loadDocument(nextProps);
} }
} }
@ -54,42 +63,49 @@ type Props = {
} }
loadDocument = async props => { loadDocument = async props => {
await this.props.documents.fetch(props.match.params.id); if (props.newDocument) {
const document = this.document; const newDocument = new Document({
collection: { id: props.match.params.id },
if (document) { });
this.props.ui.setActiveDocument(document); this.setState({ newDocument });
document.view();
}
if (this.props.match.params.edit) {
this.props.ui.enableEditMode();
} else { } else {
this.props.ui.disableEditMode(); let document = this.document;
if (document) {
this.props.ui.setActiveDocument(document);
}
await this.props.documents.fetch(props.match.params.documentSlug);
document = this.document;
if (document) {
this.props.ui.setActiveDocument(document);
document.view();
}
} }
}; };
get document() { get document() {
return this.props.documents.getByUrl(`/d/${this.props.match.params.id}`); if (this.state.newDocument) return this.state.newDocument;
return this.props.documents.getByUrl(
`/doc/${this.props.match.params.documentSlug}`
);
} }
onClickEdit = () => { onClickEdit = () => {
if (!this.document) return; if (!this.document) return;
const url = `${this.document.url}/edit`; const url = `${this.document.url}/edit`;
this.props.history.push(url); this.props.history.push(url);
this.props.ui.enableEditMode();
}; };
onSave = async (redirect: boolean = false) => { onSave = async (redirect: boolean = false) => {
const document = this.document; let document = this.document;
if (!document) return; if (!document) return;
this.setState({ isLoading: true }); this.setState({ isLoading: true });
await document.save(); document = await document.save();
this.setState({ isLoading: false }); this.setState({ isLoading: false });
this.props.ui.disableEditMode();
if (redirect) { if (redirect || this.props.newDocument) {
this.props.history.push(document.url); this.props.history.push(document.url);
} }
}; };
@ -111,86 +127,111 @@ type Props = {
this.props.history.goBack(); this.props.history.goBack();
}; };
onStartDragging = () => {
this.setState({ isDragging: true });
};
onStopDragging = () => {
this.setState({ isDragging: false });
};
render() { render() {
const isNew = this.props.newDocument || this.props.newChildDocument; const isNew = this.props.newDocument;
const isEditing = this.props.match.params.edit; const isEditing = this.props.match.params.edit || isNew;
const isFetching = !this.document && get(this.document, 'isFetching'); const isFetching = !this.document;
const titleText = get(this.document, 'title', 'Loading'); const titleText = get(this.document, 'title', 'Loading');
return ( return (
<Container column auto> <Container column auto>
{this.state.isDragging &&
<DropHere align="center" justify="center">
Drop files here to import into Atlas.
</DropHere>}
{titleText && <PageTitle title={titleText} />} {titleText && <PageTitle title={titleText} />}
{this.state.isLoading && <LoadingIndicator />} {this.state.isLoading && <LoadingIndicator />}
{isFetching && {isFetching &&
<CenteredContent> <CenteredContent>
<PreviewLoading /> <LoadingState />
</CenteredContent>} </CenteredContent>}
{!isFetching && {!isFetching &&
this.document && this.document &&
<PagePadding justify="center" auto> <DropToImport
<Prompt documentId={this.document.id}
when={this.document.hasPendingChanges} history={this.props.history}
message={DISCARD_CHANGES} onDragEnter={this.onStartDragging}
/> onDragLeave={this.onStopDragging}
<DocumentContainer> onDrop={this.onStopDragging}
<InfoWrapper visible={!isEditing}> >
<PublishingInfo <PagePadding justify="center" auto>
collaborators={this.document.collaborators} <Prompt
createdAt={this.document.createdAt} when={this.document.hasPendingChanges}
createdBy={this.document.createdBy} message={DISCARD_CHANGES}
updatedAt={this.document.updatedAt} />
updatedBy={this.document.updatedBy} <DocumentContainer>
/> <InfoWrapper visible={!isEditing}>
</InfoWrapper> <PublishingInfo
<Content> collaborators={this.document.collaborators}
<Editor createdAt={this.document.createdAt}
key={this.document.id} createdBy={this.document.createdBy}
text={this.document.text} updatedAt={this.document.updatedAt}
onImageUploadStart={this.onImageUploadStart} updatedBy={this.document.updatedBy}
onImageUploadStop={this.onImageUploadStop} />
onChange={this.onChange} </InfoWrapper>
onSave={this.onSave} <Content>
onCancel={this.onCancel} <Editor
onStar={this.document.star} key={this.document.id}
onUnstar={this.document.unstar} text={this.document.text}
starred={this.document.starred} onImageUploadStart={this.onImageUploadStart}
readOnly={!isEditing} onImageUploadStop={this.onImageUploadStop}
/> onChange={this.onChange}
</Content> onSave={this.onSave}
<InfoWrapper visible={!isEditing} bottom> onCancel={this.onCancel}
<AuthorInfo onStar={this.document.star}
collaborators={this.document.collaborators} onUnstar={this.document.unstar}
views={this.document.views} starred={this.document.starred}
/> readOnly={!isEditing}
</InfoWrapper> />
</DocumentContainer> </Content>
<Meta align="center" justify="flex-end" readOnly={!isEditing}> </DocumentContainer>
<Flex align="center"> <Meta align="center" justify="flex-end" readOnly={!isEditing}>
<HeaderAction> <Flex align="center">
{isEditing <HeaderAction>
? <SaveAction {isEditing
onClick={this.onSave.bind(this, true)} ? <SaveAction
disabled={get(this.document, 'isSaving')} onClick={this.onSave.bind(this, true)}
isNew={!!isNew} disabled={get(this.document, 'isSaving')}
/> isNew={!!isNew}
: <a onClick={this.onClickEdit}>Edit</a>} />
</HeaderAction> : <a onClick={this.onClickEdit}>Edit</a>}
{!isEditing && <Menu document={this.document} />} </HeaderAction>
</Flex> {!isEditing && <Menu document={this.document} />}
</Meta> </Flex>
</PagePadding>} </Meta>
</PagePadding>
</DropToImport>}
</Container> </Container>
); );
} }
} }
const DropHere = styled(Flex)`
pointer-events: none;
position: fixed;
top: 0;
left: ${layout.sidebarWidth};
bottom: 0;
right: 0;
text-align: center;
background: rgba(255,255,255,.9);
z-index: 1;
`;
const Meta = styled(Flex)` const Meta = styled(Flex)`
justify-content: ${props => (props.readOnly ? 'space-between' : 'flex-end')};
align-items: flex-start; align-items: flex-start;
width: 100%; width: 100%;
position: absolute; position: absolute;
top: 0; top: 0;
padding: 10px 20px; padding: ${layout.padding};
`; `;
const Content = styled(Flex)` const Content = styled(Flex)`
@ -207,6 +248,10 @@ const Container = styled.div`
width: 100%; width: 100%;
`; `;
const LoadingState = styled(PreviewLoading)`
margin: 80px 20px;
`;
const PagePadding = styled(Flex)` const PagePadding = styled(Flex)`
padding: 80px 20px; padding: 80px 20px;
position: relative; position: relative;
@ -220,4 +265,4 @@ const DocumentContainer = styled.div`
width: 50em; width: 50em;
`; `;
export default withRouter(inject('ui', 'documents')(Document)); export default withRouter(inject('ui', 'user', 'documents')(DocumentScene));

View File

@ -16,9 +16,7 @@ type Props = {
props: Props; props: Props;
onCreateDocument = () => { onCreateDocument = () => {
// Disabled until created a better API this.props.history.push(`${this.props.document.collection.url}/new`);
// invariant(this.props.collectionTree, 'collectionTree is not available');
// this.props.history.push(`${this.props.collectionTree.url}/new`);
}; };
onCreateChild = () => { onCreateChild = () => {
@ -68,7 +66,6 @@ type Props = {
<MenuItem onClick={this.onCreateDocument}> <MenuItem onClick={this.onCreateDocument}>
New document New document
</MenuItem> </MenuItem>
<MenuItem onClick={this.onCreateChild}>New child</MenuItem>
</div>} </div>}
<MenuItem onClick={this.onExport}>Export</MenuItem> <MenuItem onClick={this.onExport}>Export</MenuItem>
{allowDelete && <MenuItem onClick={this.onDelete}>Delete</MenuItem>} {allowDelete && <MenuItem onClick={this.onDelete}>Delete</MenuItem>}

View File

@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import { observer, inject } from 'mobx-react'; import { observer, inject } from 'mobx-react';
import { Redirect } from 'react-router'; import { Redirect } from 'react-router';
import { Flex } from 'reflexbox'; import Flex from 'components/Flex';
import styled from 'styled-components'; import styled from 'styled-components';
import AuthStore from 'stores/AuthStore'; import AuthStore from 'stores/AuthStore';

View File

@ -3,7 +3,7 @@ import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import _ from 'lodash'; import _ from 'lodash';
import { Flex } from 'reflexbox'; import Flex from 'components/Flex';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { searchUrl } from 'utils/routeHelpers'; import { searchUrl } from 'utils/routeHelpers';
import styled from 'styled-components'; import styled from 'styled-components';

View File

@ -1,6 +1,6 @@
// @flow // @flow
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Flex } from 'reflexbox'; import Flex from 'components/Flex';
import styled from 'styled-components'; import styled from 'styled-components';
import searchImg from 'assets/icons/search.svg'; import searchImg from 'assets/icons/search.svg';

View File

@ -1,7 +1,7 @@
// @flow // @flow
import React from 'react'; import React from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { Flex } from 'reflexbox'; import Flex from 'components/Flex';
import ApiKeyRow from './components/ApiKeyRow'; import ApiKeyRow from './components/ApiKeyRow';
import styles from './Settings.scss'; import styles from './Settings.scss';

View File

@ -50,10 +50,14 @@ class DocumentsStore {
const res = await client.post('/documents.info', { id }); const res = await client.post('/documents.info', { id });
invariant(res && res.data, 'Document not available'); invariant(res && res.data, 'Document not available');
const { data } = res; const { data } = res;
const document = new Document(data);
runInAction('DocumentsStore#fetch', () => { runInAction('DocumentsStore#fetch', () => {
this.data.set(data.id, new Document(data)); this.data.set(data.id, document);
this.isLoaded = true; this.isLoaded = true;
}); });
return document;
} catch (e) { } catch (e) {
this.errors.add('Failed to load documents'); this.errors.add('Failed to load documents');
} }

View File

@ -1,5 +1,14 @@
// @flow // @flow
export const layout = {
padding: '1.5vw 1.875vw',
vpadding: '1.5vw',
hpadding: '1.875vw',
sidebarWidth: '22%',
sidebarMinWidth: '250px',
sidebarMaxWidth: '350px',
};
export const size = { export const size = {
tiny: '2px', tiny: '2px',
small: '4px', small: '4px',
@ -28,6 +37,8 @@ export const fontWeight = {
}; };
export const color = { export const color = {
text: '#171B35',
/* Brand */ /* Brand */
primary: '#73DF7B', primary: '#73DF7B',

View File

@ -1,13 +1,13 @@
require('./init'); require('./init');
var app = require('./server').default; const app = require('./server').default;
var http = require('http'); const http = require('http');
var server = http.createServer(app.callback()); const server = http.createServer(app.callback());
server.listen(process.env.PORT || '3000'); server.listen(process.env.PORT || '3000');
server.on('error', (err) => { server.on('error', err => {
throw err; throw err;
}); });
server.on('listening', () => { server.on('listening', () => {
var address = server.address(); const address = server.address();
console.log('Listening on %s%s', address.address, address.port); console.log(`Listening on http://localhost:${address.port}`);
}); });

View File

@ -3,4 +3,4 @@ require('safestart')(__dirname, {
}); });
require('babel-core/register'); require('babel-core/register');
require('babel-polyfill'); require('babel-polyfill');
require('localenv'); require('dotenv').config({ silent: true });

View File

@ -4,11 +4,11 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"clean": "rimraf dist", "clean": "rimraf dist",
"build:webpack": "cross-env NODE_ENV=production webpack --config webpack.config.prod.js", "build:webpack": "NODE_ENV=production webpack --config webpack.config.prod.js",
"build:analyze": "cross-env NODE_ENV=production webpack --config webpack.config.prod.js --json | webpack-bundle-size-analyzer", "build:analyze": "NODE_ENV=production webpack --config webpack.config.prod.js --json | webpack-bundle-size-analyzer",
"build": "npm run clean && npm run build:webpack", "build": "npm run clean && npm run build:webpack",
"start": "node index.js", "start": "node index.js",
"dev": "cross-env NODE_ENV=development DEBUG=sql,cache,presenters ./node_modules/.bin/nodemon --watch server index.js", "dev": "NODE_ENV=development DEBUG=sql,cache,presenters ./node_modules/.bin/nodemon --watch server index.js",
"lint": "npm run lint:js && npm run lint:flow", "lint": "npm run lint:js && npm run lint:flow",
"lint:js": "eslint frontend", "lint:js": "eslint frontend",
"lint:flow": "flow check", "lint:flow": "flow check",
@ -80,7 +80,6 @@
"boundless-popover": "^1.0.4", "boundless-popover": "^1.0.4",
"bugsnag": "^1.7.0", "bugsnag": "^1.7.0",
"classnames": "2.2.3", "classnames": "2.2.3",
"cross-env": "1.0.7",
"css-loader": "0.23.1", "css-loader": "0.23.1",
"debug": "2.2.0", "debug": "2.2.0",
"dotenv": "^4.0.0", "dotenv": "^4.0.0",
@ -119,7 +118,6 @@
"koa-mount": "^3.0.0", "koa-mount": "^3.0.0",
"koa-router": "7.0.1", "koa-router": "7.0.1",
"koa-sendfile": "2.0.0", "koa-sendfile": "2.0.0",
"localenv": "0.2.2",
"lodash": "^4.17.4", "lodash": "^4.17.4",
"lodash.orderby": "4.4.0", "lodash.orderby": "4.4.0",
"marked": "0.3.6", "marked": "0.3.6",
@ -148,7 +146,6 @@
"react-router-dom": "^4.1.1", "react-router-dom": "^4.1.1",
"redis": "^2.6.2", "redis": "^2.6.2",
"redis-lock": "^0.1.0", "redis-lock": "^0.1.0",
"reflexbox": "^2.2.3",
"rimraf": "^2.5.4", "rimraf": "^2.5.4",
"safestart": "1.1.0", "safestart": "1.1.0",
"sass-loader": "4.0.0", "sass-loader": "4.0.0",
@ -181,7 +178,6 @@
"fetch-test-server": "^1.1.0", "fetch-test-server": "^1.1.0",
"flow-bin": "^0.45.0", "flow-bin": "^0.45.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"ignore-loader": "0.1.1",
"jest-cli": "^20.0.0", "jest-cli": "^20.0.0",
"koa-webpack-dev-middleware": "1.4.5", "koa-webpack-dev-middleware": "1.4.5",
"koa-webpack-hot-middleware": "1.0.3", "koa-webpack-hot-middleware": "1.0.3",

View File

@ -5,8 +5,7 @@
"<rootDir>/server" "<rootDir>/server"
], ],
"setupFiles": [ "setupFiles": [
"<rootDir>/__mocks__/console.js", "<rootDir>/__mocks__/console.js"
"./server/test/helper.js"
], ],
"testEnvironment": "node" "testEnvironment": "node"
} }

View File

@ -24,7 +24,7 @@ router.post('collections.create', auth(), async ctx => {
}); });
ctx.body = { ctx.body = {
data: await presentCollection(ctx, atlas, true), data: await presentCollection(ctx, atlas),
}; };
}); });
@ -33,7 +33,7 @@ router.post('collections.info', auth(), async ctx => {
ctx.assertPresent(id, 'id is required'); ctx.assertPresent(id, 'id is required');
const user = ctx.state.user; const user = ctx.state.user;
const atlas = await Collection.findOne({ const atlas = await Collection.scope('withRecentDocuments').findOne({
where: { where: {
id, id,
teamId: user.teamId, teamId: user.teamId,
@ -43,7 +43,7 @@ router.post('collections.info', auth(), async ctx => {
if (!atlas) throw httpErrors.NotFound(); if (!atlas) throw httpErrors.NotFound();
ctx.body = { ctx.body = {
data: await presentCollection(ctx, atlas, true), data: await presentCollection(ctx, atlas),
}; };
}); });
@ -58,16 +58,10 @@ router.post('collections.list', auth(), pagination(), async ctx => {
limit: ctx.state.pagination.limit, limit: ctx.state.pagination.limit,
}); });
// Collectiones const data = await Promise.all(
let data = []; collections.map(async atlas => await presentCollection(ctx, atlas))
await Promise.all(
collections.map(async atlas => {
return data.push(await presentCollection(ctx, atlas, true));
})
); );
data = _.orderBy(data, ['updatedAt'], ['desc']);
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
data, data,

View File

@ -8,7 +8,6 @@ import { presentDocument } from '../presenters';
import { Document, Collection, Star, View } from '../models'; import { Document, Collection, Star, View } from '../models';
const router = new Router(); const router = new Router();
router.post('documents.list', auth(), pagination(), async ctx => { router.post('documents.list', auth(), pagination(), async ctx => {
let { sort = 'updatedAt', direction } = ctx.body; let { sort = 'updatedAt', direction } = ctx.body;
if (direction !== 'ASC') direction = 'DESC'; if (direction !== 'ASC') direction = 'DESC';
@ -19,9 +18,12 @@ router.post('documents.list', auth(), pagination(), async ctx => {
order: [[sort, direction]], order: [[sort, direction]],
offset: ctx.state.pagination.offset, offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit, limit: ctx.state.pagination.limit,
include: [{ model: Star, as: 'starred', where: { userId: user.id } }],
}); });
let data = await Promise.all(documents.map(doc => presentDocument(ctx, doc))); const data = await Promise.all(
documents.map(document => presentDocument(ctx, document))
);
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
@ -42,7 +44,7 @@ router.post('documents.viewed', auth(), pagination(), async ctx => {
limit: ctx.state.pagination.limit, limit: ctx.state.pagination.limit,
}); });
let data = await Promise.all( const data = await Promise.all(
views.map(view => presentDocument(ctx, view.document)) views.map(view => presentDocument(ctx, view.document))
); );
@ -60,12 +62,17 @@ router.post('documents.starred', auth(), pagination(), async ctx => {
const views = await Star.findAll({ const views = await Star.findAll({
where: { userId: user.id }, where: { userId: user.id },
order: [[sort, direction]], order: [[sort, direction]],
include: [{ model: Document }], include: [
{
model: Document,
include: [{ model: Star, as: 'starred', where: { userId: user.id } }],
},
],
offset: ctx.state.pagination.offset, offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit, limit: ctx.state.pagination.limit,
}); });
let data = await Promise.all( const data = await Promise.all(
views.map(view => presentDocument(ctx, view.document)) views.map(view => presentDocument(ctx, view.document))
); );
@ -94,8 +101,7 @@ router.post('documents.info', auth(), async ctx => {
ctx.body = { ctx.body = {
data: await presentDocument(ctx, document, { data: await presentDocument(ctx, document, {
includeCollection: document.private, includeViews: true,
includeCollaborators: true,
}), }),
}; };
}); });
@ -108,16 +114,8 @@ router.post('documents.search', auth(), async ctx => {
const documents = await Document.searchForUser(user, query); const documents = await Document.searchForUser(user, query);
const data = []; const data = await Promise.all(
await Promise.all( documents.map(async document => await presentDocument(ctx, document))
documents.map(async document => {
data.push(
await presentDocument(ctx, document, {
includeCollection: true,
includeCollaborators: true,
})
);
})
); );
ctx.body = { ctx.body = {
@ -200,11 +198,7 @@ router.post('documents.create', auth(), async ctx => {
} }
ctx.body = { ctx.body = {
data: await presentDocument(ctx, newDocument, { data: await presentDocument(ctx, newDocument),
includeCollection: true,
includeCollaborators: true,
collection: ownerCollection,
}),
}; };
}); });
@ -230,11 +224,7 @@ router.post('documents.update', auth(), async ctx => {
} }
ctx.body = { ctx.body = {
data: await presentDocument(ctx, document, { data: await presentDocument(ctx, document),
includeCollection: true,
includeCollaborators: true,
collection: collection,
}),
}; };
}); });
@ -273,11 +263,7 @@ router.post('documents.move', auth(), async ctx => {
} }
ctx.body = { ctx.body = {
data: await presentDocument(ctx, document, { data: await presentDocument(ctx, document),
includeCollection: true,
includeCollaborators: true,
collection: collection,
}),
}; };
}); });

View File

@ -11,4 +11,4 @@
"use_env_variable": "DATABASE_URL", "use_env_variable": "DATABASE_URL",
"dialect": "postgres" "dialect": "postgres"
} }
} }

View File

@ -1,9 +1,10 @@
// @flow
import debug from 'debug'; import debug from 'debug';
const debugCache = debug('cache'); const debugCache = debug('cache');
export default function cache() { export default function cache() {
return async function cacheMiddleware(ctx, next) { return async function cacheMiddleware(ctx: Object, next: Function) {
ctx.cache = {}; ctx.cache = {};
ctx.cache.set = async (id, value) => { ctx.cache.set = async (id, value) => {

View File

@ -1,14 +1,16 @@
module.exports = { module.exports = {
up: (queryInterface, Sequelize) => { up: (queryInterface, Sequelize) => {
queryInterface.renameTable('atlases', 'collections'); queryInterface.renameTable('atlases', 'collections').then(() => {
queryInterface.addColumn('collections', 'documentStructure', { queryInterface.addColumn('collections', 'documentStructure', {
type: Sequelize.JSONB, type: Sequelize.JSONB,
allowNull: true, allowNull: true,
});
}); });
}, },
down: (queryInterface, _Sequelize) => { down: (queryInterface, _Sequelize) => {
queryInterface.renameTable('collections', 'atlases'); queryInterface.renameTable('collections', 'atlases').then(() => {
queryInterface.removeColumn('atlases', 'documentStructure'); queryInterface.removeColumn('atlases', 'documentStructure');
});
}, },
}; };

View File

@ -1,40 +1,44 @@
module.exports = { module.exports = {
up: function(queryInterface, Sequelize) { up: function(queryInterface, Sequelize) {
queryInterface.createTable('views', { queryInterface
id: { .createTable('views', {
type: Sequelize.UUID, id: {
allowNull: false, type: Sequelize.UUID,
primaryKey: true, allowNull: false,
}, primaryKey: true,
documentId: { },
type: Sequelize.UUID, documentId: {
allowNull: false, type: Sequelize.UUID,
}, allowNull: false,
userId: { },
type: Sequelize.UUID, userId: {
allowNull: false, type: Sequelize.UUID,
}, allowNull: false,
count: { },
type: Sequelize.INTEGER, count: {
allowNull: false, type: Sequelize.INTEGER,
defaultValue: 1, allowNull: false,
}, defaultValue: 1,
createdAt: { },
type: Sequelize.DATE, createdAt: {
allowNull: false, type: Sequelize.DATE,
}, allowNull: false,
updatedAt: { },
type: Sequelize.DATE, updatedAt: {
allowNull: false, type: Sequelize.DATE,
}, allowNull: false,
}); },
queryInterface.addIndex('views', ['documentId', 'userId'], { })
indicesType: 'UNIQUE', .then(() => {
}); queryInterface.addIndex('views', ['documentId', 'userId'], {
indicesType: 'UNIQUE',
});
});
}, },
down: function(queryInterface, Sequelize) { down: function(queryInterface, Sequelize) {
queryInterface.removeIndex('views', ['documentId', 'userId']); queryInterface.removeIndex('views', ['documentId', 'userId']).then(() => {
queryInterface.dropTable('views'); queryInterface.dropTable('views');
});
}, },
}; };

View File

@ -1,35 +1,39 @@
module.exports = { module.exports = {
up: function(queryInterface, Sequelize) { up: function(queryInterface, Sequelize) {
queryInterface.createTable('stars', { queryInterface
id: { .createTable('stars', {
type: Sequelize.UUID, id: {
allowNull: false, type: Sequelize.UUID,
primaryKey: true, allowNull: false,
}, primaryKey: true,
documentId: { },
type: Sequelize.UUID, documentId: {
allowNull: false, type: Sequelize.UUID,
}, allowNull: false,
userId: { },
type: Sequelize.UUID, userId: {
allowNull: false, type: Sequelize.UUID,
}, allowNull: false,
createdAt: { },
type: Sequelize.DATE, createdAt: {
allowNull: false, type: Sequelize.DATE,
}, allowNull: false,
updatedAt: { },
type: Sequelize.DATE, updatedAt: {
allowNull: false, type: Sequelize.DATE,
}, allowNull: false,
}); },
queryInterface.addIndex('stars', ['documentId', 'userId'], { })
indicesType: 'UNIQUE', .then(() => {
}); queryInterface.addIndex('stars', ['documentId', 'userId'], {
indicesType: 'UNIQUE',
});
});
}, },
down: function(queryInterface, Sequelize) { down: function(queryInterface, Sequelize) {
queryInterface.removeIndex('stars', ['documentId', 'userId']); queryInterface.removeIndex('stars', ['documentId', 'userId']).then(() => {
queryInterface.dropTable('stars'); queryInterface.dropTable('stars');
});
}, },
}; };

View File

@ -60,6 +60,16 @@ const Collection = sequelize.define(
as: 'documents', as: 'documents',
foreignKey: 'atlasId', foreignKey: 'atlasId',
}); });
Collection.addScope('withRecentDocuments', {
include: [
{
as: 'documents',
limit: 10,
model: models.Document,
order: [['updatedAt', 'DESC']],
},
],
});
}, },
}, },
instanceMethods: { instanceMethods: {

View File

@ -100,7 +100,7 @@ const Document = sequelize.define(
instanceMethods: { instanceMethods: {
getUrl() { getUrl() {
const slugifiedTitle = slugify(this.title); const slugifiedTitle = slugify(this.title);
return `/d/${slugifiedTitle}-${this.urlId}`; return `/doc/${slugifiedTitle}-${this.urlId}`;
}, },
toJSON() { toJSON() {
// Warning: only use for new documents as order of children is // Warning: only use for new documents as order of children is
@ -115,7 +115,32 @@ const Document = sequelize.define(
}, },
classMethods: { classMethods: {
associate: models => { associate: models => {
Document.belongsTo(models.User); Document.belongsTo(models.Collection, {
as: 'collection',
foreignKey: 'atlasId',
});
Document.belongsTo(models.User, {
as: 'createdBy',
foreignKey: 'createdById',
});
Document.belongsTo(models.User, {
as: 'updatedBy',
foreignKey: 'lastModifiedById',
});
Document.hasMany(models.Star, {
as: 'starred',
});
Document.addScope(
'defaultScope',
{
include: [
{ model: models.Collection, as: 'collection' },
{ model: models.User, as: 'createdBy' },
{ model: models.User, as: 'updatedBy' },
],
},
{ override: true }
);
}, },
findById: async id => { findById: async id => {
if (isUUID(id)) { if (isUUID(id)) {

View File

@ -1,8 +1,9 @@
// @flow
import _ from 'lodash'; import _ from 'lodash';
import { Document } from '../models'; import { Collection } from '../models';
import presentDocument from './document'; import presentDocument from './document';
async function present(ctx, collection, includeRecentDocuments = false) { async function present(ctx: Object, collection: Collection) {
ctx.cache.set(collection.id, collection); ctx.cache.set(collection.id, collection);
const data = { const data = {
@ -13,31 +14,21 @@ async function present(ctx, collection, includeRecentDocuments = false) {
type: collection.type, type: collection.type,
createdAt: collection.createdAt, createdAt: collection.createdAt,
updatedAt: collection.updatedAt, updatedAt: collection.updatedAt,
recentDocuments: undefined,
documents: undefined,
}; };
if (collection.type === 'atlas') if (collection.type === 'atlas') {
data.documents = await collection.getDocumentsStructure(); data.documents = await collection.getDocumentsStructure();
}
if (includeRecentDocuments) { if (collection.documents) {
const documents = await Document.findAll({ data.recentDocuments = await Promise.all(
where: { collection.documents.map(
atlasId: collection.id, async document =>
}, await presentDocument(ctx, document, { includeCollaborators: true })
limit: 10, )
order: [['updatedAt', 'DESC']],
});
const recentDocuments = [];
await Promise.all(
documents.map(async document => {
recentDocuments.push(
await presentDocument(ctx, document, {
includeCollaborators: true,
})
);
})
); );
data.recentDocuments = _.orderBy(recentDocuments, ['updatedAt'], ['desc']);
} }
return data; return data;

View File

@ -1,26 +1,22 @@
// @flow // @flow
import { Collection, Star, User, View, Document } from '../models'; import _ from 'lodash';
import { User, Document, View } from '../models';
import presentUser from './user'; import presentUser from './user';
import presentCollection from './collection'; import presentCollection from './collection';
import _ from 'lodash';
type Options = { type Options = {
includeCollection?: boolean,
includeCollaborators?: boolean, includeCollaborators?: boolean,
includeViews?: boolean, includeViews?: boolean,
}; };
async function present(ctx: Object, document: Document, options: Options) { async function present(ctx: Object, document: Document, options: ?Options) {
options = { options = {
includeCollection: true,
includeCollaborators: true, includeCollaborators: true,
includeViews: true, includeViews: false,
...options, ...options,
}; };
ctx.cache.set(document.id, document); ctx.cache.set(document.id, document);
const data = {
const userId = ctx.state.user.id;
let data = {
id: document.id, id: document.id,
url: document.getUrl(), url: document.getUrl(),
private: document.private, private: document.private,
@ -29,36 +25,23 @@ async function present(ctx: Object, document: Document, options: Options) {
html: document.html, html: document.html,
preview: document.preview, preview: document.preview,
createdAt: document.createdAt, createdAt: document.createdAt,
createdBy: undefined, createdBy: presentUser(ctx, document.createdBy),
starred: false,
updatedAt: document.updatedAt, updatedAt: document.updatedAt,
updatedBy: undefined, updatedBy: presentUser(ctx, document.updatedBy),
team: document.teamId, team: document.teamId,
collaborators: [], collaborators: [],
starred: !!document.starred,
collection: undefined,
views: undefined,
}; };
data.starred = !!await Star.findOne({ if (document.private) {
where: { documentId: document.id, userId }, data.collection = await presentCollection(ctx, document.collection);
});
if (options.includeViews) {
// $FlowIssue not found in object literal?
data.views = await View.sum('count', {
where: { documentId: document.id },
});
} }
if (options.includeCollection) { if (options.includeViews) {
// $FlowIssue not found in object literal? data.views = await View.sum('count', {
data.collection = await ctx.cache.get(document.atlasId, async () => { where: { documentId: document.id },
const collection =
options.collection ||
(await Collection.findOne({
where: {
id: document.atlasId,
},
}));
return presentCollection(ctx, collection);
}); });
} }
@ -66,27 +49,13 @@ async function present(ctx: Object, document: Document, options: Options) {
// This could be further optimized by using ctx.cache // This could be further optimized by using ctx.cache
data['collaborators'] = await User.findAll({ data['collaborators'] = await User.findAll({
where: { where: {
id: { id: { $in: _.takeRight(document.collaboratorIds, 10) || [] },
$in: _.takeRight(document.collaboratorIds, 10) || [],
},
}, },
}).map(user => presentUser(ctx, user)); }).map(user => presentUser(ctx, user));
// $FlowIssue not found in object literal? // $FlowIssue not found in object literal?
data.collaboratorCount = document.collaboratorIds.length; data.collaboratorCount = document.collaboratorIds.length;
} }
const createdBy = await ctx.cache.get(
document.createdById,
async () => await User.findById(document.createdById)
);
data.createdBy = await presentUser(ctx, createdBy);
const updatedBy = await ctx.cache.get(
document.lastModifiedById,
async () => await User.findById(document.lastModifiedById)
);
data.updatedBy = await presentUser(ctx, updatedBy);
return data; return data;
} }

View File

@ -1,4 +1,7 @@
function present(ctx, team) { // @flow
import { Team } from '../models';
function present(ctx: Object, team: Team) {
ctx.cache.set(team.id, team); ctx.cache.set(team.id, team);
return { return {

View File

@ -1,28 +1,33 @@
<!doctype html> <!doctype html>
<html> <html>
<head>
<title>Atlas</title>
<link href="/static/styles.css" rel="stylesheet"></head>
<style>
body, html {
margin: 0;
padding: 0;
}
body { <head>
display: flex; <title>Atlas</title>
width: 100%; <link href="/static/styles.css" rel="stylesheet">
height: 100%; </head>
} <style>
body,
html {
margin: 0;
padding: 0;
}
#root { body {
flex: 1; display: flex;
height: 100%; width: 100%;
} height: 100%;
</style> }
</head>
<body> #root {
<div id="root"></div> flex: 1;
<script src="/static/bundle.js"></script> height: 100%;
</body> }
</html> </style>
</head>
<body>
<div id="root"></div>
<script src="/static/bundle.js"></script>
</body>
</html>

View File

@ -1,16 +1,30 @@
<!doctype html> <!doctype html>
<html> <html>
<head>
<title>Atlas</title> <head>
<style> <title>Atlas</title>
body, <style>
html { body,
display: flex; html {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
</style>
</head> body {
<body> display: flex;
</body> width: 100%;
</html> height: 100%;
}
#root {
flex: 1;
height: 100%;
}
</style>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -1,4 +1,5 @@
require('localenv'); // @flow
require('../../init');
// test environment variables // test environment variables
process.env.DATABASE_URL = process.env.DATABASE_URL_TEST; process.env.DATABASE_URL = process.env.DATABASE_URL_TEST;

View File

@ -36,13 +36,6 @@ module.exports = {
loader: 'url-loader?limit=1&mimetype=application/font-woff&name=public/fonts/[name].[ext]', loader: 'url-loader?limit=1&mimetype=application/font-woff&name=public/fonts/[name].[ext]',
}, },
{ test: /\.md/, loader: 'raw-loader' }, { test: /\.md/, loader: 'raw-loader' },
// Excludes
{
// slug
test: /unicode/,
loader: 'ignore-loader',
},
], ],
}, },
resolve: { resolve: {

View File

@ -41,12 +41,5 @@ productionWebpackConfig.plugins.push(
}, },
}) })
); );
productionWebpackConfig.plugins.push(
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('production'),
},
})
);
module.exports = productionWebpackConfig; module.exports = productionWebpackConfig;

View File

@ -1639,10 +1639,6 @@ combined-stream@^1.0.5, combined-stream@~1.0.5:
dependencies: dependencies:
delayed-stream "~1.0.0" delayed-stream "~1.0.0"
commander@2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.5.0.tgz#d777b6a4d847d423e5d475da864294ac1ff5aa9d"
commander@2.8.x: commander@2.8.x:
version "2.8.1" version "2.8.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.8.1.tgz#06be367febfda0c330aa1e2a072d3dc9762425d4" resolved "https://registry.yarnpkg.com/commander/-/commander-2.8.1.tgz#06be367febfda0c330aa1e2a072d3dc9762425d4"
@ -1899,20 +1895,6 @@ create-hmac@^1.1.0, create-hmac@^1.1.2:
create-hash "^1.1.0" create-hash "^1.1.0"
inherits "^2.0.1" inherits "^2.0.1"
cross-env@1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-1.0.7.tgz#dd6cea13b31df4ffab4591343e605e370182647e"
dependencies:
cross-spawn-async "2.0.0"
lodash.assign "^3.2.0"
cross-spawn-async@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/cross-spawn-async/-/cross-spawn-async-2.0.0.tgz#4af143df4156900d012be41cabf4da3abfc797c0"
dependencies:
lru-cache "^2.6.5"
which "^1.1.1"
cross-spawn@^3.0.0: cross-spawn@^3.0.0:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982"
@ -3970,10 +3952,6 @@ ignore-by-default@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09"
ignore-loader@0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/ignore-loader/-/ignore-loader-0.1.1.tgz#187c846c661afcdee269ef4c3f42c73888903334"
ignore@^3.2.0: ignore@^3.2.0:
version "3.2.7" version "3.2.7"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.2.7.tgz#4810ca5f1d8eca5595213a34b94f2eb4ed926bbd" resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.2.7.tgz#4810ca5f1d8eca5595213a34b94f2eb4ed926bbd"
@ -5165,12 +5143,6 @@ loader-utils@0.2.x, loader-utils@^0.2.11, loader-utils@^0.2.14, loader-utils@^0.
json5 "^0.5.0" json5 "^0.5.0"
object-assign "^4.0.1" object-assign "^4.0.1"
localenv@0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/localenv/-/localenv-0.2.2.tgz#c508f29d3485bdc9341d3ead17f61c5abd1b0bab"
dependencies:
commander "2.5.0"
locate-path@^2.0.0: locate-path@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
@ -5293,7 +5265,7 @@ lodash._topath@^3.0.0:
dependencies: dependencies:
lodash.isarray "^3.0.0" lodash.isarray "^3.0.0"
lodash.assign@^3.0.0, lodash.assign@^3.2.0: lodash.assign@^3.0.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-3.2.0.tgz#3ce9f0234b4b2223e296b8fa0ac1fee8ebca64fa" resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-3.2.0.tgz#3ce9f0234b4b2223e296b8fa0ac1fee8ebca64fa"
dependencies: dependencies:
@ -5564,7 +5536,7 @@ lowercase-keys@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306" resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306"
lru-cache@2, lru-cache@^2.6.5: lru-cache@2:
version "2.7.3" version "2.7.3"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952"
@ -8969,7 +8941,7 @@ which-module@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f"
which@1, which@^1.0.5, which@^1.1.1, which@^1.2.10, which@^1.2.12, which@^1.2.9: which@1, which@^1.0.5, which@^1.2.10, which@^1.2.12, which@^1.2.9:
version "1.2.14" version "1.2.14"
resolved "https://registry.yarnpkg.com/which/-/which-1.2.14.tgz#9a87c4378f03e827cecaf1acdf56c736c01c14e5" resolved "https://registry.yarnpkg.com/which/-/which-1.2.14.tgz#9a87c4378f03e827cecaf1acdf56c736c01c14e5"
dependencies: dependencies: