ff-stream-web

git clone git://archive.git.mtrnord.blog/MTRNord/ff-stream-web.git
Log | Files | Refs | README | LICENSE

commit 9bf87bfbac2f605f354d122ab9967ee2cfe28b9e
parent 5d12250ea15a100f2f744bb0354adc37e5f7f744
Author: Konstantin Tarkus <hello@tarkus.me>
Date:   Thu, 23 Jul 2015 20:19:23 +0300

Update react to v0.14.0-beta1; move routing logic into `/src/router.js`

Diffstat:
Mpackage.json | 4+++-
Asrc/actions/.gitignore | 0
Dsrc/actions/AppActions.js | 48------------------------------------------------
Asrc/api/content.js | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/api/query.js | 29-----------------------------
Msrc/app.js | 85+++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/components/App/App.js | 58+++++-----------------------------------------------------
Asrc/components/ErrorPage/ErrorPage.js | 28++++++++++++++++++++++++++++
Asrc/components/ErrorPage/ErrorPage.less | 42++++++++++++++++++++++++++++++++++++++++++
Asrc/components/ErrorPage/package.json | 6++++++
Msrc/constants/ActionTypes.js | 2--
Dsrc/core/Database.js | 53-----------------------------------------------------
Asrc/core/Location.js | 24++++++++++++++++++++++++
Asrc/core/http.js | 35+++++++++++++++++++++++++++++++++++
Asrc/router.js | 38++++++++++++++++++++++++++++++++++++++
Msrc/server.js | 51+++++++++++++++++++++------------------------------
Asrc/stores/.gitignore | 0
Dsrc/stores/AppStore.js | 80-------------------------------------------------------------------------------
Msrc/utils/Link.js | 4++--
Asrc/utils/fs.js | 19+++++++++++++++++++
Mwebpack.config.js | 18+++++++++---------
21 files changed, 323 insertions(+), 350 deletions(-)

