Renamed /src to /frontend

This commit is contained in:
Jori Lallo
2016-07-24 15:32:31 -07:00
parent 19da05eee7
commit d2187c4b10
147 changed files with 10 additions and 10 deletions

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { observer } from 'mobx-react';
import Link from 'react-router/lib/Link';
import DocumentLink from './components/DocumentLink';
import styles from './AtlasPreview.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
@observer
class AtlasPreview extends React.Component {
static propTypes = {
data: React.PropTypes.object.isRequired,
}
render() {
const data = this.props.data;
return (
<div className={ styles.container }>
<h2><Link to={ `/atlas/${data.id}` } className={ styles.atlasLink }>{ data.name }</Link></h2>
{ data.recentDocuments.length > 0 ?
data.recentDocuments.map(document => {
return (
<DocumentLink document={ document } key={ document.id } />)
})
: (
<div className={ styles.description }>No documents. Why not <Link to={ `/atlas/${data.id}/new` }>create one</Link>?</div>
) }
</div>
);
}
};
export default AtlasPreview;

View File

@@ -0,0 +1,19 @@
@import '~styles/constants.scss';
.container {
display: flex;
flex: 1;
flex-direction: column;
padding-bottom: 40px;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
}
.atlasLink {
text-decoration: none;
color: $textColor;
}
.description {
color: #aaa;
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { observer } from "mobx-react"
import moment from 'moment';
import Link from 'react-router/lib/Link';
import styles from './DocumentLink.scss';
const DocumentLink = observer((props) => {
return (
<Link to={ `/documents/${props.document.id}` } className={ styles.link }>
<h3 className={ styles.title }>{ props.document.title }</h3>
<span className={ styles.timestamp }>{ moment(props.document.updatedAt).fromNow() }</span>
</Link>
);
});
export default DocumentLink;

View File

@@ -0,0 +1,23 @@
@import '~styles/constants.scss';
.link {
display: flex;
flex: 1;
justify-content: space-between;
margin-bottom: 20px;
text-decoration: none;
}
.title {
font-weight: normal;
font-size: 15px;
color: $textColor;
margin: 0;
}
.timestamp {
font-size: 13px;
color: #ccc;
}

View File

@@ -0,0 +1,2 @@
import DocumentLink from './DocumentLink';
export default DocumentLink;

View File

@@ -0,0 +1,2 @@
import AtlasPreview from './AtlasPreview';
export default AtlasPreview;

View File

@@ -0,0 +1,18 @@
import React from 'react';
import styles from './AtlasPreviewLoading.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
import { randomInteger } from 'utils/random';
export default (props) => {
return (
<div className={ cx(styles.container, styles.animated) }>
<div className={ cx(styles.mask, styles.header) } style={{ width: `${randomInteger(65,80)}%` }}>&nbsp;</div>
<div className={ cx(styles.mask, styles.bodyText) } style={{ width: `${randomInteger(85,100)}%` }}>&nbsp;</div>
<div className={ cx(styles.mask, styles.bodyText) } style={{ width: `${randomInteger(85,100)}%` }}>&nbsp;</div>
<div className={ cx(styles.mask, styles.bodyText) } style={{ width: `${randomInteger(85,100)}%` }}>&nbsp;</div>
</div>
);
};

View File

@@ -0,0 +1,26 @@
.mask {
display: flex;
width: 100%;
background-color: #ddd;
}
.header {
height: 28px;
margin-bottom: 32px;
}
.bodyText {
height: 18px;
margin-bottom: 14px;
}
.animated {
width: 100%;
animation: PULSATE 1.3s infinite;
}
@keyframes PULSATE {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}

View File

@@ -0,0 +1,2 @@
import AtlasPreviewLoading from './AtlasPreviewLoading';
export default AtlasPreviewLoading;

View File

View File

View File

@@ -0,0 +1,2 @@
import Button from './Button';
export default Button;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import styles from './CenteredContent.scss';
const CenteredContent = (props) => {
const style = {
maxWidth: props.maxWidth,
...props.style,
};
return (
<div className={ styles.content } style={ style }>
{ props.children }
</div>
);
};
CenteredContent.defaultProps = {
maxWidth: '740px',
};
CenteredContent.propTypes = {
children: React.PropTypes.node.isRequired,
style: React.PropTypes.object,
};
export default CenteredContent;

View File

@@ -0,0 +1,4 @@
.content {
width: 100%;
margin: 40px 20px;
}

View File

@@ -0,0 +1,2 @@
import CenteredContent from './CenteredContent';
export default CenteredContent;

View File

@@ -0,0 +1,11 @@
import React from 'react';
import styles from './Divider.scss';
const Divider = (props) => {
return(
<div className={ styles.divider }><span></span></div>
);
};
export default Divider;

View File

@@ -0,0 +1,13 @@
.divider {
display: flex;
flex: 1;
justify-content: center;
span {
display: flex;
width: 50%;
margin: 20px 0;
border-bottom: 1px solid #eee;
}
}

View File

@@ -0,0 +1,2 @@
import Divider from './Divider';
export default Divider;

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { observer } from 'mobx-react';
import moment from 'moment';
import { Link } from 'react-router';
import PublishingInfo from 'components/PublishingInfo';
import styles from './Document.scss';
const DocumentHtml = observer((props) => {
return (
<div
className={ styles.document }
dangerouslySetInnerHTML={{ __html: props.html }}
{ ...props }
/>
);
});
@observer
class Document extends React.Component {
static propTypes = {
document: React.PropTypes.object.isRequired,
}
render() {
return (
<div className={ styles.container }>
<PublishingInfo
name={ this.props.document.user.name }
avatarUrl={ this.props.document.user.avatarUrl }
createdAt={ this.props.document.createdAt }
updatedAt={ this.props.document.updatedAt }
/>
<DocumentHtml html={ this.props.document.html } />
</div>
);
}
};
export default Document;
export {
DocumentHtml
};

View File

@@ -0,0 +1,31 @@
.container {
width: 100%;
padding: 20px;
}
.document {
h1, h2, h3, h4, h5, h6 {
:global {
.anchor {
visibility: hidden;
color: #ccc;
}
}
&:hover {
:global {
.anchor {
visibility: visible;
}
}
}
}
ul {
padding-left: 1.5em;
ul {
margin: 0;
}
}
}

View File

@@ -0,0 +1,6 @@
import Document, { DocumentHtml } from './Document';
export default Document;
export {
DocumentHtml,
};

View File

@@ -0,0 +1,29 @@
import React from 'react';
import DocumentPreview from 'components/DocumentPreview';
import Divider from 'components/Divider';
import styles from './DocumentList.scss';
class DocumentList extends React.Component {
static propTypes = {
documents: React.PropTypes.arrayOf(React.PropTypes.object),
}
render() {
return (
<div>
{ this.props.documents && this.props.documents.map((document) => {
return (
<div>
<DocumentPreview document={ document } />
<Divider />
</div>
);
}) }
</div>
);
}
};
export default DocumentList;

View File

@@ -0,0 +1,2 @@
import DocumentList from './DocumentList';
export default DocumentList;

View File

@@ -0,0 +1,46 @@
import React from 'react';
import marked from 'marked';
import { Link } from 'react-router';
import PublishingInfo from 'components/PublishingInfo';
import styles from './DocumentPreview.scss';
class Document extends React.Component {
static propTypes = {
document: React.PropTypes.object.isRequired,
}
render() {
return (
<div className={ styles.container }>
<PublishingInfo
avatarUrl={ this.props.document.user.avatarUrl }
name={ this.props.document.user.name }
createdAt={ document.createdAt }
/>
<Link
to={ `/documents/${this.props.document.id}` }
className={ styles.title }
>
<h2>{ this.props.document.title }</h2>
</Link>
<div dangerouslySetInnerHTML={{ __html: this.props.document.preview }} />
<div>
<Link
to={ `/documents/${this.props.document.id}` }
className={ styles.continueLink }
>
Continue reading...
</Link>
</div>
</div>
);
}
};
export default Document;

View File

@@ -0,0 +1,20 @@
@import '~styles/constants.scss';
.container {
width: 100%;
padding: 20px 0;
}
.title {
color: $textColor;
text-decoration: none;
h2 {
font-size: 1.3em;
}
}
.continueLink {
text-decoration: none;
}

View File

@@ -0,0 +1,2 @@
import DocumentPreview from './DocumentPreview';
export default DocumentPreview;

View File

@@ -0,0 +1,66 @@
import React from 'react';
import styles from './DropdownMenu.scss';
const MenuItem = (props) => {
return (
<div
className={ styles.menuItem }
onClick={ props.onClick}
>{ props.children }</div>
);
};
MenuItem.propTypes = {
onClick: React.PropTypes.func,
children: React.PropTypes.node.isRequired,
};
//
class DropdownMenu extends React.Component {
static propTypes = {
label: React.PropTypes.node.isRequired,
children: React.PropTypes.node.isRequired,
}
state = {
menuVisible: false,
}
onMouseEnter = () => {
this.setState({ menuVisible: true });
}
onMouseLeave = () => {
this.setState({ menuVisible: false });
}
onClick = () => {
this.setState({ menuVisible: !this.state.menuVisible });
}
render() {
return (
<div
className={ styles.menuContainer }
onMouseEnter={ this.onMouseEnter }
onMouseLeave={ this.onMouseLeave }
>
<div className={ styles.label } onClick={ this.onClick }>
{ this.props.label }
</div>
{ this.state.menuVisible ? (
<div className={ styles.menu }>
{ this.props.children }
</div>
) : null }
</div>
);
}
};
export default DropdownMenu;
export {
MenuItem,
}

View File

@@ -0,0 +1,51 @@
@import '~styles/constants.scss';
.label {
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
min-height: 43px;
margin: 0 5px;
color: $actionColor;
}
.menuContainer {
position: relative;
.menu {
position: absolute;
top: $headerHeight;
right: 0;
z-index: 1000;
border: 1px solid #eee;
background-color: #fff;
min-width: 150px;
}
}
.menuItem {
margin: 0;
padding: 5px 10px;
height: 32px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
border-left: 2px solid transparent;
span {
margin-top: 2px;
}
a {
color: $textColor;
text-decoration: none;
}
&:hover {
border-left: 2px solid $actionColor;
}
}

View File

@@ -0,0 +1,14 @@
import React from 'react';
import styles from './MoreIcon.scss';
const MoreIcon = (props) => {
return (
<img
src={ require("./assets/more.svg") }
className={ styles.icon }
/>
);
};
export default MoreIcon;

View File

@@ -0,0 +1,4 @@
.icon {
width: 21px;
margin-top: 6px;
}

View File

@@ -0,0 +1 @@
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" version="1.1" x="0px" y="0px" viewBox="0 0 100 125"><g transform="translate(0,-952.36218)"><path style="color:#000000;enable-background:accumulate;" d="M 17 41 C 12.029437 41 8 45.029437 8 50 C 8 54.970563 12.029437 59 17 59 C 21.970563 59 26 54.970563 26 50 C 26 45.029437 21.970563 41 17 41 z M 50 41 C 45.029437 41 41 45.029437 41 50 C 41 54.970563 45.029437 59 50 59 C 54.970563 59 59 54.970563 59 50 C 59 45.029437 54.970563 41 50 41 z M 83 41 C 78.029437 41 74 45.029437 74 50 C 74 54.970563 78.029437 59 83 59 C 87.970563 59 92 54.970563 92 50 C 92 45.029437 87.970563 41 83 41 z " transform="translate(0,952.36218)" fill="#000000" stroke="none" marker="none" visibility="visible" display="inline" overflow="visible"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,2 @@
import MoreIcon from './MoreIcon';
export default MoreIcon;

View File

@@ -0,0 +1,7 @@
import DropdownMenu, { MenuItem } from './DropdownMenu';
import MoreIcon from './components/MoreIcon';
export default DropdownMenu;
export {
MenuItem,
MoreIcon,
};

View File

@@ -0,0 +1,34 @@
import React from 'react';
const Flex = (props) => {
const style = {
display: 'flex',
flex: props.flex ? '1' : null,
flexDirection: props.direction,
justifyContent: props.justify,
alignItems: props.align,
};
return (
<div style={ style } {...props}>
{ props.children }
</div>
);
};
Flex.defaultProps = {
direction: 'row',
justify: null,
align: null,
flex: null,
};
Flex.propTypes = {
children: React.PropTypes.node,
direction: React.PropTypes.string,
justify: React.PropTypes.string,
align: React.PropTypes.string,
flex: React.PropTypes.bool,
};
export default Flex;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import CenteredContent from 'components/CenteredContent';
import { Button } from 'rebass';
import styles from './FullscreenField.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
class FullscreenField extends React.Component {
render() {
return (
<div className={ styles.container }>
<CenteredContent>
<div className={ styles.content }>
<h2>Create a new atlas</h2>
<p>Atlases are collections where you, your teams or friends can share and collect information.</p>
<div className={ styles.field }>
<div className={ styles.label }>Atlas name</div>
<input type="text" placeholder="Meeting notes" />
</div>
<div className={ cx(styles.field, styles.description) }>
<div className={ styles.label }>Description</div>
<input type="text" placeholder="All your note are belong to us" />
</div>
<div className={ styles.field }>
<button className={ styles.button }>Create atlas</button>
</div>
</div>
</CenteredContent>
</div>
);
}
}
export default FullscreenField;

View File

@@ -0,0 +1,46 @@
.container {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
background-color: #fff;
}
.content {
padding: 100px 0;
}
.field {
padding: 20px 0;
.label {
font-size: 16px;
font-weight: bold;
color: #242425;
}
input {
font-size: 32px;
width: 100%;
padding: 5px 0;
border: 0;
border-bottom: 1px solid #242425;
border-radius: 0;
outline: none;
}
}
.button {
font-size: 20px;
background-color: #171B35;
color: #FAFAFA;
padding: 8px 20px 5px;
border: none;
}

View File

@@ -0,0 +1,2 @@
import FullscreenField from './FullscreenField';
export default FullscreenField;

View File

@@ -0,0 +1,31 @@
import React from 'react';
export default ({ style = {}, className }) => {
return (
<span className={className}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={ style.width || 208 }
height={ style.height || 128 }
viewBox="0 0 208 128"
color={ style.color }
>
<rect
width="198"
height="118"
x="5"
y="5"
ry="10"
stroke="currentColor"
strokeWidth="10"
fill="none"
fillOpacity="0"
/>
<path
d="M30 98v-68h20l20 25 20-25h20v68h-20v-39l-20 25-20-25v39zM155 98l-30-33h20v-35h20v35h20z"
fill="currentColor"
/>
</svg>
</span>
);
};

View File

@@ -0,0 +1,103 @@
import React from 'react';
import { browserHistory, Link } from 'react-router';
import Helmet from 'react-helmet';
import { observer } from 'mobx-react';
import keydown from 'react-keydown';
import _ from 'lodash';
import DropdownMenu, { MenuItem } from 'components/DropdownMenu';
import Flex from 'components/Flex';
import LoadingIndicator from 'components/LoadingIndicator';
import { Avatar } from 'rebass';
import styles from './Layout.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
@observer(['user'])
class Layout extends React.Component {
static propTypes = {
actions: React.PropTypes.node,
title: React.PropTypes.node,
titleText: React.PropTypes.node,
fixed: React.PropTypes.bool,
loading: React.PropTypes.bool,
user: React.PropTypes.object.isRequired,
search: React.PropTypes.bool,
}
static defaultProps = {
search: true,
}
@keydown(['/', 't'])
search() {
// if (!this.props.search) return;
_.defer(() => browserHistory.push('/search'));
}
@keydown(['d'])
dashboard() {
// if (!this.props.search) return;
_.defer(() => browserHistory.push('/'));
}
render() {
const user = this.props.user;
return (
<div className={ styles.container }>
<Helmet
title={
this.props.titleText
? `${this.props.titleText} - Beautiful Atlas`
: 'Beautiful Atlas'
}
/>
{ this.props.loading ? (
<LoadingIndicator />
) : null }
<div className={ cx(styles.header, { fixed: this.props.fixed }) }>
<div className={ styles.headerLeft }>
<Link to="/" className={ styles.team }>{ user.team.name }</Link>
<span className={ styles.title }>
{ this.props.title && (<span>&nbsp;/&nbsp;</span>) }{ this.props.title }
</span>
</div>
<Flex direction="row" className={ styles.headerRight }>
<Flex align="center" className={ styles.actions }>
{ this.props.actions }
</Flex>
{ this.props.search && (
<Flex>
<div
onClick={ this.search }
className={ styles.search }
title="Search (/)"
>
<img src={ require('assets/icons/search.svg') } />
</div>
</Flex>
) }
<DropdownMenu label={
<Avatar
circle
size={24}
src={ user.user.avatarUrl }
/>
}>
<MenuItem onClick={ user.logout }>Logout</MenuItem>
</DropdownMenu>
</Flex>
</div>
<div className={ cx(styles.content, { fixed: this.props.fixed }) }>
{ this.props.children }
</div>
</div>
);
}
}
export default Layout;

View File

@@ -0,0 +1,80 @@
@import '~styles/constants.scss';
.container {
display: flex;
flex: 1;
flex-direction: column;
width: 100%;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
height: $headerHeight;
border-bottom: 1px solid #eee;
font-size: 14px;
line-height: 1;
&.fixed {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 900;
background: #fff;
}
}
.headerLeft {
display: flex;
align-items: center;
.team {
font-family: 'Atlas Grotesk';
font-weight: bold;
color: $textColor;
text-decoration: none;
font-size: 16px;
}
.title {
color: #ccc;
a {
color: #ccc;
}
a:hover {
color: $textColor;
}
}
}
.headerRight {
}
.search {
margin: 0 5px;
padding: 15px 5px 0 5px;
cursor: pointer;
img {
height: 20px;
}
}
.content {
display: flex;
flex: 1;
justify-content: center;
&.fixed {
padding-top: $headerHeight;
}
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import styles from './HeaderAction.scss';
const HeaderAction = (props) => {
return (
<div
onClick={ props.onClick }
className={ styles.container }
>{ props.children }</div>
);
};
HeaderAction.propTypes = {
onClick: React.PropTypes.func,
};
export default HeaderAction;

View File

@@ -0,0 +1,9 @@
.container {
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
min-height: 43px;
padding: 0 0.5rem;
color: #0C77F8;
}

View File

@@ -0,0 +1,2 @@
import HeaderAction from './HeaderAction';
export default HeaderAction;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import _truncate from 'lodash/truncate';
import styles from './Title.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
const Title = (props) => {
let title;
if (props.truncate) {
title = _truncate(props.children, props.truncate);
} else {
title = props.children;
}
let usePlaceholder;
if (props.children === null && props.placeholder) {
title = props.placeholder;
usePlaceholder = true;
}
return(
<span
title={ props.children }
className={ cx(styles.title, { untitled: usePlaceholder })}
>
{ title }
</span>
);
};
Title.propTypes = {
children: React.PropTypes.string,
truncate: React.PropTypes.number,
placeholder: React.PropTypes.string,
}
export default Title;

View File

@@ -0,0 +1,7 @@
.title {
}
.untitled {
color: #ccc;
}

View File

@@ -0,0 +1,2 @@
import Title from './Title';
export default Title;

View File

@@ -0,0 +1,10 @@
import Layout from './Layout';
import Title from './components/Title';
import HeaderAction from './components/HeaderAction';
export default Layout;
export {
Title,
HeaderAction,
};

View File

@@ -0,0 +1,13 @@
import React from 'react';
import styles from './LoadingIndicator.scss';
const LoadingIndicator = (props) => {
return (
<div className={ styles.loading }>
<div className={ styles.loader }></div>
</div>
);
};
export default LoadingIndicator;

View File

@@ -0,0 +1,20 @@
.loader {
width: 100%;
height: 2px;
background-color: #03A9F4;
}
.loading {
position: fixed;
top: 0;
z-index: 9999;
background-color: #03A9F4;
width: 100%;
animation: loading 4s ease-in-out infinite;
}
@keyframes loading {
from {margin-left: -100%; z-index:100;}
to {margin-left: 100%; }
}

View File

@@ -0,0 +1,2 @@
import LoadingIndicator from './LoadingIndicator';
export default LoadingIndicator;

View File

@@ -0,0 +1,159 @@
import React from 'react';
import { observer } from 'mobx-react';
import Codemirror from 'react-codemirror';
import 'codemirror/mode/gfm/gfm';
import 'codemirror/mode/javascript/javascript';
import 'codemirror/addon/edit/continuelist';
import 'codemirror/addon/display/placeholder.js';
import Dropzone from 'react-dropzone';
import ClickablePadding from './components/ClickablePadding';
import styles from './MarkdownEditor.scss';
import './codemirror.scss';
import { client } from 'utils/ApiClient';
@observer
class MarkdownEditor extends React.Component {
static propTypes = {
text: React.PropTypes.string,
onChange: React.PropTypes.func.isRequired,
replaceText: React.PropTypes.func.isRequired,
onSave: React.PropTypes.func.isRequired,
// This is actually not used but it triggers
// re-render to help with CodeMirror focus issues
preview: React.PropTypes.bool,
}
getEditorInstance = () => {
return this.refs.editor.getCodeMirror();
}
onChange = (newText) => {
if (newText !== this.props.text) {
this.props.onChange(newText);
}
}
onDropAccepted = (files) => {
const file = files[0];
const editor = this.getEditorInstance();
const cursorPosition = editor.getCursor();
const insertOnNewLine = cursorPosition.ch !== 0;
let newCursorPositionLine;
// Lets set up the upload text
const pendingUploadTag = `![${file.name}](Uploading...)`;
if (insertOnNewLine) {
editor.replaceSelection('\n' + pendingUploadTag + '\n');
newCursorPositionLine = cursorPosition.line + 3;
} else {
editor.replaceSelection(pendingUploadTag + '\n');
newCursorPositionLine = cursorPosition.line + 2;
}
editor.setCursor(newCursorPositionLine, 0);
client.post('/user.s3Upload', {
kind: file.type,
size: file.size,
filename: file.name,
})
.then(response => {
const data = response.data;
// Upload using FormData API
let formData = new FormData();
for (let key in data.form) {
formData.append(key, data.form[key]);
}
if (file.blob) {
formData.append('file', file.file);
} else {
formData.append('file', file);
}
fetch(data.upload_url, {
method: 'post',
body: formData
})
.then(s3Response => {
this.props.replaceText({
original: pendingUploadTag,
new: `![${file.name}](${data.asset.url})`
});
editor.setCursor(newCursorPositionLine, 0);
})
.catch(err => {
this.props.replaceText({
original: pendingUploadTag,
new: '',
});
editor.setCursor(newCursorPositionLine, 0);
});
});
}
onPaddingTopClick = () => {
const cm = this.getEditorInstance();
cm.setCursor(0, 0);
cm.focus();
}
onPaddingBottomClick = () => {
const cm = this.getEditorInstance();
cm.setCursor(cm.lineCount(), 0);
cm.focus();
}
render = () => {
const options = {
readOnly: false,
lineNumbers: false,
mode: 'gfm',
matchBrackets: true,
lineWrapping: true,
viewportMargin: Infinity,
scrollbarStyle: 'null',
theme: 'atlas',
extraKeys: {
Enter: 'newlineAndIndentContinueMarkdownList',
"Ctrl-Enter": this.props.onSave,
"Cmd-Enter": this.props.onSave,
"Cmd-Esc": this.props.onCancel,
"Ctrl-Esc": this.props.onCancel,
// "Cmd-Shift-p": this.props.togglePreview,
// "Ctrl-Shift-p": this.props.togglePreview,
},
placeholder: "# Start with a title...",
};
return (
<Dropzone
onDropAccepted={this.onDropAccepted}
disableClick={true}
multiple={false}
accept={'image/*'}
className={styles.container}
>
<ClickablePadding onClick={ this.onPaddingTopClick } />
<Codemirror
value={this.props.text}
onChange={this.onChange}
options={options}
ref="editor"
className={styles.codeMirrorContainer}
/>
<ClickablePadding onClick={ this.onPaddingBottomClick } />
</Dropzone>
);
}
}
export default MarkdownEditor;

View File

@@ -0,0 +1,29 @@
.container {
display: flex;
flex: 1;
flex-direction: column;
font-weight: 400;
font-size: 1em;
line-height: 1.5em;
padding: 0 3em;
max-width: 50em;
}
.codeMirrorContainer {
width: 100%;
}
@media all and (max-width: 2000px) and (min-width: 960px) {
.container {
// margin-top: 48px;
font-size: 1.1em;
}
}
@media all and (max-width: 960px) {
.container {
font-size: 0.9em;
}
}

View File

@@ -0,0 +1,59 @@
@import '~styles/constants.scss';
:global {
/* Custom styling */
.cm-s-atlas.CodeMirror {
background: #fff;
color: #202020;
font-family: 'Atlas Typewriter', 'Menlo', 'Cousine', 'Monaco', monospace;
font-weight: 300;
height: auto; // This will break layout for some reason. TODO: investigate
width: 100%;
}
// Use Menlo for stronger weight
.cm-s-atlas .cm-header { font-family: 'Menlo', 'Cousine', 'Monaco', monospace; }
/* Disable ondrag cursor for file uploads */
.cm-s-atlas div.CodeMirror-dragcursors {
visibility: hidden;
}
.cm-s-atlas .CodeMirror-line::selection,
.cm-s-atlas .CodeMirror-line > span::selection,
.cm-s-atlas .CodeMirror-line > span > span::selection {
background: #90CAF9;
}
.cm-s-atlas .CodeMirror-line::-moz-selection, .cm-s-atlas .CodeMirror-line > span::-moz-selection, .cm-s-atlas .CodeMirror-line > span > span::-moz-selection { background: #e0e0e0; }
.cm-s-atlas .CodeMirror-gutters { background: #f5f5f5; border-right: 0px; }
.cm-s-atlas .CodeMirror-guttermarker { color: #ac4142; }
.cm-s-atlas .CodeMirror-guttermarker-subtle { color: #b0b0b0; }
.cm-s-atlas .CodeMirror-linenumber { color: #b0b0b0; }
.cm-s-atlas .CodeMirror-cursor {
border-left: 2px solid #2196F3;
}
.cm-s-atlas span.cm-quote {
font-style: italic;
}
.cm-s-atlas span.cm-comment { color: #969896; }
.cm-s-atlas span.cm-atom { color: #0086b3; }
.cm-s-atlas span.cm-number { color: $textColor; }
.cm-s-atlas span.cm-property, .cm-s-atlas span.cm-attribute { color: $textColor; }
.cm-s-atlas span.cm-keyword { color: #a71d5d; }
.cm-s-atlas span.cm-string { color: #df5000; }
.cm-s-atlas span.cm-variable { color: $textColor; }
.cm-s-atlas span.cm-variable-2 { color: $textColor; }
.cm-s-atlas span.cm-def { color: $textColor; }
.cm-s-atlas span.cm-bracket { color: #202020; }
.cm-s-atlas span.cm-tag { color: #ac4142; }
.cm-s-atlas span.cm-link { color: $actionColor; }
.cm-s-atlas span.cm-error { background: #ac4142; color: #505050; }
.cm-s-atlas .CodeMirror-activeline-background { background: #DDDCDC; }
.cm-s-atlas .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; }
.cm-s-atlas .CodeMirror-placeholder { color: rgba(0, 0, 0, 0.5); font-weight: bold; }
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import styles from './ClickablePadding.scss';
const ClickablePadding = (props) => {
return (
<div
className={ styles.container }
onClick={ props.onClick }
>&nbsp;</div>
)
};
ClickablePadding.propTypes = {
onClick: React.PropTypes.func,
};
export default ClickablePadding;

View File

@@ -0,0 +1,10 @@
.container {
padding-top: 50px;
cursor: text;
}
@media all and (max-width: 960px) {
.container {
padding-top: 50px;
}
}

View File

@@ -0,0 +1,2 @@
import ClickablePadding from './ClickablePadding';
export default ClickablePadding;

View File

@@ -0,0 +1,2 @@
import MarkdownEditor from './MarkdownEditor';
export default MarkdownEditor;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import moment from 'moment';
import { Avatar } from 'rebass';
import Flex from 'components/Flex';
import styles from './PublishingInfo.scss';
const PublishingInfo = (props) => {
return (
<Flex align="center" className={ styles.user }>
<Avatar src={ props.avatarUrl } size={ 24 } />
<span className={ styles.userName }>
{ props.name } published { moment(props.createdAt).fromNow() }
{ props.createdAt !== props.updatedAt ? (
<span>
&nbsp;and modified { moment(props.updatedAt).fromNow() }
</span>
) : null }
</span>
</Flex>
);
};
PublishingInfo.propTypes = {
avatarUrl: React.PropTypes.string.isRequired,
name: React.PropTypes.string.isRequired,
createdAt: React.PropTypes.string.isRequired,
updatedAt: React.PropTypes.string.isRequired,
};
export default PublishingInfo;

View File

@@ -0,0 +1,9 @@
.user {
margin-bottom: 30px;
color: #ccc;
font-size: 13px;
}
.userName {
margin: 0 0 0 10px;
}

View File

@@ -0,0 +1,2 @@
import PublishingInfo from './PublishingInfo';
export default PublishingInfo;

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { observer } from 'mobx-react';
import styles from './SlackAuthLink.scss';
@observer(['user'])
class SlackAuthLink extends React.Component {
static propTypes = {
scopes: React.PropTypes.arrayOf(React.PropTypes.string),
user: React.PropTypes.object.isRequired,
}
static defaultProps = {
scopes: [
'identity.email',
'identity.basic',
'identity.avatar',
'identity.team',
]
}
slackUrl = () => {
const baseUrl = 'https://slack.com/oauth/authorize';
const params = {
client_id: '30086650419.30130733398',
scope: this.props.scopes.join(" "),
redirect_uri: __DEV__ ?
'http://localhost:3000/auth/slack/' :
'https://www.beautifulatlas.com/auth/slack/',
state: this.props.user.getOauthState(),
};
const urlParams = Object.keys(params).map(function(key) {
return key + '=' + encodeURIComponent(params[key]);
}).join('&');
return `${baseUrl}?${urlParams}`;
}
render() {
return (
<a href={ this.slackUrl() } className={ styles.link }>Authorize /w Slack</a>
)
}
}
export default SlackAuthLink;

View File

@@ -0,0 +1,6 @@
.link {
text-decoration: none;
background: no-repeat left/10% url(./assets/slack_icon.svg);
padding: 5px 0 4px 36px;
font-size: 1.4em;
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="148px" height="147px" viewBox="0 0 148 147" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs></defs>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g>
<g>
<g>
<path d="M12.997,77.78 C7.503,77.822 2.849,74.548 1.133,69.438 C1.067,69.24 1.01,69.048 0.955,68.855 C-0.915,62.311 2.711,55.465 9.21,53.273 L113.45,18.35 C114.717,17.987 115.993,17.802 117.257,17.794 C122.897,17.75 127.679,21.096 129.437,26.314 L129.593,26.818 C131.543,33.634 126.698,39.718 120.893,41.668 C120.889,41.671 119.833,42.028 17.231,77.059 C15.844,77.53 14.421,77.768 12.997,77.78 L12.997,77.78 L12.997,77.78 Z" fill="#70CADB"></path>
<path d="M30.372,129.045 C24.835,129.085 20.165,125.857 18.469,120.82 C18.405,120.628 18.344,120.435 18.289,120.241 C16.393,113.619 20.015,106.701 26.536,104.506 L130.78,69.263 C132.127,68.813 133.518,68.583 134.917,68.57 C140.469,68.528 145.347,71.92 147.068,77.014 L147.228,77.544 C148.235,81.065 147.64,85.022 145.638,88.145 C144.146,90.467 139.44,92.511 139.44,92.511 L34.8,128.29 C33.342,128.777 31.855,129.034 30.372,129.047 L30.372,129.045 L30.372,129.045 Z" fill="#E01765"></path>
<path d="M117.148,129.268 C111.588,129.311 106.665,125.803 104.893,120.545 L70.103,17.205 L69.929,16.625 C68.044,10.035 71.669,3.161 78.166,0.971 C79.466,0.534 80.81,0.306 82.163,0.294 C84.173,0.279 86.118,0.732 87.95,1.637 C91.013,3.162 93.304,5.787 94.399,9.029 L129.186,112.36 L129.287,112.692 C131.241,119.534 127.624,126.412 121.127,128.602 C119.84,129.031 118.5,129.256 117.148,129.268 L117.148,129.268 L117.148,129.268 Z" fill="#E8A723"></path>
<path d="M65.435,146.674 C59.875,146.717 54.948,143.209 53.175,137.944 L18.394,34.608 C18.334,34.418 18.274,34.228 18.216,34.033 C16.336,27.445 19.95,20.57 26.445,18.378 C27.74,17.948 29.079,17.721 30.43,17.71 C35.991,17.666 40.915,21.173 42.687,26.433 L77.469,129.773 C77.534,129.953 77.593,130.152 77.646,130.342 C79.53,136.935 75.914,143.814 69.409,146.006 C68.117,146.437 66.78,146.662 65.431,146.673 L65.435,146.673 L65.435,146.674 Z" fill="#3EB890"></path>
<path d="M99.997,105.996 L124.255,97.702 L116.325,74.152 L92.039,82.359 L99.997,105.996 L99.997,105.996 Z" fill="#CC2027"></path>
<path d="M48.364,123.65 L72.62,115.357 L64.63,91.627 L40.35,99.837 L48.364,123.65 L48.364,123.65 Z" fill="#361238"></path>
<path d="M82.727,54.7 L106.987,46.417 L99.15,23.142 L74.845,31.285 L82.727,54.7 L82.727,54.7 Z" fill="#65863A"></path>
<path d="M31.088,72.33 L55.348,64.047 L47.415,40.475 L23.11,48.617 L31.088,72.33 L31.088,72.33 Z" fill="#1A937D"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,2 @@
import SlackAuthLink from './SlackAuthLink';
export default SlackAuthLink;

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { Base } from 'rebass';
import { observer } from 'mobx-react';
import { actionColor } from 'styles/constants.scss';
/**
* Binary toggle switch component
*/
const Switch = observer(({
checked,
...props
}) => {
const scale = '18';
const colors = {
success: actionColor,
white: '#fff',
};
const borderColor = actionColor;
const color = checked ? colors.success : borderColor
const transform = checked ? `translateX(${scale * 0.5}px)` : 'translateX(0)'
const sx = {
root: {
display: 'inline-flex',
width: scale * 1.5,
height: scale,
color,
backgroundColor: checked ? 'currentcolor' : null,
borderRadius: 99999,
boxShadow: 'inset 0 0 0 2px',
cursor: 'pointer'
},
dot: {
width: scale,
height: scale,
transitionProperty: 'transform, color',
transitionDuration: '.1s',
transitionTimingFunction: 'ease-out',
transform,
boxShadow: 'inset 0 0 0 2px',
borderRadius: 99999,
color,
backgroundColor: colors.white
}
}
return (
<Base
{...props}
className='Switch'
role='checkbox'
aria-checked={checked}
baseStyle={sx.root}>
<div style={sx.dot} />
</Base>
)
});
Switch.propTypes = {
/** Sets the Switch to an active style */
checked: React.PropTypes.bool
}
Switch.contextTypes = {
rebass: React.PropTypes.object
}
export default Switch;

View File

@@ -0,0 +1,113 @@
var React = require('react');
import history from 'utils/History';
import styles from './Tree.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
var Node = React.createClass({
displayName: 'UITreeNode',
renderCollapse() {
var index = this.props.index;
if(index.children && index.children.length) {
var collapsed = index.node.collapsed;
return (
<span
className={cx(styles.collapse, collapsed ? styles.caretRight : styles.caretDown)}
onMouseDown={function(e) {e.stopPropagation()}}
onClick={this.handleCollapse}
>
<img src={ require("./assets/chevron.svg") } />
</span>
);
}
return null;
},
renderChildren() {
var index = this.props.index;
var tree = this.props.tree;
var dragging = this.props.dragging;
if(index.children && index.children.length) {
var childrenStyles = {};
if (!this.props.rootNode) {
if(index.node.collapsed) childrenStyles.display = 'none';
childrenStyles['paddingLeft'] = this.props.paddingLeft + 'px';
}
return (
<div className={ styles.children } style={childrenStyles}>
{index.children.map((child) => {
var childIndex = tree.getIndex(child);
return (
<Node
tree={tree}
index={childIndex}
key={childIndex.id}
dragging={dragging}
paddingLeft={this.props.paddingLeft}
onCollapse={this.props.onCollapse}
onDragStart={this.props.onDragStart}
/>
);
})}
</div>
);
}
return null;
},
render() {
var tree = this.props.tree;
var index = this.props.index;
var dragging = this.props.dragging;
var node = index.node;
var style = {};
return (
<div className={cx(styles.node, {
placeholder: index.id === dragging,
rootNode: this.props.rootNode,
})} style={style}>
<div
className={ styles.inner }
ref="inner"
onMouseDown={this.props.rootNode ? (e) => e.stopPropagation() : this.handleMouseDown}
>
{!this.props.rootNode && this.renderCollapse()}
<span
className={ cx(styles.nodeLabel, { rootLabel: this.props.rootNode }) }
onClick={() => { history.push(node.url) }}
>
{ node.title }
</span>
</div>
{this.renderChildren()}
</div>
);
},
handleCollapse(e) {
e.stopPropagation();
var nodeId = this.props.index.id;
if(this.props.onCollapse) this.props.onCollapse(nodeId);
},
handleMouseDown(e) {
var nodeId = this.props.index.id;
var dom = this.refs.inner;
if(this.props.onDragStart) {
this.props.onDragStart(nodeId, dom, e);
}
}
});
module.exports = Node;

View File

@@ -0,0 +1,68 @@
var Tree = require('js-tree');
var proto = Tree.prototype;
proto.updateNodesPosition = function() {
var top = 1;
var left = 1;
var root = this.getIndex(1);
var self = this;
root.top = top++;
root.left = left++;
if(root.children && root.children.length) {
walk(root.children, root, left, root.node.collapsed);
}
function walk(children, parent, left, collapsed) {
var height = 1;
children.forEach(function(id) {
var node = self.getIndex(id);
if(collapsed) {
node.top = null;
node.left = null;
} else {
node.top = top++;
node.left = left;
}
if(node.children && node.children.length) {
height += walk(node.children, node, left+1, collapsed || node.node.collapsed);
} else {
node.height = 1;
height += 1;
}
});
if(parent.node.collapsed) parent.height = 1;
else parent.height = height;
return parent.height;
}
};
proto.move = function(fromId, toId, placement) {
if(fromId === toId || toId === 1) return;
var obj = this.remove(fromId);
var index = null;
if(placement === 'before') index = this.insertBefore(obj, toId);
else if(placement === 'after') index = this.insertAfter(obj, toId);
else if(placement === 'prepend') index = this.prepend(obj, toId);
else if(placement === 'append') index = this.append(obj, toId);
// todo: perf
this.updateNodesPosition();
return index;
};
proto.getNodeByTop = function(top) {
var indexes = this.indexes;
for(var id in indexes) {
if(indexes.hasOwnProperty(id)) {
if(indexes[id].top === top) return indexes[id];
}
}
};
module.exports = Tree;

View File

@@ -0,0 +1,79 @@
@mixin no-select {
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.tree {
position: relative;
overflow: hidden;
@include no-select;
}
.draggable {
position: absolute;
opacity: 0.8;
@include no-select;
}
.node {
&.placeholder > * {
visibility: hidden;
}
&.placeholder {
border: 1px dashed #ccc;
}
.inner {
position: relative;
cursor: pointer;
padding-left: 10px;
}
.collapse {
position: absolute;
left: 0;
cursor: pointer;
width: 20px;
height: 25px;
}
.caretRight {
margin-top: 3px;
margin-left: -3px;
}
.caretDown {
transform: rotate(90deg);
margin-left: -4px;
margin-top: 2px;
}
}
.node {
&.placeholder {
border: 1px dashed #1385e5;
}
.inner {
font-size: 14px;
}
.nodeLabel {
display: inline-block;
width: 100%;
padding: 4px 5px;
&.isActive {
background-color: #31363F;
}
}
.rootLabel {
color: #ccc;
}
}

View File

@@ -0,0 +1,267 @@
var React = require('react');
var Tree = require('./Tree');
var Node = require('./Node');
import styles from './Tree.scss';
module.exports = React.createClass({
displayName: 'UITree',
propTypes: {
tree: React.PropTypes.object.isRequired,
paddingLeft: React.PropTypes.number,
renderNode: React.PropTypes.func.isRequired,
onCollapse: React.PropTypes.func,
},
getDefaultProps() {
return {
paddingLeft: 20
};
},
getInitialState() {
return this.init(this.props);
},
componentWillReceiveProps(nextProps) {
if(!this._updated) this.setState(this.init(nextProps));
else this._updated = false;
},
init(props) {
var tree = new Tree(props.tree);
tree.isNodeCollapsed = props.isNodeCollapsed;
tree.renderNode = props.renderNode;
tree.changeNodeCollapsed = props.changeNodeCollapsed;
tree.updateNodesPosition();
return {
tree: tree,
dragging: {
id: null,
x: null,
y: null,
w: null,
h: null
}
};
},
getDraggingDom() {
var tree = this.state.tree;
var dragging = this.state.dragging;
if(dragging && dragging.id) {
var draggingIndex = tree.getIndex(dragging.id);
var draggingStyles = {
top: dragging.y,
left: dragging.x,
width: dragging.w
};
return (
<div className={ styles.draggable } style={draggingStyles}>
<Node
tree={tree}
index={draggingIndex}
paddingLeft={this.props.paddingLeft}
/>
</div>
);
}
return null;
},
render() {
var tree = this.state.tree;
var dragging = this.state.dragging;
var draggingDom = this.getDraggingDom();
return (
<div className={ styles.tree }>
{draggingDom}
<Node
rootNode={ true }
tree={tree}
index={tree.getIndex(1)}
key={1}
paddingLeft={this.props.paddingLeft}
onDragStart={this.dragStart}
onCollapse={this.toggleCollapse}
dragging={dragging && dragging.id}
/>
</div>
);
},
dragStart(id, dom, e) {
this.dragging = {
id: id,
w: dom.offsetWidth,
h: dom.offsetHeight,
x: dom.offsetLeft,
y: dom.offsetTop
};
this._startX = dom.offsetLeft;
this._startY = dom.offsetTop;
this._offsetX = e.clientX;
this._offsetY = e.clientY;
this._start = true;
window.addEventListener('mousemove', this.drag);
window.addEventListener('mouseup', this.dragEnd);
},
// oh
drag(e) {
if(this._start) {
this.setState({
dragging: this.dragging
});
this._start = false;
}
var tree = this.state.tree;
var dragging = this.state.dragging;
var paddingLeft = this.props.paddingLeft;
var newIndex = null;
var index = tree.getIndex(dragging.id);
var collapsed = index.node.collapsed;
var _startX = this._startX;
var _startY = this._startY;
var _offsetX = this._offsetX;
var _offsetY = this._offsetY;
var pos = {
x: _startX + e.clientX - _offsetX,
y: _startY + e.clientY - _offsetY
};
dragging.x = pos.x;
dragging.y = pos.y;
var diffX = dragging.x - paddingLeft/2 - (index.left-2) * paddingLeft;
var diffY = dragging.y - dragging.h/2 - (index.top-2) * dragging.h;
if(diffX < 0) { // left
if(index.parent && !index.next) {
newIndex = tree.move(index.id, index.parent, 'after');
}
} else if(diffX > paddingLeft) { // right
if(index.prev) {
var prevNode = tree.getIndex(index.prev).node;
if(!prevNode.collapsed && !prevNode.leaf) {
newIndex = tree.move(index.id, index.prev, 'append');
}
}
}
if(newIndex) {
index = newIndex;
newIndex.node.collapsed = collapsed;
dragging.id = newIndex.id;
}
if(diffY < 0) { // up
var above = tree.getNodeByTop(index.top-1);
newIndex = tree.move(index.id, above.id, 'before');
} else if(diffY > dragging.h) { // down
if(index.next) {
var below = tree.getIndex(index.next);
if(below.children && below.children.length && !below.node.collapsed) {
newIndex = tree.move(index.id, index.next, 'prepend');
} else {
newIndex = tree.move(index.id, index.next, 'after');
}
} else {
var below = tree.getNodeByTop(index.top+index.height);
if(below && below.parent !== index.id) {
if(below.children && below.children.length) {
newIndex = tree.move(index.id, below.id, 'prepend');
} else {
newIndex = tree.move(index.id, below.id, 'after');
}
}
}
}
if(newIndex) {
newIndex.node.collapsed = collapsed;
dragging.id = newIndex.id;
}
this.setState({
tree: tree,
dragging: dragging
});
},
dragEnd() {
this.setState({
dragging: {
id: null,
x: null,
y: null,
w: null,
h: null
}
});
this.change(this.state.tree);
window.removeEventListener('mousemove', this.drag);
window.removeEventListener('mouseup', this.dragEnd);
},
change(tree) {
this._updated = true;
if(this.props.onChange) this.props.onChange(tree.obj);
},
toggleCollapse(nodeId) {
var tree = this.state.tree;
var index = tree.getIndex(nodeId);
var node = index.node;
node.collapsed = !node.collapsed;
tree.updateNodesPosition();
this.setState({
tree: tree
});
if(this.props.onCollapse) this.props.onCollapse(node.id, node.collapsed);
},
// buildTreeNumbering(tree) {
// const numberBuilder = (index, node, parentNumbering) => {
// let numbering = parentNumbering ? `${parentNumbering}.${index}` : index;
// let children;
// if (node.children) {
// children = node.children.map((child, childIndex) => {
// return numberBuilder(childIndex+1, child, numbering);
// });
// }
// const data = {
// module: {
// ...node.module,
// index: numbering,
// }
// }
// if (children) {
// data.children = children;
// }
// return data;
// };
// const newTree = {...tree};
// newTree.children = [];
// tree.children.forEach((child, index) => {
// newTree.children.push(numberBuilder(index+1, child));
// })
// return newTree;
// }
});

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 30" x="0px" y="0px"><path d="M8.59,18.16L14.25,12.5L8.59,6.84L7.89,7.55L12.84,12.5L7.89,17.45L8.59,18.16Z"/></svg>

After

Width:  |  Height:  |  Size: 227 B

View File

@@ -0,0 +1,2 @@
import UiTree from './UiTree';
export default UiTree;