Sidebar work

This commit is contained in:
Jori Lallo
2017-06-15 20:39:08 -07:00
parent 42e54a3a54
commit aa0ddd94bf
21 changed files with 250 additions and 368 deletions

View File

@ -6,23 +6,31 @@ 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 searchIcon from 'assets/icons/search.svg';
import { Flex } from 'reflexbox'; import { Flex } from 'reflexbox';
import { textColor, headerHeight } from 'styles/constants.scss'; import { textColor } from 'styles/constants.scss';
import DropdownMenu, { MenuItem } from 'components/DropdownMenu'; import DropdownMenu, { MenuItem } from 'components/DropdownMenu';
import LoadingIndicator from 'components/LoadingIndicator'; import LoadingIndicator from 'components/LoadingIndicator';
import SidebarCollection from './components/SidebarCollection';
import SidebarCollectionList from './components/SidebarCollectionList';
import SidebarLink from './components/SidebarLink';
import UserStore from 'stores/UserStore'; import UserStore from 'stores/UserStore';
import AuthStore from 'stores/AuthStore'; import AuthStore from 'stores/AuthStore';
import UiStore from 'stores/UiStore';
import CollectionsStore from 'stores/CollectionsStore';
type Props = { type Props = {
history: Object, history: Object,
collections: CollectionsStore,
children?: ?React.Element<any>, children?: ?React.Element<any>,
actions?: ?React.Element<any>, actions?: ?React.Element<any>,
title?: ?React.Element<any>, title?: ?React.Element<any>,
loading?: boolean, loading?: boolean,
user: UserStore, user: UserStore,
auth: AuthStore, auth: AuthStore,
ui: UiStore,
search: ?boolean, search: ?boolean,
notifications?: React.Element<any>, notifications?: React.Element<any>,
}; };
@ -36,13 +44,13 @@ type Props = {
@keydown(['/', 't']) @keydown(['/', 't'])
search() { search() {
if (this.props.auth.isAuthenticated) if (this.props.auth.authenticated)
_.defer(() => this.props.history.push('/search')); _.defer(() => this.props.history.push('/search'));
} }
@keydown(['d']) @keydown(['d'])
dashboard() { dashboard() {
if (this.props.auth.isAuthenticated) if (this.props.auth.authenticated)
_.defer(() => this.props.history.push('/')); _.defer(() => this.props.history.push('/'));
} }
@ -51,7 +59,7 @@ type Props = {
}; };
render() { render() {
const { auth, user } = this.props; const { user, auth, ui, collections } = this.props;
return ( return (
<Container column auto> <Container column auto>
@ -69,48 +77,52 @@ type Props = {
{this.props.notifications} {this.props.notifications}
<Header> <Flex auto>
<Flex align="center"> {auth.authenticated &&
<LogoLink to="/">Atlas</LogoLink> user &&
</Flex> <Sidebar column>
<Flex> <Header justify="space-between">
<Flex> <Flex align="center">
<Flex align="center"> <LogoLink to="/">Atlas</LogoLink>
{this.props.actions} </Flex>
</Flex> <DropdownMenu label={<Avatar src={user.user.avatarUrl} />}>
{auth.authenticated && <MenuLink to="/settings">
user && <MenuItem>Settings</MenuItem>
<Flex> </MenuLink>
{this.props.search && <MenuLink to="/keyboard-shortcuts">
<Flex> <MenuItem>
<Link to="/search"> Keyboard shortcuts
<Search title="Search (/)"> </MenuItem>
<SearchIcon src={searchIcon} alt="Search" /> </MenuLink>
</Search> <MenuLink to="/developers">
</Link> <MenuItem>API</MenuItem>
</Flex>} </MenuLink>
<DropdownMenu label={<Avatar src={user.user.avatarUrl} />}> <MenuItem onClick={this.handleLogout}>Logout</MenuItem>
<MenuLink to="/settings"> </DropdownMenu>
<MenuItem>Settings</MenuItem> </Header>
</MenuLink>
<MenuLink to="/keyboard-shortcuts">
<MenuItem>
Keyboard shortcuts
</MenuItem>
</MenuLink>
<MenuLink to="/developers">
<MenuItem>API</MenuItem>
</MenuLink>
<MenuItem onClick={this.handleLogout}>Logout</MenuItem>
</DropdownMenu>
</Flex>}
</Flex>
</Flex>
</Header>
<Content auto justify="center"> <Flex column>
{this.props.children} <LinkSection>
</Content> <SidebarLink to="/search">Search</SidebarLink>
</LinkSection>
<LinkSection>
<SidebarLink to="/dashboard">Dashboard</SidebarLink>
<SidebarLink to="/starred">Starred</SidebarLink>
</LinkSection>
<LinkSection>
{ui.activeCollection
? <SidebarCollection
collection={collections.getById(ui.activeCollection)}
/>
: <SidebarCollectionList />}
</LinkSection>
</Flex>
</Sidebar>}
<Content auto justify="center">
{this.props.children}
</Content>
</Flex>
</Container> </Container>
); );
} }
@ -122,24 +134,8 @@ const Container = styled(Flex)`
height: 100%; height: 100%;
`; `;
const Header = styled(Flex)`
position: absolute;
top: 0;
left: 0;
right: 0;
justify-content: space-between;
align-items: center;
padding: 0 20px;
z-index: 1;
height: ${headerHeight};
font-size: 14px;
line-height: 1;
`;
const LogoLink = styled(Link)` const LogoLink = styled(Link)`
margin-top: 5px;
font-family: 'Atlas Grotesk'; font-family: 'Atlas Grotesk';
font-weight: bold; font-weight: bold;
color: ${textColor}; color: ${textColor};
@ -147,16 +143,6 @@ const LogoLink = styled(Link)`
font-size: 16px; font-size: 16px;
`; `;
const Search = styled(Flex)`
margin: 0 5px;
padding: 15px 5px 0 5px;
cursor: pointer;
`;
const SearchIcon = styled.img`
height: 20px;
`;
const Avatar = styled.img` const Avatar = styled.img`
width: 24px; width: 24px;
height: 24px; height: 24px;
@ -172,4 +158,20 @@ const Content = styled(Flex)`
overflow: scroll; overflow: scroll;
`; `;
export default withRouter(inject('user', 'auth')(Layout)); const Sidebar = styled(Flex)`
width: 250px;
padding: 10px 20px;
background: rgba(250, 251, 252, 0.71);
border-right: 1px solid #eceff3;
`;
const Header = styled(Flex)`
margin-bottom: 20px;
`;
const LinkSection = styled(Flex)`
margin-bottom: 20px;
flex-direction: column;
`;
export default withRouter(inject('user', 'auth', 'ui', 'collections')(Layout));

View File

@ -0,0 +1,39 @@
// @flow
import React from 'react';
import { observer } from 'mobx-react';
import { Flex } from 'reflexbox';
import styled from 'styled-components';
import SidebarLink from '../SidebarLink';
import Collection from 'models/Collection';
type Props = {
collection: Collection,
};
const SidebarCollection = ({ collection }: Props) => {
if (collection) {
return (
<Flex column>
<Header>{collection.name}</Header>
{collection.documents.map(document => (
<SidebarLink key={document.id} to={document.url}>
{document.title}
</SidebarLink>
))}
</Flex>
);
}
return null;
};
const Header = styled(Flex)`
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
color: #9FA6AB;
letter-spacing: 0.04em;
`;
export default observer(SidebarCollection);

View File

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

View File

@ -0,0 +1,36 @@
// @flow
import React from 'react';
import { observer, inject } from 'mobx-react';
import { Flex } from 'reflexbox';
import styled from 'styled-components';
import SidebarLink from '../SidebarLink';
import CollectionsStore from 'stores/CollectionsStore';
type Props = {
collections: CollectionsStore,
};
const SidebarCollectionList = observer(({ collections }: Props) => {
return (
<Flex column>
<Header>Collections</Header>
{collections.data.map(collection => (
<SidebarLink key={collection.id} to={collection.url}>
{collection.name}
</SidebarLink>
))}
</Flex>
);
});
const Header = styled(Flex)`
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
color: #9FA6AB;
letter-spacing: 0.04em;
`;
export default inject('collections')(SidebarCollectionList);

View File

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

View File

@ -0,0 +1,26 @@
// @flow
import React from 'react';
import { observer } from 'mobx-react';
import { NavLink } from 'react-router-dom';
import { Flex } from 'reflexbox';
import styled from 'styled-components';
const activeStyle = {
color: '#000000',
};
const SidebarLink = observer(props => (
<LinkContainer>
<NavLink {...props} activeStyle={activeStyle} />
</LinkContainer>
));
const LinkContainer = styled(Flex)`
padding: 5px 0;
a {
color: #848484;
}
`;
export default SidebarLink;

View File

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

View File

@ -1,85 +0,0 @@
// @flow
import React from 'react';
import { observer } from 'mobx-react';
import { withRouter } from 'react-router-dom';
import { Flex } from 'reflexbox';
import Tree from 'components/Tree';
import Separator from './components/Separator';
import styles from './Sidebar.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
import SidebarStore from './SidebarStore';
type Props = {
open?: boolean,
onToggle: Function,
navigationTree: Object,
onNavigationUpdate: Function,
onNodeCollapse: Function,
history: Object,
};
@observer class Sidebar extends React.Component {
props: Props;
store: SidebarStore;
constructor(props: Props) {
super(props);
this.store = new SidebarStore();
}
toggleEdit = (e: MouseEvent) => {
e.preventDefault();
this.store.toggleEdit();
};
render() {
return (
<Flex>
{this.props.open &&
<Flex column className={cx(styles.sidebar)}>
<Flex auto className={cx(styles.content)}>
<Tree
paddingLeft={10}
tree={this.props.navigationTree}
allowUpdates={this.store.isEditing}
onChange={this.props.onNavigationUpdate}
onCollapse={this.props.onNodeCollapse}
history={this.props.history}
/>
</Flex>
<Flex auto className={styles.actions}>
{this.store.isEditing &&
<span className={styles.action}>
Drag & drop to organize <Separator />&nbsp;
</span>}
<span
role="button"
onClick={this.toggleEdit}
className={cx(styles.action, { active: this.store.isEditing })}
>
{!this.store.isEditing ? 'Organize documents' : 'Done'}
</span>
</Flex>
</Flex>}
<div
onClick={this.props.onToggle}
className={cx(styles.sidebarToggle, { active: this.store.isEditing })}
title="Toggle sidebar (Cmd+/)"
>
<img
src={require('assets/icons/menu.svg')}
className={styles.menuIcon}
alt="Menu"
/>
</div>
</Flex>
);
}
}
export default withRouter(Sidebar);

View File

@ -1,56 +0,0 @@
@import '~styles/constants.scss';
.sidebar {
position: relative;
width: 250px;
border-right: 1px solid #eee;
font-size: 13px;
}
.sidebarToggle {
display: flex;
position: relative;
width: 60px;
cursor: pointer;
justify-content: center;
&:hover {
background-color: #f5f5f5;
.menuIcon {
opacity: 1;
}
}
}
.menuIcon {
margin-top: 18px;
height: 28px;
opacity: 0.15;
}
.content {
padding: 20px 20px 20px 5px;
}
.tree {
}
.actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
padding: 10px 20px;
height: 40px;
}
.action {
color: $gray;
&.active {
color: $textColor;
}
}

View File

@ -1,14 +0,0 @@
// @flow
import { observable, action } from 'mobx';
class SidebarStore {
@observable isEditing = false;
/* Actions */
@action toggleEdit = () => {
this.isEditing = !this.isEditing;
};
}
export default SidebarStore;

View File

@ -1,16 +0,0 @@
// @flow
import React from 'react';
import styles from './Separator.scss';
class Separator extends React.Component {
render() {
return (
<span className={styles.separator}>
·
</span>
);
}
}
export default Separator;

View File

@ -1,6 +0,0 @@
@import '~styles/constants.scss';
.separator {
padding: 0 10px;
color: $lightGray;
}

View File

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

View File

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

View File

@ -3,9 +3,9 @@ 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 _ from 'lodash'; import _ from 'lodash';
import { notFoundUrl } from 'utils/routeHelpers';
import CollectionsStore from 'stores/CollectionsStore'; import CollectionsStore from 'stores/CollectionsStore';
import CollectionStore from './CollectionStore';
import Layout from 'components/Layout'; import Layout from 'components/Layout';
import CenteredContent from 'components/CenteredContent'; import CenteredContent from 'components/CenteredContent';
@ -16,48 +16,28 @@ type Props = {
match: Object, match: Object,
}; };
type State = {
redirectUrl: ?string,
};
@observer class Collection extends React.Component { @observer class Collection extends React.Component {
props: Props; props: Props;
state: State; store: CollectionStore;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.store = new CollectionStore();
redirectUrl: null,
};
} }
componentDidMount = () => { componentDidMount = () => {
const { id } = this.props.match.params; const { id } = this.props.match.params;
this.props.collections this.store.fetchCollection(id);
.getById(id)
.then(collection => {
if (collection.type !== 'atlas')
throw new Error('TODO code up non-atlas collections');
this.setState({
redirectUrl: collection.documents[0].url,
});
})
.catch(() => {
this.setState({
redirectUrl: notFoundUrl(),
});
});
}; };
render() { render() {
return ( return (
<Layout> <Layout>
{this.state.redirectUrl && <Redirect to={this.state.redirectUrl} />} {this.store.redirectUrl
? <Redirect to={this.store.redirectUrl} />
<CenteredContent> : <CenteredContent>
<PreviewLoading /> <PreviewLoading />
</CenteredContent> </CenteredContent>}
</Layout> </Layout>
); );
} }

View File

@ -1,37 +1,30 @@
// @flow // @flow
import { observable, action, computed } from 'mobx'; import { observable, action } from 'mobx';
import invariant from 'invariant'; import invariant from 'invariant';
import { client } from 'utils/ApiClient'; import { client } from 'utils/ApiClient';
import Collection from 'models/Collection'; import { notFoundUrl } from 'utils/routeHelpers';
const store = new class AtlasStore {
@observable collection: ?(Collection & { recentDocuments?: Object[] });
class CollectionStore {
@observable redirectUrl: ?string;
@observable isFetching = true; @observable isFetching = true;
/* Computed */
@computed get isLoaded(): boolean {
return !this.isFetching && !!this.collection;
}
/* Actions */ /* Actions */
@action fetchCollection = async (id: string, successCallback: Function) => { @action fetchCollection = async (id: string) => {
this.isFetching = true; this.isFetching = true;
this.collection = null;
try { try {
const res = await client.get('/collections.info', { id }); const res = await client.get('/collections.info', { id });
invariant(res && res.data, 'Data should be available'); invariant(res && res.data, 'Data should be available');
const { data } = res; const { data } = res;
this.collection = new Collection(data);
successCallback(data); if (data.type === 'atlas') this.redirectUrl = data.documents[0].url;
else throw new Error('TODO code up non-atlas collections');
} catch (e) { } catch (e) {
console.error('Something went wrong'); this.redirectUrl = notFoundUrl();
} }
this.isFetching = false; this.isFetching = false;
}; };
}(); }
export default store; export default CollectionStore;

View File

@ -2,10 +2,12 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import get from 'lodash/get'; import get from 'lodash/get';
import styled from 'styled-components'; import styled from 'styled-components';
import { observer } 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 'reflexbox';
import UiStore from 'stores/UiStore';
import DocumentStore from './DocumentStore'; import DocumentStore from './DocumentStore';
import Breadcrumbs from './components/Breadcrumbs'; import Breadcrumbs from './components/Breadcrumbs';
import Menu from './components/Menu'; import Menu from './components/Menu';
@ -26,6 +28,7 @@ type Props = {
history: Object, history: Object,
keydown: Object, keydown: Object,
newChildDocument?: boolean, newChildDocument?: boolean,
ui: UiStore,
}; };
@observer class Document extends Component { @observer class Document extends Component {
@ -34,10 +37,13 @@ type Props = {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.store = new DocumentStore({ history: this.props.history }); this.store = new DocumentStore({
history: this.props.history,
ui: props.ui,
});
} }
componentDidMount = () => { componentDidMount() {
if (this.props.newDocument) { if (this.props.newDocument) {
this.store.collectionId = this.props.match.params.id; this.store.collectionId = this.props.match.params.id;
this.store.newDocument = true; this.store.newDocument = true;
@ -53,7 +59,11 @@ type Props = {
this.store.newDocument = false; this.store.newDocument = false;
this.store.fetchDocument(); this.store.fetchDocument();
} }
}; }
componentWillUnmout() {
this.props.ui.clearActiveCollection();
}
onEdit = () => { onEdit = () => {
const url = `${this.store.document.url}/edit`; const url = `${this.store.document.url}/edit`;
@ -163,4 +173,4 @@ const Container = styled.div`
width: 50em; width: 50em;
`; `;
export default withRouter(Document); export default withRouter(inject('ui')(Document));

View File

@ -38,8 +38,7 @@ class CollectionsStore {
} }
}; };
@action getById = async (id: string): Promise<Collection> => { getById = (id: string): Collection => {
if (!this.isLoaded) await this.fetch();
return _.find(this.data, { id }); return _.find(this.data, { id });
}; };

