From af30485e9f19433e4cfc8d18ba60989f4283a822 Mon Sep 17 00:00:00 2001 From: Jori Lallo Date: Sat, 27 Feb 2016 13:53:11 -0800 Subject: [PATCH] Initial commit --- .babelrc | 8 ++ .eslintrc | 9 ++ .gitignore | 5 + README.md | 7 ++ index.html | 22 ++++ package.json | 67 +++++++++++ server.js | 83 +++++++++++++ src/Actions/index.js | 29 +++++ src/Components/Header/Header.js | 24 ++++ src/Components/Header/Header.scss | 47 ++++++++ src/Components/Header/index.js | 2 + .../MarkdownEditor/MarkdownEditor.js | 52 +++++++++ .../MarkdownEditor/MarkdownEditor.scss | 4 + src/Components/MarkdownEditor/codemirror.css | 58 ++++++++++ src/Components/MarkdownEditor/index.js | 2 + src/Components/TextEditor/TextEditor.js | 52 +++++++++ src/Components/TextEditor/TextEditor.scss | 5 + src/Components/TextEditor/index.js | 2 + src/Constants.js | 19 +++ src/Reducers/index.js | 39 +++++++ src/Utils/ApiClient.js | 109 ++++++++++++++++++ src/Utils/Auth.js | 42 +++++++ src/Utils/History.js | 3 + src/Utils/Markdown.js | 42 +++++++ src/Views/App/App.js | 81 +++++++++++++ src/Views/App/App.scss | 13 +++ src/Views/App/index.js | 2 + src/Views/Dashboard/Dashboard.js | 80 +++++++++++++ src/Views/Dashboard/Dashboard.scss | 15 +++ src/Views/Dashboard/index.js | 2 + src/Views/Login/Login.js | 75 ++++++++++++ src/Views/Login/Login.scss | 0 src/Views/Login/index.js | 2 + src/index.js | 41 +++++++ webpack.config.dev.js | 18 +++ webpack.config.js | 41 +++++++ webpack.config.prod.js | 33 ++++++ 37 files changed, 1135 insertions(+) create mode 100644 .babelrc create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 README.md create mode 100644 index.html create mode 100644 package.json create mode 100644 server.js create mode 100644 src/Actions/index.js create mode 100644 src/Components/Header/Header.js create mode 100644 src/Components/Header/Header.scss create mode 100644 src/Components/Header/index.js create mode 100644 src/Components/MarkdownEditor/MarkdownEditor.js create mode 100644 src/Components/MarkdownEditor/MarkdownEditor.scss create mode 100644 src/Components/MarkdownEditor/codemirror.css create mode 100644 src/Components/MarkdownEditor/index.js create mode 100644 src/Components/TextEditor/TextEditor.js create mode 100644 src/Components/TextEditor/TextEditor.scss create mode 100644 src/Components/TextEditor/index.js create mode 100644 src/Constants.js create mode 100644 src/Reducers/index.js create mode 100644 src/Utils/ApiClient.js create mode 100644 src/Utils/Auth.js create mode 100644 src/Utils/History.js create mode 100644 src/Utils/Markdown.js create mode 100644 src/Views/App/App.js create mode 100644 src/Views/App/App.scss create mode 100644 src/Views/App/index.js create mode 100644 src/Views/Dashboard/Dashboard.js create mode 100644 src/Views/Dashboard/Dashboard.scss create mode 100644 src/Views/Dashboard/index.js create mode 100644 src/Views/Login/Login.js create mode 100644 src/Views/Login/Login.scss create mode 100644 src/Views/Login/index.js create mode 100644 src/index.js create mode 100644 webpack.config.dev.js create mode 100644 webpack.config.js create mode 100644 webpack.config.prod.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 000000000..15aa9d00e --- /dev/null +++ b/.babelrc @@ -0,0 +1,8 @@ +{ + "presets": ["react", "es2015", "stage-0"], + "env": { + "development": { + "presets": ["react-hmre"] + } + } +} \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 000000000..2c1688432 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,9 @@ +{ + "extends": "airbnb", + "parser": "babel-eslint", + "rules": { + "arrow-body-style":[0, "as-needed"], // fix `this` shortcut on ES6 classes + "react/jsx-no-bind": 0, // Makes difficult to pass args to prop functions + "no-else-return": 0, + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..5f28d58b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/* +dist/* +.env +npm-debug.log +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..329b62cb5 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Beautiful Atlas + +## Ideas + +- Create sharable private URLs for notes +- Settings + - Enable :emoji: autoconvert \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 000000000..28ed6cbeb --- /dev/null +++ b/index.html @@ -0,0 +1,22 @@ + + + + Beautiful Atlas + + + + +
+ + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 000000000..cd65a38e8 --- /dev/null +++ b/package.json @@ -0,0 +1,67 @@ +{ + "name": "BeautifulAtlas", + "version": "0.0.1", + "description": "For writing", + "main": "index.js", + "scripts": { + "clean": "rimraf dist", + "build:webpack": "cross-env NODE_ENV=production webpack --config webpack.config.prod.js --progress --colors", + "build": "npm run clean && npm run build:webpack", + "start": "node server.js", + "lint": "eslint src" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/jorilallo/atlas.git" + }, + "author": "Jori Lallo", + "license": "ISC", + "bugs": { + "url": "https://github.com/jorilallo/atlas/issues" + }, + "homepage": "https://github.com/jorilallo/atlas#readme", + "dependencies": { + "express": "^4.13.4", + "react": "^0.14.7", + "react-dom": "^0.14.7" + }, + "devDependencies": { + "babel-core": "^6.4.5", + "babel-eslint": "^4.1.8", + "babel-loader": "^6.2.1", + "babel-preset-es2015": "^6.3.13", + "babel-preset-react": "^6.3.13", + "babel-preset-react-hmre": "^1.0.1", + "babel-preset-stage-0": "^6.5.0", + "body-parser": "^1.15.0", + "codemirror": "^5.11.0", + "cross-env": "^1.0.7", + "css-loader": "^0.23.1", + "dotenv": "^2.0.0", + "eslint": "^1.10.3", + "eslint-config-airbnb": "^5.0.0", + "eslint-plugin-react": "^3.16.1", + "exports-loader": "^0.6.3", + "fetch": "^1.0.1", + "history": "^1.17.0", + "imports-loader": "^0.6.5", + "json-loader": "^0.5.4", + "lodash": "^4.3.0", + "marked": "^0.3.5", + "node-sass": "^3.4.2", + "normalize.css": "^3.0.3", + "react": "^0.14.7", + "react-codemirror": "^0.2.5", + "react-medium-editor": "^1.6.2", + "react-redux": "^4.4.0", + "react-router": "^2.0.0", + "redux": "^3.3.1", + "sass-loader": "^3.1.2", + "style-loader": "^0.13.0", + "to-markdown": "^2.0.1", + "webpack": "^1.12.12", + "webpack-dev-middleware": "^1.5.1", + "webpack-hot-middleware": "^2.6.4", + "whatwg-fetch": "^0.11.0" + } +} diff --git a/server.js b/server.js new file mode 100644 index 000000000..dd1e72859 --- /dev/null +++ b/server.js @@ -0,0 +1,83 @@ +var path = require('path'); +var express = require('express'); + +var app = express(); +var port = process.env.PORT || 3000; + +if (process.env.NODE_ENV !== 'production') { + var webpack = require('webpack'); + var config = require('./webpack.config.dev'); + var compiler = webpack(config); + + app.use(require('webpack-dev-middleware')(compiler, { + noInfo: true, + publicPath: config.output.publicPath + })); + app.use(require('webpack-hot-middleware')(compiler)); +} else { + app.use('/static', express.static('dist')); +} + +// API stubs - Feel free to tear these down in favor of rolling out proper APIs +// Also `body-parser` module is included only for this +var router = express.Router(); + +var validJwtToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'; +function isAuthenticated(req, res, next) { + // Authenticate with JWT + if (req.headers.authorization) { + var tokenParts = req.headers.authorization.split(" "); + if (tokenParts.length === 2 && + tokenParts[0].trim().toUpperCase() === "JWT" && + tokenParts[1].trim() === validJwtToken + ) { + return next(); + } + } + + // Return 401 with invalid credentials + res.status(401).json({ + 'error': 'Invalid JWT token' + }); +} + +router.post('/authenticate', function(req, res) { + if (req.body.email === 'user1@example.com' && + req.body.password === 'test123!') { + res.json({ + 'jwt_token': validJwtToken, + }); + } else { + res.status(400).json({ + 'error': 'Invalid credentials' + }); + } +}); + +router.get('/user', isAuthenticated, function(req, res) { + res.json({ + id: '93c3a6d6-3958-44c9-a668-59711befb25c', + email: 'user1@example.com', + name: 'Test User' + }); +}); + + +// Register API +var bodyParser = require('body-parser'); +app.use(bodyParser.json()); +app.use('/api', router); + +// Frontend +app.get('*', function(req, res) { + res.sendFile(path.join(__dirname, 'index.html')); +}); + +app.listen(port, function(err) { + if (err) { + console.log(err); + return; + } + + console.log('Listening at ' + port); +}); diff --git a/src/Actions/index.js b/src/Actions/index.js new file mode 100644 index 000000000..8b0634779 --- /dev/null +++ b/src/Actions/index.js @@ -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 }; +} diff --git a/src/Components/Header/Header.js b/src/Components/Header/Header.js new file mode 100644 index 000000000..040aba127 --- /dev/null +++ b/src/Components/Header/Header.js @@ -0,0 +1,24 @@ +import React from 'react'; + +import styles from './Header.scss'; + +const Header = ({ activeEditors, toggleEditors }) => { + return ( +
+
Beautiful Atlas
+
+ Markdown + Text +
+
Versions
+
+ ); +}; + +export default Header; diff --git a/src/Components/Header/Header.scss b/src/Components/Header/Header.scss new file mode 100644 index 000000000..157d54a3f --- /dev/null +++ b/src/Components/Header/Header.scss @@ -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; + } +} \ No newline at end of file diff --git a/src/Components/Header/index.js b/src/Components/Header/index.js new file mode 100644 index 000000000..24421ce68 --- /dev/null +++ b/src/Components/Header/index.js @@ -0,0 +1,2 @@ +import Header from './Header'; +export default Header; diff --git a/src/Components/MarkdownEditor/MarkdownEditor.js b/src/Components/MarkdownEditor/MarkdownEditor.js new file mode 100644 index 000000000..2e69a74ad --- /dev/null +++ b/src/Components/MarkdownEditor/MarkdownEditor.js @@ -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 ( +
+ +
+ ); + } +} + +export default MarkdownAtlas; diff --git a/src/Components/MarkdownEditor/MarkdownEditor.scss b/src/Components/MarkdownEditor/MarkdownEditor.scss new file mode 100644 index 000000000..ded1dd455 --- /dev/null +++ b/src/Components/MarkdownEditor/MarkdownEditor.scss @@ -0,0 +1,4 @@ +.container { + width: 70%; + margin: 48px auto; +} \ No newline at end of file diff --git a/src/Components/MarkdownEditor/codemirror.css b/src/Components/MarkdownEditor/codemirror.css new file mode 100644 index 000000000..339ff1ffb --- /dev/null +++ b/src/Components/MarkdownEditor/codemirror.css @@ -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; } \ No newline at end of file diff --git a/src/Components/MarkdownEditor/index.js b/src/Components/MarkdownEditor/index.js new file mode 100644 index 000000000..b4ab100ed --- /dev/null +++ b/src/Components/MarkdownEditor/index.js @@ -0,0 +1,2 @@ +import MarkdownEditor from './MarkdownEditor'; +export default MarkdownEditor; diff --git a/src/Components/TextEditor/TextEditor.js b/src/Components/TextEditor/TextEditor.js new file mode 100644 index 000000000..96b80d44b --- /dev/null +++ b/src/Components/TextEditor/TextEditor.js @@ -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 ( +
+
+ +
+ ); + } +} + +export default TextEditor; diff --git a/src/Components/TextEditor/TextEditor.scss b/src/Components/TextEditor/TextEditor.scss new file mode 100644 index 000000000..845d035e6 --- /dev/null +++ b/src/Components/TextEditor/TextEditor.scss @@ -0,0 +1,5 @@ +.editor { + outline: none; + margin: 0 0 20px 0; + padding: 0 0 20px 0; +} \ No newline at end of file diff --git a/src/Components/TextEditor/index.js b/src/Components/TextEditor/index.js new file mode 100644 index 000000000..0a79673b9 --- /dev/null +++ b/src/Components/TextEditor/index.js @@ -0,0 +1,2 @@ +import TextEditor from './TextEditor'; +export default TextEditor; diff --git a/src/Constants.js b/src/Constants.js new file mode 100644 index 000000000..4b9e2dbbc --- /dev/null +++ b/src/Constants.js @@ -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); diff --git a/src/Reducers/index.js b/src/Reducers/index.js new file mode 100644 index 000000000..77be62db2 --- /dev/null +++ b/src/Reducers/index.js @@ -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; diff --git a/src/Utils/ApiClient.js b/src/Utils/ApiClient.js new file mode 100644 index 000000000..c32c79f93 --- /dev/null +++ b/src/Utils/ApiClient.js @@ -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 }; diff --git a/src/Utils/Auth.js b/src/Utils/Auth.js new file mode 100644 index 000000000..728535462 --- /dev/null +++ b/src/Utils/Auth.js @@ -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` + }, +}; diff --git a/src/Utils/History.js b/src/Utils/History.js new file mode 100644 index 000000000..76445fa09 --- /dev/null +++ b/src/Utils/History.js @@ -0,0 +1,3 @@ +// https://github.com/reactjs/react-router/blob/master/docs/guides/NavigatingOutsideOfComponents.md +import { browserHistory } from 'react-router'; +export default browserHistory; diff --git a/src/Utils/Markdown.js b/src/Utils/Markdown.js new file mode 100644 index 000000000..c0490d79b --- /dev/null +++ b/src/Utils/Markdown.js @@ -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; +} diff --git a/src/Views/App/App.js b/src/Views/App/App.js new file mode 100644 index 000000000..76a676306 --- /dev/null +++ b/src/Views/App/App.js @@ -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 ( +
+
+
+ { this.props.children } +
+
+ ); + } +} + +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 ? ( +// Logout +// ) : ( +// Login +// )} diff --git a/src/Views/App/App.scss b/src/Views/App/App.scss new file mode 100644 index 000000000..ba81774a1 --- /dev/null +++ b/src/Views/App/App.scss @@ -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 { +} \ No newline at end of file diff --git a/src/Views/App/index.js b/src/Views/App/index.js new file mode 100644 index 000000000..54374b9b6 --- /dev/null +++ b/src/Views/App/index.js @@ -0,0 +1,2 @@ +import App from './App'; +export default App; diff --git a/src/Views/Dashboard/Dashboard.js b/src/Views/Dashboard/Dashboard.js new file mode 100644 index 000000000..0f92efc88 --- /dev/null +++ b/src/Views/Dashboard/Dashboard.js @@ -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 ( +
+ { + activeEditors.includes('MARKDOWN') ? ( +
1 ? + styles.panel : styles.fullscreen} ${styles.markdown}`} + > + +
+ ) : null + } + { + activeEditors.includes('TEXT') ? ( +
1 ? + styles.panel : styles.fullscreen} ${styles.text}`} + > + +
+ ) : null + } +
+ ); + } +} + +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; diff --git a/src/Views/Dashboard/Dashboard.scss b/src/Views/Dashboard/Dashboard.scss new file mode 100644 index 000000000..80d56502a --- /dev/null +++ b/src/Views/Dashboard/Dashboard.scss @@ -0,0 +1,15 @@ +.container { + display: flex; +} + +.panel { + width: 50%; +} + +.fullscreen { + width: 100%; +} + +.markdown { + background-color: #fbfbfb; +} \ No newline at end of file diff --git a/src/Views/Dashboard/index.js b/src/Views/Dashboard/index.js new file mode 100644 index 000000000..e5cb98c3e --- /dev/null +++ b/src/Views/Dashboard/index.js @@ -0,0 +1,2 @@ +import Dashboard from './Dashboard'; +export default Dashboard; diff --git a/src/Views/Login/Login.js b/src/Views/Login/Login.js new file mode 100644 index 000000000..00191dfa9 --- /dev/null +++ b/src/Views/Login/Login.js @@ -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 ( +
+

Login

+
+ {this.state.error && ( +

{ this.state.error }

+ )} + +
+ +
+
+ +
+
+ +
+
+
+ ); + } +} diff --git a/src/Views/Login/Login.scss b/src/Views/Login/Login.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/Views/Login/index.js b/src/Views/Login/index.js new file mode 100644 index 000000000..189be5f01 --- /dev/null +++ b/src/Views/Login/index.js @@ -0,0 +1,2 @@ +import Login from './Login'; +export default Login; diff --git a/src/index.js b/src/index.js new file mode 100644 index 000000000..1d248aadc --- /dev/null +++ b/src/index.js @@ -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(( + + + + + + + + +), document.getElementById('root')); diff --git a/webpack.config.dev.js b/webpack.config.dev.js new file mode 100644 index 000000000..d2656ab7c --- /dev/null +++ b/webpack.config.dev.js @@ -0,0 +1,18 @@ +var path = require('path'); +var webpack = require('webpack'); + +commonWebpackConfig = require('./webpack.config'); + +developmentWebpackConfig = Object.assign(commonWebpackConfig, { + cache: true, + devtool: 'eval', + entry: [ + 'webpack-hot-middleware/client', + './src/index', + ], +}); + +developmentWebpackConfig.plugins.push(new webpack.HotModuleReplacementPlugin()); +developmentWebpackConfig.plugins.push(new webpack.NoErrorsPlugin()); + +module.exports = developmentWebpackConfig; diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 000000000..b132bcdaa --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,41 @@ +var path = require('path'); +var webpack = require('webpack'); + +// Load .env +require('dotenv').config(); + +var definePlugin = new webpack.DefinePlugin({ + __DEV__: JSON.stringify(JSON.parse(process.env.BUILD_DEV || 'true')), + __PRERELEASE__: JSON.stringify(JSON.parse(process.env.BUILD_PRERELEASE || 'false')) +}); + +module.exports = { + output: { + path: path.join(__dirname, 'dist'), + filename: 'bundle.js', + publicPath: '/static/' + }, + module: { + loaders: [ + { + test: /\.js$/, + loader: 'babel', + include: path.join(__dirname, 'src') + }, + { test: /\.json$/, loader: 'json-loader' }, + { test: /\.scss$/, loader: 'style!css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!sass?sourceMap' }, + { test: /\.css$/, loader: 'style!css-loader' }, + { test: /\.(png|jpg)$/, loader: 'url-loader?limit=8192' } // inline base64 URLs for <=8k images, direct URLs for the rest + ] + }, + resolve: { + // you can now require('file') instead of require('file.json') + extensions: ['', '.js', '.json'] + }, + plugins: [ + definePlugin, + new webpack.ProvidePlugin({ + 'fetch': 'imports?this=>global!exports?global.fetch!whatwg-fetch' + }), + ] +}; \ No newline at end of file diff --git a/webpack.config.prod.js b/webpack.config.prod.js new file mode 100644 index 000000000..16d9efb4a --- /dev/null +++ b/webpack.config.prod.js @@ -0,0 +1,33 @@ +var path = require('path'); +var webpack = require('webpack'); + +commonWebpackConfig = require('./webpack.config'); + +productionWebpackConfig = Object.assign(commonWebpackConfig, { + cache: true, + devtool: 'cheap-module-source-map', + entry: './src/index', + output: { + path: path.join(__dirname, 'dist'), + filename: 'bundle.js', + publicPath: '/static/' + }, +}); + +productionWebpackConfig.plugins.push(new webpack.optimize.OccurenceOrderPlugin()); +productionWebpackConfig.plugins.push( + new webpack.optimize.UglifyJsPlugin({ + compress: { + warnings: false + } + }) +); +productionWebpackConfig.plugins.push( + new webpack.DefinePlugin({ + 'process.env': { + 'NODE_ENV': JSON.stringify('production') + } + }) +); + +module.exports = productionWebpackConfig;