Initial commit

This commit is contained in:
Jori Lallo
2016-02-27 13:53:11 -08:00
commit af30485e9f
37 changed files with 1135 additions and 0 deletions

29
src/Actions/index.js Normal file
View File

@@ -0,0 +1,29 @@
import keyMirror from 'fbjs/lib/keyMirror';
/*
* Action types
*/
export const UPDATE_TEXT = 'UPDATE_TEXT';
export const TOGGLE_EDITORS = 'TOGGLE_EDITORS';
/*
* Other Constants
*/
export const ActiveEditors = keyMirror({
MARKDOWN: null,
TEXT: null,
});
/*
* Action creators
*/
export function updateText(text, editor) {
return { type: UPDATE_TEXT, text, editor };
}
export function toggleEditors(toggledEditor) {
return { type: TOGGLE_EDITORS, toggledEditor };
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import styles from './Header.scss';
const Header = ({ activeEditors, toggleEditors }) => {
return (
<div className={ styles.header }>
<div className={ styles.headerItem }><i>Beautiful</i> Atlas</div>
<div className={ `${styles.headerItem} ${styles.editorToggle}` }>
<span
onClick={toggleEditors.bind(this, 'MARKDOWN')}
className={ activeEditors.includes('MARKDOWN') ? styles.active : '' }
>Markdown</span>
<span
onClick={toggleEditors.bind(this, 'TEXT')}
className={ activeEditors.includes('TEXT') ? styles.active : '' }
>Text</span>
</div>
<div className={ styles.headerItem }>Versions</div>
</div>
);
};
export default Header;

View File

@@ -0,0 +1,47 @@
.header {
display: flex;
width: 100%;
height: 42px;
justify-content: space-between;
background-color: #111;
color: #fff;
i {
color: #fff;
font-family: serif;
}
.headerItem {
width: 150px;
padding: 12px 22px;
font-size: 13px;
font-weight: 300;
text-align: center;
&:first-child {
text-align: left;
}
&:last-child {
text-align: right;
}
}
.editorToggle {
span {
margin-right: 12px;
cursor: pointer;
&:last-child {
margin-right: 0;
}
}
}
.active {
text-decoration: underline;
text-decoration-color: #fff;
}
}

View File

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

View File

@@ -0,0 +1,52 @@
import React from 'react';
import Codemirror from 'react-codemirror';
import 'codemirror/mode/gfm/gfm';
import 'codemirror/addon/edit/continuelist';
import styles from './MarkdownEditor.scss';
import './codemirror.css';
class MarkdownAtlas extends React.Component {
static propTypes = {
text: React.PropTypes.string,
onChange: React.PropTypes.func,
}
onChange = (newText) => {
if (newText !== this.props.text) {
this.props.onChange(newText);
}
}
render = () => {
// https://github.com/jbt/markdown-editor/blob/master/index.html
const options = {
readOnly: false,
lineNumbers: false,
mode: 'gfm',
matchBrackets: true,
lineWrapping: true,
viewportMargin: Infinity,
theme: 'atlas',
extraKeys: {
Enter: 'newlineAndIndentContinueMarkdownList',
},
};
// http://codepen.io/lubelski/pen/fnGae
// TODO:
// - Emojify
// -
return (
<div className={ styles.container }>
<Codemirror
value={this.props.text}
onChange={this.onChange}
options={options}
/>
</div>
);
}
}
export default MarkdownAtlas;

View File

@@ -0,0 +1,4 @@
.container {
width: 70%;
margin: 48px auto;
}

View File

@@ -0,0 +1,58 @@
/*
Name: Base16 Default Light
Author: Chris Kempson (http://chriskempson.com)
CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror)
Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16)
*/
@import url(https://fonts.googleapis.com/css?family=Cousine:400,700,700italic,400italic);
.cm-s-atlas.CodeMirror {
background: #fcfcfc;
color: #202020;
font-family: 'Cousine', 'Monaco', monospace;
font-weight: 300;
}
.cm-s-atlas div.CodeMirror-selected {
background: #90CAF9;
}
.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: #8f5536; }
.cm-s-atlas span.cm-atom { color: #aa759f; }
.cm-s-atlas span.cm-number { color: #aa759f; }
.cm-s-atlas span.cm-property, .cm-s-atlas span.cm-attribute { color: #90a959; }
.cm-s-atlas span.cm-keyword { color: #ac4142; }
.cm-s-atlas span.cm-string { color: #f4bf75; }
.cm-s-atlas span.cm-variable { color: #90a959; }
.cm-s-atlas span.cm-variable-2 { color: #788696; }
.cm-s-atlas span.cm-def { color: #d28445; }
.cm-s-atlas span.cm-bracket { color: #202020; }
.cm-s-atlas span.cm-tag { color: #ac4142; }
.cm-s-atlas span.cm-link { color: #aa759f; }
.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; }

View File

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

View File

@@ -0,0 +1,52 @@
import React from 'react';
import Editor from 'react-medium-editor';
import marked from 'marked';
require('medium-editor/dist/css/medium-editor.css');
require('medium-editor/dist/css/themes/default.css');
import styles from './TextEditor.scss';
class TextEditor extends React.Component {
static propTypes = {
text: React.PropTypes.string,
onChange: React.PropTypes.func,
}
onChange = (newText) => {
if (newText !== this.props.text) {
this.props.onChange(newText);
}
}
render = () => {
return (
<div className={ styles.container }>
<div></div>
<Editor
options={{
toolbar: {
buttons: [
'bold',
'italic',
'underline',
'anchor',
'unorderedlist',
'orderedlist',
'h1',
'h2',
'h3',
'quote',
],
},
placeholder: false,
}}
text={marked(this.props.text)}
onChange={ this.onChange }
className={ styles.editor }
/>
</div>
);
}
}
export default TextEditor;

View File

@@ -0,0 +1,5 @@
.editor {
outline: none;
margin: 0 0 20px 0;
padding: 0 0 20px 0;
}

View File

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

19
src/Constants.js Normal file
View File

@@ -0,0 +1,19 @@
import keyMirror from 'fbjs/lib/keyMirror';
// Get application version from package.json 😅
import { name, version } from '../package.json';
// Constant KEYS 🔑
const keys = keyMirror({
JWT_STORE_KEY: null, // localStorage key for JWT
});
// Constant values
const constants = {
API_USER_AGENT: `${name}/${version}`,
API_BASE_URL: 'http://localhost:3000/api',
LOGIN_PATH: '/login',
LOGIN_SUCCESS_PATH: '/dashboard',
};
export default Object.assign(keys, constants);

39
src/Reducers/index.js Normal file
View File

@@ -0,0 +1,39 @@
import _ from 'lodash';
import { combineReducers } from 'redux';
import {
UPDATE_TEXT,
TOGGLE_EDITORS,
ActiveEditors,
} from '../Actions';
function activeEditors(state = [ActiveEditors.MARKDOWN, ActiveEditors.TEXT], action) {
switch (action.type) {
case TOGGLE_EDITORS: {
const newState = _.xor(state, [action.toggledEditor]);
if (newState.length > 0) {
return newState;
} else {
return [action.toggledEditor];
}
}
default:
return state;
}
}
function text(state = '', action) {
switch (action.type) {
case UPDATE_TEXT:
return action.text;
default:
return state;
}
}
const application = combineReducers({
activeEditors,
text,
});
export default application;

109
src/Utils/ApiClient.js Normal file
View File

@@ -0,0 +1,109 @@
import _ from 'lodash';
import Auth from './Auth';
import Constants from '../Constants';
class ApiClient {
constructor(options = {}) {
this.baseUrl = options.baseUrl || Constants.API_BASE_URL;
this.userAgent = options.userAgent || Constants.API_USER_AGENT;
}
fetch = (path, method, data) => {
let body;
let modifiedPath;
if (method === 'GET') {
modifiedPath = path + this.constructQueryString(data);
} else if (method === 'POST' || method === 'PUT') {
body = JSON.stringify(data);
}
// Construct headers
const headers = new Headers({
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': this.userAgent,
});
if (Auth.getToken()) {
headers.set('Authorization', `JWT ${Auth.getToken()}`);
}
// Construct request
const request = fetch(this.baseUrl + (modifiedPath || path), {
method,
body,
headers,
redirect: 'follow',
});
// Handle request promises and return a new promise
return new Promise((resolve, reject) => {
request
.then((response) => {
// Handle successful responses
if (response.status >= 200 && response.status < 300) {
return response;
}
// Handle 401, log out user
if (response.status === 401) {
Auth.logout();
}
// Handle failed responses
let error;
try {
// Expect API to return JSON
error = JSON.parse(response);
} catch (e) {
// Expect call to fail without JSON response
error = { error: response.statusText };
}
error.statusCode = response.status;
error.response = response;
reject(error);
})
.then((response) => {
return response.json();
})
.then((json) => {
resolve(json);
})
.catch(() => {
reject({ error: 'Unknown error' });
});
});
}
post = (path, data) => {
return this.fetch(path, 'POST', data);
}
put = (path, data) => {
return this.fetch(path, 'PUT', data);
}
get = (path, data) => {
return this.fetch(path, 'GET', data);
}
delete = (path, data) => {
return this.fetch(path, 'DELETE', data);
}
// Helpers
constructQueryString = (data) => {
return _.map(data, (v, k) => {
return `${encodeURIComponent(k)}=${encodeURIComponent(v)}`;
}).join('&');
};
}
export default ApiClient;
// In case you don't want to always initiate, just import with `import { client } ...`
const client = new ApiClient();
export { client };

42
src/Utils/Auth.js Normal file
View File

@@ -0,0 +1,42 @@
// Inspired by https://github.com/reactjs/react-router/blob/master/examples/auth-flow/auth.js
import Constants from '../Constants';
import History from './History';
import { client } from './ApiClient';
export default {
login(email, password) {
return new Promise((resolve, reject) => {
client.post('/authenticate', {
email,
password,
})
.then((data) => {
localStorage.setItem(Constants.JWT_STORE_KEY, data.jwt_token);
this.onChange(true);
resolve(data);
})
.catch((error) => {
reject(error);
});
});
},
getToken() {
return localStorage.getItem(Constants.JWT_STORE_KEY);
},
logout() {
localStorage.removeItem(Constants.JWT_STORE_KEY);
History.push(Constants.LOGIN_PATH);
this.onChange(false);
},
loggedIn() {
return !!localStorage.getItem(Constants.JWT_STORE_KEY);
},
onChange() {
// This is overriden with a callback function in `Views/App/App.js`
},
};

3
src/Utils/History.js Normal file
View File

@@ -0,0 +1,3 @@
// https://github.com/reactjs/react-router/blob/master/docs/guides/NavigatingOutsideOfComponents.md
import { browserHistory } from 'react-router';
export default browserHistory;

42
src/Utils/Markdown.js Normal file
View File

@@ -0,0 +1,42 @@
import toMd from 'to-markdown';
const liConverter = {
filter: 'li',
replacement: (content, node) => {
// Change `replace(/\n/gm, '\n ')` to work with our case here :/
content = content.replace(/^\s+/, '').replace(/\n/gm, '\n ');
var prefix = '- ';
var parent = node.parentNode;
var index = Array.prototype.indexOf.call(parent.children, node) + 1;
prefix = /ol/i.test(parent.nodeName) ? index + '. ' : '- ';
return prefix + content;
}
};
const ulConverter = {
filter: ['ul', 'ol'],
replacement: function (content, node) {
var strings = [];
for (var i = 0; i < node.childNodes.length; i++) {
strings.push(node.childNodes[i]._replacement);
}
if (/li/i.test(node.parentNode.nodeName)) {
return '\n' + strings.join('\n');
}
return '\n\n' + strings.join('\n') + '\n\n';
}
};
export function toMarkdown(html) {
console.log(html);
const markdown = toMd(
html, {
gfm: true,
converters: [ liConverter, ulConverter ],
},
);
console.log(markdown);
return markdown;
}

81
src/Views/App/App.js Normal file
View File

@@ -0,0 +1,81 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import 'normalize.css/normalize.css';
import styles from './App.scss';
import { toggleEditors } from '../../Actions';
import Header from '../../Components/Header';
import Auth from '../../Utils/Auth';
class App extends Component {
static propTypes = {
children: React.PropTypes.element,
activeEditors: React.PropTypes.isRequired,
toggleEditors: React.PropTypes.func.isRequired,
}
static defaultProps = {}
state = {
loggedIn: Auth.loggedIn(),
}
componentWillMount = () => {
Auth.onChange = this.updateAuth;
}
updateAuth = (loggedIn) => {
this.setState({
loggedIn,
});
}
logout = () => {
// TODO: Replace with Redux actions
Auth.logout();
}
render() {
return (
<div className={ styles.container }>
<Header
activeEditors={this.props.activeEditors}
toggleEditors={this.props.toggleEditors}
/>
<div className={ styles.content }>
{ this.props.children }
</div>
</div>
);
}
}
const mapStateToProps = (state) => {
return {
activeEditors: state.activeEditors,
};
};
const mapDispatchToProps = (dispatch) => {
return {
toggleEditors: (toggledEditor) => {
dispatch(toggleEditors(toggledEditor));
},
};
};
App = connect(
mapStateToProps,
mapDispatchToProps,
)(App);
export default App;
// {this.state.loggedIn ? (
// <a href="#" onClick={this.logout}>Logout</a>
// ) : (
// <Link to="/login">Login</Link>
// )}

13
src/Views/App/App.scss Normal file
View File

@@ -0,0 +1,13 @@
.container {
display: flex;
flex-flow: column;
width: 100%;
height: 100%;
background-color: #fff;
font-family: -apple-system, "Helvetica Neue", "Lucida Grande";
color: #222;
}
.content {
}

2
src/Views/App/index.js Normal file
View File

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

View File

@@ -0,0 +1,80 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import MarkdownEditor from '../../Components/MarkdownEditor';
import TextEditor from '../../Components/TextEditor';
import { toMarkdown } from '../../Utils/Markdown';
import { updateText } from '../../Actions';
import styles from './Dashboard.scss';
class Dashboard extends Component {
static propTypes = {
editMarkdown: React.PropTypes.func.isRequired,
editText: React.PropTypes.func.isRequired,
text: React.PropTypes.string,
activeEditors: React.PropTypes.array,
}
// componentDidMount = () => {
// client.get('/user')
// .then(data => {
// this.setState({ user: data });
// });
// }
render() {
const activeEditors = this.props.activeEditors;
return (
<div className={ styles.container }>
{
activeEditors.includes('MARKDOWN') ? (
<div className={ `${activeEditors.length > 1 ?
styles.panel : styles.fullscreen} ${styles.markdown}`}
>
<MarkdownEditor onChange={this.props.editMarkdown} text={this.props.text} />
</div>
) : null
}
{
activeEditors.includes('TEXT') ? (
<div className={ `${activeEditors.length > 1 ?
styles.panel : styles.fullscreen} ${styles.text}`}
>
<TextEditor onChange={this.props.editText} text={this.props.text} />
</div>
) : null
}
</div>
);
}
}
const mapStateToProps = (state) => {
return {
text: state.text,
editor: state.editor,
activeEditors: state.activeEditors,
};
};
const mapDispatchToProps = (dispatch) => {
return {
editMarkdown: (text) => {
dispatch(updateText(text, 'markdown'));
},
editText: (html) => {
const text = toMarkdown(html);
dispatch(updateText(text, 'text'));
},
};
};
Dashboard = connect(
mapStateToProps,
mapDispatchToProps,
)(Dashboard);
export default Dashboard;

View File

@@ -0,0 +1,15 @@
.container {
display: flex;
}
.panel {
width: 50%;
}
.fullscreen {
width: 100%;
}
.markdown {
background-color: #fbfbfb;
}

View File

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

75
src/Views/Login/Login.js Normal file
View File

@@ -0,0 +1,75 @@
import React, { Component } from 'react';
import Auth from '../../Utils/Auth';
export default class Login extends Component {
static propTypes = {
location: React.PropTypes.object,
}
static contextTypes = {
router: React.PropTypes.object.isRequired,
}
state = {
email: '',
password: '',
error: null,
}
handleEmailChange = (event) => {
this.setState({ email: event.target.value });
}
handlePasswordChange = (event) => {
this.setState({ password: event.target.value });
}
handleSubmit = (event) => {
event.preventDefault();
Auth.login(this.state.email, this.state.password)
.then(() => {
const { location } = this.props;
if (location.state && location.state.nextPathname) {
this.context.router.replace(location.state.nextPathname);
} else {
this.context.router.replace('/dashboard');
}
})
.catch((err) => {
this.setState({ error: err.error });
});
}
render() {
return (
<div>
<h2>Login</h2>
<form action="" onSubmit={ this.handleSubmit }>
{this.state.error && (
<p>{ this.state.error }</p>
)}
<div>
<input
placeholder={ 'Email' }
onChange={ this.handleEmailChange }
/>
</div>
<div>
<input
placeholder={ 'Password' }
type={ 'password' }
onChange={ this.handlePasswordChange }
/>
</div>
<div>
<input type={ 'submit' } />
</div>
</form>
</div>
);
}
}

View File

2
src/Views/Login/index.js Normal file
View File

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

41
src/index.js Normal file
View File

@@ -0,0 +1,41 @@
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { Router, Route } from 'react-router';
import { createStore, compose } from 'redux';
import History from './Utils/History';
import Auth from './Utils/Auth';
import reducers from './Reducers';
import App from './Views/App';
import Login from './Views/Login';
import Dashboard from './Views/Dashboard';
const store = createStore(
reducers,
compose(
window.devToolsExtension ? window.devToolsExtension() : f => f
)
);
function requireAuth(nextState, replace) {
if (!Auth.loggedIn()) {
replace({
pathname: '/login',
state: { nextPathname: nextState.location.pathname },
});
}
}
render((
<Provider store={store}>
<Router history={History}>
<Route path="/" component={App}>
<Route path="login" component={Login} />
<Route path="dashboard" component={Dashboard} onEnter={requireAuth} />
</Route>
</Router>
</Provider>
), document.getElementById('root'));