View File

@ -1,34 +1,18 @@
// @flow // @flow
import { observable, action, computed, autorunAsync } from 'mobx'; import { observable, action } from 'mobx';
const UI_STORE = 'UI_STORE';
class UiStore { class UiStore {
@observable sidebar: boolean = false; @observable activeCollection: ?string;
/* Computed */
@computed get asJson(): string {
return JSON.stringify({
sidebar: this.sidebar,
});
}
/* Actions */ /* Actions */
@action toggleSidebar = (): void => { @action setActiveCollection = (id: string): void => {
this.sidebar = !this.sidebar; this.activeCollection = id;
}; };
constructor() { @action clearActiveCollection = (): void => {
// Rehydrate this.activeCollection = null;
const data = JSON.parse(localStorage.getItem(UI_STORE) || '{}'); };
this.sidebar = data.sidebar;
autorunAsync(() => {
localStorage.setItem(UI_STORE, this.asJson);
});
}
} }
export default UiStore; export default UiStore;

View File

@ -20,11 +20,11 @@ html, body, .viewport {
} }
body { body {
font-family: -apple-system, 'Helvetica Neue', Helvetica, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
font-size: 15px; font-size: 15px;
line-height: 1.5; line-height: 1.5;
margin: 0; margin: 0;
color: $textColor; color: #617180;
background-color: #fff; background-color: #fff;
display: flex; display: flex;
position: absolute; position: absolute;
@ -32,6 +32,10 @@ body {
right: 0; right: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
} }
img { img {
max-width: 100%; max-width: 100%;
@ -41,7 +45,7 @@ svg {
max-height: 100%; max-height: 100%;
} }
a { a {
color: $actionColor; color: #005AA6;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }

View File

@ -1,36 +1,19 @@
<!doctype html> <!doctype html>
<html> <html>
<head>
<title>Atlas</title>
<style>
body, html {
margin: 0;
padding: 0;
}
#root { <head>
flex: 1; <title>Atlas</title>
height: 100%; <style>
} body,
html {
display: flex;
margin: 0;
padding: 0;
}
</style>
</head>
.container { <body>
display: flex; </body>
flex;
}
.header { </html>
display: flex;
flex: 1;
height: 42px;
border-bottom: 1px solid #eee;
}
</style>
</head>
<body style='display: flex; width: 100%; height: 100%;'>
<div id="root">
<div class="container">
<div class="header"></div>
</div>
</div>
</body>
</html>