diff --git a/package.json b/package.json @@ -15,7 +15,9 @@ "jade": "1.11.0", "lodash": "3.10.0", "normalize.css": "3.0.3", - "react": "0.13.3", + "react": "^0.14.0-beta1", + "react-dom": "^0.14.0-beta1", + "react-routing": "0.0.3", "source-map-support": "0.3.2", "superagent": "1.2.0" }, diff --git a/src/actions/.gitignore b/src/actions/.gitignore diff --git a/src/actions/AppActions.js b/src/actions/AppActions.js @@ -1,48 +0,0 @@ -/*! React Starter Kit | MIT License | http://www.reactstarterkit.com/ */ - -import http from 'superagent'; -import { canUseDOM } from 'react/lib/ExecutionEnvironment'; -import Dispatcher from '../core/Dispatcher'; -import ActionTypes from '../constants/ActionTypes'; - -export default { - - navigateTo(path, options) { - this.loadPage(path, () => { - if (canUseDOM) { - if (options && options.replace) { - window.history.replaceState({}, document.title, path); - } else { - window.history.pushState({}, document.title, path); - } - } - - Dispatcher.dispatch({ - type: ActionTypes.CHANGE_LOCATION, - path - }); - }); - }, - - loadPage(path, cb) { - Dispatcher.dispatch({ - type: ActionTypes.GET_PAGE, - path - }); - - http.get('/api/query?path=' + encodeURI(path)) - .accept('application/json') - .end((err, res) => { - Dispatcher.dispatch({ - type: ActionTypes.RECEIVE_PAGE, - path, - err, - page: res ? res.body : null - }); - if (cb) { - cb(); - } - }); - } - -}; diff --git a/src/api/content.js b/src/api/content.js @@ -0,0 +1,49 @@ +/*! React Starter Kit | MIT License | http://www.reactstarterkit.com/ */ + +import { join } from 'path'; +import { Router } from 'express'; +import jade from 'jade'; +import fm from 'front-matter'; +import fs from '../utils/fs'; + +// A folder with Jade/Markdown/HTML content pages +const CONTENT_DIR = join(__dirname, './content'); + +// Extract 'front matter' metadata and generate HTML +const parseJade = (path, jadeContent) => { + const content = fm(jadeContent); + const html = jade.render(content.body, null, ' '); + const page = Object.assign({ path, content: html }, content.attributes); + return page; +}; + +const router = new Router(); + +router.get('/', async (req, res, next) => { + try { + let path = req.query.path; + + if (!path || path === 'undefined') { + res.status(400).send({error: `The 'path' query parameter cannot be empty.`}); + return; + } + + let fileName = join(CONTENT_DIR, (path === '/' ? '/index' : path) + '.jade'); + if (!await fs.exists(fileName)) { + fileName = join(CONTENT_DIR, path + '/index.jade'); + } + + if (!await fs.exists(fileName)) { + res.status(404).send({error: `The page '${path}' is not found.`}); + } else { + const source = await fs.readFile(fileName, { encoding: 'utf8' }); + const content = parseJade(path, source); + res.status(200).send(content); + } + } catch (err) { + next(err); + } +}); + +export default router; + diff --git a/src/api/query.js b/src/api/query.js @@ -1,29 +0,0 @@ -/*! React Starter Kit | MIT License | http://www.reactstarterkit.com/ */ - -import { Router } from 'express'; -import db from '../core/Database'; - -const router = new Router(); - -router.get('/', async (req, res, next) => { - try { - let path = req.query.path; - - if (!path) { - res.status(400).send({error: `The 'path' query parameter cannot be empty.`}); - } - - let page = await db.getPage(path); - - if (page) { - res.status(200).send(page); - } else { - res.status(404).send({error: `The page '${path}' is not found.`}); - } - } catch (err) { - next(err); - } -}); - -export default router; - diff --git a/src/app.js b/src/app.js @@ -1,62 +1,61 @@ /*! React Starter Kit | MIT License | http://www.reactstarterkit.com/ */ import 'babel/polyfill'; -import React from 'react'; +import ReactDOM from 'react-dom'; import FastClick from 'fastclick'; -import App from './components/App'; +import router from './router'; import Dispatcher from './core/Dispatcher'; -import AppActions from './actions/AppActions'; +import Location from './core/Location'; import ActionTypes from './constants/ActionTypes'; -let path = decodeURI(window.location.pathname); -let onSetMeta = (name, content) => { - // Remove and create a new <meta /> tag in order to make it work - // with bookmarks in Safari - let elements = document.getElementsByTagName('meta'); - [].slice.call(elements).forEach((element) => { - if (element.getAttribute('name') === name) { - element.parentNode.removeChild(element); - } - }); - let meta = document.createElement('meta'); - meta.setAttribute('name', name); - meta.setAttribute('content', content); - document.getElementsByTagName('head')[0].appendChild(meta); +const container = document.getElementById('app'); +const context = { + onSetTitle: value => document.title = value, + onSetMeta: (name, content) => { + // Remove and create a new <meta /> tag in order to make it work + // with bookmarks in Safari + let elements = document.getElementsByTagName('meta'); + [].slice.call(elements).forEach((element) => { + if (element.getAttribute('name') === name) { + element.parentNode.removeChild(element); + } + }); + let meta = document.createElement('meta'); + meta.setAttribute('name', name); + meta.setAttribute('content', content); + document.getElementsByTagName('head')[0].appendChild(meta); + } }; function run() { - // Render the top-level React component - let props = { - path: path, - context: { - onSetTitle: value => document.title = value, - onSetMeta - } - }; - let element = React.createElement(App, props); - React.render(element, document.getElementById('app'), () => { - let css = document.getElementById('css'); - css.parentNode.removeChild(css); + router.dispatch({ path: window.location.pathname, context }, (state, component) => { + ReactDOM.render(component, container, () => { + let css = document.getElementById('css'); + css.parentNode.removeChild(css); + }); }); - // Update `Application.path` prop when `window.location` is changed - Dispatcher.register((action) => { + Dispatcher.register(action => { if (action.type === ActionTypes.CHANGE_LOCATION) { - element = React.cloneElement(element, {path: action.path}); - React.render(element, document.getElementById('app')); + router.dispatch({ path: action.path, context }, (state, component) => { + ReactDOM.render(component, container); + }); } }); } +function handlePopState(event) { + Location.navigateTo(window.location.pathname, { replace: !!event.state }); +} + // Run the application when both DOM is ready // and page content is loaded -Promise.all([ - new Promise((resolve) => { - if (window.addEventListener) { - window.addEventListener('DOMContentLoaded', resolve); - } else { - window.attachEvent('onload', resolve); - } - }).then(() => FastClick.attach(document.body)), - new Promise((resolve) => AppActions.loadPage(path, resolve)) -]).then(run); +new Promise(resolve => { + if (window.addEventListener) { + window.addEventListener('DOMContentLoaded', resolve); + window.addEventListener('popstate', handlePopState); + } else { + window.attachEvent('onload', resolve); + window.attachEvent('popstate', handlePopState); + } +}).then(() => FastClick.attach(document.body)).then(run); diff --git a/src/components/App/App.js b/src/components/App/App.js @@ -4,76 +4,28 @@ import React, { PropTypes } from 'react'; import styles from './App.less'; import withContext from '../../decorators/withContext'; import withStyles from '../../decorators/withStyles'; -import AppActions from '../../actions/AppActions'; -import AppStore from '../../stores/AppStore'; import Header from '../Header'; -import ContentPage from '../ContentPage'; -import ContactPage from '../ContactPage'; -import LoginPage from '../LoginPage'; -import RegisterPage from '../RegisterPage'; -import NotFoundPage from '../NotFoundPage'; import Feedback from '../Feedback'; import Footer from '../Footer'; -const pages = { ContentPage, ContactPage, LoginPage, RegisterPage, NotFoundPage }; - @withContext @withStyles(styles) class App { static propTypes = { - path: PropTypes.string.isRequired + children: PropTypes.element.isRequired, + error: PropTypes.object }; - componentDidMount() { - window.addEventListener('popstate', this.handlePopState); - } - - componentWillUnmount() { - window.removeEventListener('popstate', this.handlePopState); - } - - shouldComponentUpdate(nextProps) { - return this.props.path !== nextProps.path; - } - render() { - let component; - - switch (this.props.path) { - - case '/': - case '/about': - case '/privacy': - let page = AppStore.getPage(this.props.path); - component = React.createElement(pages[page.component], page); - break; - - case '/contact': - component = <ContactPage />; - break; - - case '/login': - component = <LoginPage />; - break; - - case '/register': - component = <RegisterPage />; - break; - } - - return component ? ( + return !this.props.error ? ( <div> <Header /> - {component} + {this.props.children} <Feedback /> <Footer /> </div> - ) : <NotFoundPage />; - } - - handlePopState(event) { - AppActions.navigateTo(window.location.pathname, {replace: !!event.state}); + ) : this.props.children; } } diff --git a/src/components/ErrorPage/ErrorPage.js b/src/components/ErrorPage/ErrorPage.js @@ -0,0 +1,28 @@ +/*! React Starter Kit | MIT License | http://www.reactstarterkit.com/ */ + +import React, { PropTypes } from 'react'; +import withStyles from '../../decorators/withStyles'; +import styles from './ErrorPage.less'; + +@withStyles(styles) +class ErrorPage { + + static contextTypes = { + onSetTitle: PropTypes.func.isRequired, + onPageNotFound: PropTypes.func.isRequired + }; + + render() { + let title = 'Error'; + this.context.onSetTitle(title); + return ( + <div> + <h1>{title}</h1> + <p>Sorry, an critical error occurred on this page.</p> + </div> + ); + } + +} + +export default ErrorPage; diff --git a/src/components/ErrorPage/ErrorPage.less b/src/components/ErrorPage/ErrorPage.less @@ -0,0 +1,42 @@ +/* React Starter Kit | MIT License | http://www.reactstarterkit.com/ */ + +* { + margin: 0; + line-height: 1.2; +} + +html { + display: table; + width: 100%; + height: 100%; + color: #888; + text-align: center; + font-family: sans-serif; +} + +body { + display: table-cell; + margin: 2em auto; + vertical-align: middle; +} + +h1 { + color: #555; + font-weight: 400; + font-size: 2em; +} + +p { + margin: 0 auto; + width: 280px; +} + +@media only screen and (max-width: 280px) { + body, p { + width: 95%; + } + h1 { + font-size: 1.5em; + margin: 0 0 0.3em; + } +} diff --git a/src/components/ErrorPage/package.json b/src/components/ErrorPage/package.json @@ -0,0 +1,6 @@ +{ + "name": "ErrorPage", + "version": "0.0.0", + "private": true, + "main": "./ErrorPage.js" +} diff --git a/src/constants/ActionTypes.js b/src/constants/ActionTypes.js @@ -3,7 +3,5 @@ import keyMirror from 'react/lib/keyMirror'; export default keyMirror({ - GET_PAGE: null, - RECEIVE_PAGE: null, CHANGE_LOCATION: null }); diff --git a/src/core/Database.js b/src/core/Database.js @@ -1,53 +0,0 @@ -/*! React Starter Kit | MIT License | http://www.reactstarterkit.com/ */ - -import fs from 'fs'; -import path from 'path'; -import jade from 'jade'; -import fm from 'front-matter'; -import Dispatcher from './Dispatcher'; -import ActionTypes from '../constants/ActionTypes'; - -// A folder with Jade/Markdown/HTML content pages -const CONTENT_DIR = path.join(__dirname, './content'); - -// Check if that directory exists, print an error message if not -fs.exists(CONTENT_DIR, (exists) => { - if (!exists) { - console.error(`Error: Directory '${CONTENT_DIR}' does not exist.`); - } -}); - -// Extract 'front matter' metadata and generate HTML -function parseJade(uri, jadeContent) { - let content = fm(jadeContent); - let html = jade.render(content.body, null, ' '); - let page = Object.assign({path: uri, content: html}, content.attributes); - return page; -} - -export default { - - getPage: (uri) => { - // Read page content from a Jade file - return new Promise((resolve) => { - let fileName = path.join(CONTENT_DIR, (uri === '/' ? '/index' : uri) + '.jade'); - fs.readFile(fileName, {encoding: 'utf8'}, (err, data) => { - if (err) { - fileName = path.join(CONTENT_DIR, uri + '/index.jade'); - fs.readFile(fileName, {encoding: 'utf8'}, (err2, data2) => { - resolve(err2 ? null : parseJade(uri, data2)); - }); - } else { - resolve(parseJade(uri, data)); - } - }); - }).then((page) => { - Dispatcher.dispatch({ - type: ActionTypes.RECEIVE_PAGE, - page: page}); - return Promise.resolve(page); - }); - } - -}; - diff --git a/src/core/Location.js b/src/core/Location.js @@ -0,0 +1,24 @@ +/*! React Starter Kit | MIT License | http://www.reactstarterkit.com/ */ + +import { canUseDOM } from 'react/lib/ExecutionEnvironment'; +import Dispatcher from '../core/Dispatcher'; +import ActionTypes from '../constants/ActionTypes'; + +export default { + + navigateTo(path, options) { + if (canUseDOM) { + if (options && options.replace) { + window.history.replaceState({}, document.title, path); + } else { + window.history.pushState({}, document.title, path); + } + } + + Dispatcher.dispatch({ + type: ActionTypes.CHANGE_LOCATION, + path + }); + } + +}; diff --git a/src/core/http.js b/src/core/http.js @@ -0,0 +1,35 @@ +/*! React Starter Kit | MIT License | http://www.reactstarterkit.com/ */ + +import request from 'superagent'; +import ExecutionEnvironment from 'react/lib/ExecutionEnvironment'; + +const getBaseUrl = (() => { + let baseUrl; + return () => baseUrl || (baseUrl = ExecutionEnvironment.canUseDOM ? '' : + process.env.WEBSITE_HOSTNAME ? + `http://${process.env.WEBSITE_HOSTNAME}` : + `http://127.0.0.1:${global.server.get('port')}`); +})(); + +const http = { + + get: path => new Promise((resolve, reject) => { + request + .get(getBaseUrl() + path) + .accept('application/json') + .end((err, res) => { + if (err) { + if (err.status === 404) { + resolve(null); + } else { + reject(err); + } + } else { + resolve(res.body); + } + }); + }) + +}; + +export default http; diff --git a/src/router.js b/src/router.js @@ -0,0 +1,38 @@ +/*! React Starter Kit | MIT License | http://www.reactstarterkit.com/ */ + +import React from 'react'; +import Router from 'react-routing/src/Router'; +import http from './core/http'; +import App from './components/App'; +import ContentPage from './components/ContentPage'; +import ContactPage from './components/ContactPage'; +import LoginPage from './components/LoginPage'; +import RegisterPage from './components/RegisterPage'; +import NotFoundPage from './components/NotFoundPage'; +import ErrorPage from './components/ErrorPage'; + +const router = new Router(on => { + + on('*', async (state, next) => { + const component = await next(); + return component && <App context={state.context}>{component}</App>; + }); + + on('/contact', async () => <ContactPage />); + + on('/login', async () => <LoginPage />); + + on('/register', async () => <RegisterPage />); + + on('*', async (state) => { + const content = await http.get(`/api/content?path=${state.path}`); + return content && <ContentPage {...content} />; + }); + + on('error', (state, error) => state.statusCode === 404 ? + <App context={state.context} error={error}><NotFoundPage /></App> : + <App context={state.context} error={error}><ErrorPage /></App>); + +}); + +export default router; diff --git a/src/server.js b/src/server.js @@ -5,13 +5,10 @@ import _ from 'lodash'; import fs from 'fs'; import path from 'path'; import express from 'express'; -import React from 'react'; -import './core/Dispatcher'; -import './stores/AppStore'; -import db from './core/Database'; -import App from './components/App'; +import ReactDOM from 'react-dom/server'; +import router from './router'; -const server = express(); +const server = global.server = express(); server.set('port', (process.env.PORT || 5000)); server.use(express.static(path.join(__dirname, 'public'))); @@ -19,7 +16,7 @@ server.use(express.static(path.join(__dirname, 'public'))); // // Register API middleware // ----------------------------------------------------------------------------- -server.use('/api/query', require('./api/query')); +server.use('/api/content', require('./api/content')); // // Register server-side rendering middleware @@ -31,29 +28,23 @@ const template = _.template(fs.readFileSync(templateFile, 'utf8')); server.get('*', async (req, res, next) => { try { - // TODO: Temporary fix #159 - if (['/', '/about', '/privacy'].indexOf(req.path) !== -1) { - await db.getPage(req.path); - } - let notFound = false; - let css = []; - let data = {description: ''}; - let app = (<App - path={req.path} - context={{ - onInsertCss: value => css.push(value), - onSetTitle: value => data.title = value, - onSetMeta: (key, value) => data[key] = value, - onPageNotFound: () => notFound = true - }} />); - - data.body = React.renderToString(app); - data.css = css.join(''); - let html = template(data); - if (notFound) { - res.status(404); - } - res.send(html); + let statusCode = 200; + const data = { title: '', description: '', css: '', body: '' }; + const css = []; + const context = { + onInsertCss: value => css.push(value), + onSetTitle: value => data.title = value, + onSetMeta: (key, value) => data[key] = value, + onPageNotFound: () => statusCode = 404 + }; + + await router.dispatch({ path: req.path, context }, (state, component) => { + data.body = ReactDOM.renderToString(component); + data.css = css.join(''); + }); + + const html = template(data); + res.status(statusCode).send(html); } catch (err) { next(err); } diff --git a/src/stores/.gitignore b/src/stores/.gitignore diff --git a/src/stores/AppStore.js b/src/stores/AppStore.js @@ -1,80 +0,0 @@ -/*! React Starter Kit | MIT License | http://www.reactstarterkit.com/ */ - -import EventEmitter from 'eventemitter3'; -import Dispatcher from '../core/Dispatcher'; -import ActionTypes from '../constants/ActionTypes'; - -const CHANGE_EVENT = 'change'; - -var pages = {}; -var loading = false; - -var AppStore = Object.assign({}, EventEmitter.prototype, { - - isLoading() { - return loading; - }, - - /** - * Gets page data by the given URL path. - * - * @param {String} path URL path. - * @returns {*} Page data. - */ - getPage(path) { - return path in pages ? pages[path] : null; - }, - - /** - * Emits change event to all registered event listeners. - * - * @returns {Boolean} Indication if we've emitted an event. - */ - emitChange() { - return this.emit(CHANGE_EVENT); - }, - - /** - * Register a new change event listener. - * - * @param {function} callback Callback function. - */ - onChange(callback) { - this.on(CHANGE_EVENT, callback); - }, - - /** - * Remove change event listener. - * - * @param {function} callback Callback function. - */ - off(callback) { - this.removeListener(CHANGE_EVENT, callback); - } - -}); - -AppStore.dispatchToken = Dispatcher.register((action) => { - - switch (action.type) { - - case ActionTypes.GET_PAGE: - loading = true; - AppStore.emitChange(); - break; - - case ActionTypes.RECEIVE_PAGE: - loading = false; - if (!action.err) { - pages[action.page.path] = action.page; - } - AppStore.emitChange(); - break; - - default: - // Do nothing - } - -}); - -export default AppStore; diff --git a/src/utils/Link.js b/src/utils/Link.js @@ -1,7 +1,7 @@ /*! React Starter Kit | MIT License | http://www.reactstarterkit.com/ */ import invariant from 'react/lib/invariant'; -import AppActions from '../actions/AppActions'; +import Location from '../core/Location'; function handleClick(event) { @@ -23,7 +23,7 @@ function handleClick(event) { var path = el.pathname + el.search + (el.hash || ''); event.preventDefault(); - AppActions.navigateTo(path); + Location.navigateTo(path); } export default { handleClick }; diff --git a/src/utils/fs.js b/src/utils/fs.js @@ -0,0 +1,19 @@ +/*! React Starter Kit | MIT License | http://www.reactstarterkit.com/ */ + +import fs from 'fs'; + +const exists = filename => new Promise(resolve => { + fs.exists(filename, resolve); +}); + +const readFile = filename => new Promise((resolve, reject) => { + fs.readFile(filename, 'utf8', (err, data) => { + if (err) { + reject(err); + } else { + resolve(data); + } + }); +}); + +export default { exists, readFile }; diff --git a/webpack.config.js b/webpack.config.js @@ -6,6 +6,7 @@ * LICENSE.txt file in the root directory of this source tree. */ +import path from 'path'; import webpack, { DefinePlugin, BannerPlugin } from 'webpack'; import merge from 'lodash/object/merge'; import autoprefixer from 'autoprefixer-core'; @@ -58,14 +59,6 @@ const config = { }, module: { - preLoaders: [ - { - test: /\.js$/, - exclude: /node_modules/, - loader: 'eslint-loader' - } - ], - loaders: [ { test: /\.css$/, @@ -93,12 +86,19 @@ const config = { }, { test: /\.jsx?$/, - exclude: /node_modules/, + include: [ + path.resolve(__dirname, 'node_modules/react-routing'), + path.resolve(__dirname, 'src') + ], loader: 'babel-loader' } ] }, + resolveLoader: { + root: path.join(__dirname, 'node_modules') + }, + postcss: [autoprefixer(AUTOPREFIXER_BROWSERS)] };