diff --git a/.babelrc b/.babelrc new file mode 100644 index 000000000..ac314eabb --- /dev/null +++ b/.babelrc @@ -0,0 +1,17 @@ +{ + "presets": ["es2015", "stage-0", "react"], + "plugins": [ + "transform-runtime", + "add-module-exports", + "transform-decorators-legacy", + "transform-react-display-name", + "typecheck" + ], + "env": { + "test": { + "plugins": [ + "rewire" + ] + } + } +} diff --git a/.bootstraprc b/.bootstraprc new file mode 100644 index 000000000..6d660e0ee --- /dev/null +++ b/.bootstraprc @@ -0,0 +1,62 @@ +{ + bootstrapVersion: 3, + styleLoaders: [ 'style', 'css', 'sass' ], + preBootstrapCustomizations: './src/theme/variables.scss', + appStyles: './src/theme/bootstrap.overrides.scss', + verbose: false, + debug: false, + scripts: { + transition: false, + alert: false, + button: false, + carousel: false, + collapse: false, + dropdown: false, + modal: false, + tooltip: true, + popover: false, + scrollspy: false, + tab: false, + affix: false + }, + styles: { + mixins: true, + normalize: true, + print: false, + glyphicons: false, + scaffolding: true, + type: true, + code: false, + grid: true, + tables: false, + forms: true, + buttons: true, + 'component-animations': true, + dropdowns: true, + 'button-groups': false, + 'input-groups': false, + navs: true, + navbar: true, + breadcrumbs: false, + pagination: true, + pager: true, + labels: true, + badges: false, + jumbotron: false, + thumbnails: false, + alerts: false, + 'progress-bars': false, + media: false, + 'list-group': false, + panels: false, + wells: false, + 'responsive-embed': false, + close: false, + modals: false, + tooltip: true, + popovers: true, + carousel: false, + utilities: true, + 'responsive-utilities': true + } +} diff --git a/.editorconfig b/.editorconfig index 2536d66bf..5760be583 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,7 +3,7 @@ root = true [*] indent_style = space -indent_size = 4 +indent_size = 2 charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true diff --git a/.eslintrc b/.eslintrc index 839cbb37b..267bcac44 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,40 +1,48 @@ { "parser": "babel-eslint", + "extends": "airbnb", "env": { - "es6": true, - "node": true, "browser": true, - "jquery": true - }, - "ecmaFeatures": { - "arrowFunctions": true, - "binaryLiterals": true, - "blockBindings": true, - "classes": true, - "defaultParams": true, - "destructuring": true, - "forOf": true, - "generators": true, - "modules": true, - "objectLiteralComputedProperties": true, - "objectLiteralDuplicateProperties": true, - "objectLiteralShorthandMethods": true, - "objectLiteralShorthandProperties": true, - "octalLiterals": true, - "regexUFlag": true, - "regexYFlag": true, - "spread": true, - "superInFunctions": true, - "templateStrings": true, - "unicodeCodePointEscapes": true, - "globalReturn": true, - "jsx": false + "node": true, + "mocha": true, + "es6": true }, "rules": { - "strict": 0, - "indent": [2, 2], - "quotes": [2, "single"], - "no-unused-vars": 0 + "no-param-reassign": [2, {"props": false}], + "react/jsx-no-bind": 0, + "object-curly-spacing": 0, + "react/no-multi-comp": 0, + "import/default": 0, + "import/no-duplicates": 0, + "import/named": 0, + "import/namespace": 0, + "import/no-unresolved": 0, + "import/no-named-as-default": 2, + "block-scoped-var": 0, + max-len: [2, 140, 4], + space-before-function-paren: 0, + "padded-blocks": 0, + "comma-dangle": 0, // not sure why airbnb turned this on. gross! + "indent": [2, 2, {"SwitchCase": 1}], + "no-console": 0, + "no-alert": 0 + }, + "plugins": [ + "react", "import" + ], + "settings": { + "import/parser": "babel-eslint", + "import/resolve": { + moduleDirectory: ["node_modules", "src"] + } }, - "plugins": ["react"] + "globals": { + "__DEVELOPMENT__": true, + "__CLIENT__": true, + "__SERVER__": true, + "__DISABLE_SSR__": true, + "__DEVTOOLS__": true, + "socket": true, + "webpackIsomorphicTools": true + } } diff --git a/.gitignore b/.gitignore index fa75aa835..4ab886e06 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,12 @@ .idea/ -build -node_modules -/node_modules +build/ +node_modules/ +dist/ +coverage/ test-results.xml +*.iml npm-debug.log +webpack-assets.json webpack-stats.json bundle-stats.json selenium-debug.log @@ -11,3 +14,5 @@ tests/functional/output/* test/functional/screenshots/* .ssh webpack-stats.debug.json +phantomjsdriver.log +stats.json diff --git a/Dockerfile b/Dockerfile index fdc518043..0333bfd29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,4 @@ -FROM ubuntu - -ENV NODE_ENV production - -RUN apt-get -y update && apt-get -y install \ -nodejs npm supervisor nodejs-legacy ssh rsync - -# logrotate -RUN apt-get -y install logrotate -COPY docker/supervisord.conf /etc/supervisor/supervisord.conf -COPY docker/pm2.logrotate.conf /etc/logrotate.d/pm2 -RUN cp /etc/cron.daily/logrotate /etc/cron.hourly +FROM node:5.1.1 # cache npm install when package.json hasn't changed WORKDIR /tmp @@ -17,23 +6,30 @@ ADD package.json package.json RUN npm install RUN npm install -g pm2 -RUN mkdir /quran -RUN cp -a /tmp/node_modules /quran +RUN mkdir /sparrow +RUN cp -a /tmp/node_modules /sparrow -WORKDIR /quran -ADD . /quran/ +WORKDIR /sparrow +ADD . /sparrow/ +ENV NODE_ENV production +ENV API_URL http://marketplace.peek.com +ENV PIRATE_URL http://www.peek.com RUN npm run build -# ssh keys -WORKDIR /root -RUN mv /quran/.ssh /root/ - # upload js and css -WORKDIR /quran/build -RUN rsync --update --progress -raz main* ahmedre@rsync.keycdn.com:zones/assets/ +WORKDIR /sparrow/build +# UPLOAD TO S3! -# go back to /quran -WORKDIR /quran +# go back to /sparrow +WORKDIR /sparrow + +ENV NODE_ENV production +ENV NODE_PATH "./src" +ENV HOST 127.0.0.1 +ENV PORT 8000 +ENV API_URL http://marketplace.peek.com +ENV PIRATE_URL http://www.peek.com +ENV DISABLE_SSR false EXPOSE 8000 -CMD ["supervisord", "--nodaemon", "-c", "/etc/supervisor/supervisord.conf"] +CMD ["pm2", "start", "./bin/server.js", "--no-daemon", "-i", "0"] diff --git a/README.md b/README.md index 06afb2da5..24b19fd24 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,150 @@ -## Quran.com -This is the project soon to be the Quran.com facing site. This is built in -Reactjs + Flux (Fluxible by Yahoo) + Expressjs + Webpack. It is isomorphic (javascript shared -between both the server and the client) for SEO reasons. +# Quran.com -#### Getting started -Simply clone this repo, then run `npm install` to install all the required node_modules. -From there, you are ready to go! To start the app, run `npm run watch` which will -run both the server and the client (webpack) to compile upon edits. In fact, -hot-module has been added that components will update without the need to refresh -the page. +## About -#### Tests -Run `npm run test:watch` to run the tests locally and watching. Otherwise use `npm run test` for CI level tests. +This is a starter boilerplate app I've put together using the following technologies: -We also have nightwatch function tests. You can install nightwatch globally and can run tests like this: +* ~~Isomorphic~~ [Universal](https://medium.com/@mjackson/universal-javascript-4761051b7ae9) rendering +* Both client and server make calls to load data from separate API server +* [React](https://github.com/facebook/react) +* [React Router](https://github.com/rackt/react-router) +* [Express](http://expressjs.com) +* [Babel](http://babeljs.io) for ES6 and ES7 magic +* [Webpack](http://webpack.github.io) for bundling +* [Webpack Dev Middleware](http://webpack.github.io/docs/webpack-dev-middleware.html) +* [Webpack Hot Middleware](https://github.com/glenjamin/webpack-hot-middleware) +* [Redux](https://github.com/rackt/redux)'s futuristic [Flux](https://facebook.github.io/react/blog/2014/05/06/flux.html) implementation +* [Redux Dev Tools](https://github.com/gaearon/redux-devtools) for next generation DX (developer experience). Watch [Dan Abramov's talk](https://www.youtube.com/watch?v=xsSnOQynTHs). +* [Redux Router](https://github.com/rackt/redux-router) Keep your router state in your Redux store +* [ESLint](http://eslint.org) to maintain a consistent code style +* [redux-form](https://github.com/erikras/redux-form) to manage form state in Redux +* [lru-memoize](https://github.com/erikras/lru-memoize) to speed up form validation +* [multireducer](https://github.com/erikras/multireducer) to combine single reducers into one key-based reducer +* [style-loader](https://github.com/webpack/style-loader), [sass-loader](https://github.com/jtangelder/sass-loader) and [less-loader](https://github.com/webpack/less-loader) to allow import of stylesheets in plain css, sass and less, +* [bootstrap-sass-loader](https://github.com/shakacode/bootstrap-sass-loader) and [font-awesome-webpack](https://github.com/gowravshekar/font-awesome-webpack) to customize Bootstrap and FontAwesome +* [react-document-meta](https://github.com/kodyl/react-document-meta) to manage title and meta tag information on both server and client +* [webpack-isomorphic-tools](https://github.com/halt-hammerzeit/webpack-isomorphic-tools) to allow require() work for statics both on client and server +* [mocha](https://mochajs.org/) to allow writing unit tests for the project. + +I cobbled this together from a wide variety of similar "starter" repositories. As I post this in June 2015, all of these libraries are right at the bleeding edge of web development. They may fall out of fashion as quickly as they have come into it, but I personally believe that this stack is the future of web development and will survive for several years. I'm building my new projects like this, and I recommend that you do, too. + +## Installation + +```bash +npm install ``` -nightwatch --test tests/functional/specs/Index_spec.js + +## Running Dev Server + +```bash +npm run dev ``` -#### Backend -Current at: https://github.com/quran/quran-api-rails -DB is private, message me for acceess. +The first time it may take a little while to generate the first `webpack-assets.json` and complain with a few dozen `[webpack-isomorphic-tools] (waiting for the first Webpack build to finish)` printouts, but be patient. Give it 30 seconds. + +### Using Redux DevTools -#### How to contribute -Fork this repo, then create a PR for specific fixes, improvements, etc. We trust that -you will not steal this, this is at the end of the day for the sake of Allah and we -all have good intentions while working with this project. But I must stress, stealing -this is unacceptable. +In development, Redux Devtools are enabled by default. You can toggle visibility and move the dock around using the following keyboard shortcuts: -#### Design -We currently use InvisionApp. Again, contact me if you'd like access to it. +- Ctrl+H Toggle DevTools Dock +- Ctrl+Q Move Dock Position +- see [redux-devtools-dock-monitor](https://github.com/gaearon/redux-devtools-dock-monitor) for more detail information. -#### Making sure main.js is small -Follow: https://www.npmjs.com/package/webpack-bundle-size-analyzer +## Building and Running Production Server + +```bash +npm run build +npm run start +``` + +If you want to mimic what production is like, I encourage you to install pm2 +```bash +npm install pm2 -g ``` -env NODE_ENV=development webpack --json > bundle-stats.json -subl bundle-stats.json #so that you can the output -analyze-bundle-size bundle-stats.json + +Then run +```bash +env NODE_PATH="./src" NODE_ENV=production pm2 start ./bin/server.js ``` +Navigate to localhost:4600 if you're on local. Prod should be 8000. + +## Explanation + +What initially gets run is `bin/server.js`, which does little more than enable ES6 and ES7 awesomeness in the +server-side node code. It then initiates `server.js`. In `server.js` we proxy any requests to `/api/*` to the +[API server](#api-server), running at `localhost:3030`. All the data fetching calls from the client go to `/api/*`. +Aside from serving the favicon and static content from `/static`, the only thing `server.js` does is initiate delegate +rendering to `react-router`. At the bottom of `server.js`, we listen to port `3000` and initiate the API server. + +#### Routing and HTML return + +The primary section of `server.js` generates an HTML page with the contents returned by `react-router`. First we instantiate an `ApiClient`, a facade that both server and client code use to talk to the API server. On the server side, `ApiClient` is given the request object so that it can pass along the session cookie to the API server to maintain session state. We pass this API client facade to the `redux` middleware so that the action creators have access to it. + +Then we perform [server-side data fetching](#server-side-data-fetching), wait for the data to be loaded, and render the page with the now-fully-loaded `redux` state. + +The last interesting bit of the main routing section of `server.js` is that we swap in the hashed script and css from the `webpack-assets.json` that the Webpack Dev Server – or the Webpack build process on production – has spit out on its last run. You won't have to deal with `webpack-assets.json` manually because [webpack-isomorphic-tools](https://github.com/halt-hammerzeit/webpack-isomorphic-tools) take care of that. + +We also spit out the `redux` state into a global `window.__data` variable in the webpage to be loaded by the client-side `redux` code. + +#### Server-side Data Fetching + +We ask `react-router` for a list of all the routes that match the current request and we check to see if any of the matched routes has a static `fetchData()` function. If it does, we pass the redux dispatcher to it and collect the promises returned. Those promises will be resolved when each matching route has loaded its necessary data from the API server. + +#### Client Side + +The client side entry point is reasonably named `client.js`. All it does is load the routes, initiate `react-router`, rehydrate the redux state from the `window.__data` passed in from the server, and render the page over top of the server-rendered DOM. This makes React enable all its event listeners without having to re-render the DOM. + +#### Redux Middleware + +The middleware, [`clientMiddleware.js`](https://github.com/erikras/react-redux-universal-hot-example/blob/master/src/redux/middleware/clientMiddleware.js), serves two functions: + +1. To allow the action creators access to the client API facade. Remember this is the same on both the client and the server, and cannot simply be `import`ed because it holds the cookie needed to maintain session on server-to-server requests. +2. To allow some actions to pass a "promise generator", a function that takes the API client and returns a promise. Such actions require three action types, the `REQUEST` action that initiates the data loading, and a `SUCCESS` and `FAILURE` action that will be fired depending on the result of the promise. There are other ways to accomplish this, some discussed [here](https://github.com/rackt/redux/issues/99), which you may prefer, but to the author of this example, the middleware way feels cleanest. + +#### Redux Modules... *What the Duck*? + +The `src/redux/modules` folder contains "modules" to help +isolate concerns within a Redux application (aka [Ducks](https://github.com/erikras/ducks-modular-redux), a Redux Style Proposal that I came up with). I encourage you to read the +[Ducks Docs](https://github.com/erikras/ducks-modular-redux) and provide feedback. + +#### API Server + +This is where the meat of your server-side application goes. It doesn't have to be implemented in Node or Express at all. This is where you connect to your database and provide authentication and session management. In this example, it's just spitting out some json with the current time stamp. + +#### Getting data and actions into components + +To understand how the data and action bindings get into the components – there's only one, `InfoBar`, in this example – I'm going to refer to you to the [Redux](https://github.com/gaearon/redux) library. The only innovation I've made is to package the component and its wrapper in the same js file. This is to encapsulate the fact that the component is bound to the `redux` actions and state. The component using `InfoBar` needn't know or care if `InfoBar` uses the `redux` data or not. + +#### Images + +Now it's possible to render the image both on client and server. Please refer to issue [#39](https://github.com/erikras/react-redux-universal-hot-example/issues/39) for more detail discussion, the usage would be like below (super easy): + +```javascript +let logoImage = require('./logo.png'); +``` + +#### Styles + +This project uses [local styles](https://medium.com/seek-ui-engineering/the-end-of-global-css-90d2a4a06284) using [css-loader](https://github.com/webpack/css-loader). The way it works is that you import your stylesheet at the top of the class with your React Component, and then you use the classnames returned from that import. Like so: + +```javascript +const styles = require('./App.scss'); +``` + +Then you set the `className` of your element to match one of the CSS classes in your SCSS file, and you're good to go! + +```jsx +
...
+``` + +#### Unit Tests + +The project uses [Mocha](https://mochajs.org/) to run your unit tests, it uses [Karma](http://karma-runner.github.io/0.13/index.html) as the test runner, it enables the feature that you are able to render your tests to the browser (e.g: Firefox, Chrome etc.), which means you are able to use the [Test Utilities](http://facebook.github.io/react/docs/test-utils.html) from Facebook api like `renderIntoDocument()`. + +To run the tests in the project, just simply run `npm test` if you have `Chrome` installed, it will be automatically launched as a test service for you. + +To keep watching your test suites that you are working on, just set `singleRun: false` in the `karma.conf.js` file. Please be sure set it to `true` if you are running `npm test` on a continuous integration server (travis-ci, etc). + +#### How do I disable the dev tools? + +They will only show in development, but if you want to disable them even there, set `__DEVTOOLS__` to `false` in `/webpack/dev.config.js`. diff --git a/api/__tests__/api.spec.js b/api/__tests__/api.spec.js new file mode 100644 index 000000000..610f4421d --- /dev/null +++ b/api/__tests__/api.spec.js @@ -0,0 +1,67 @@ +import {expect} from 'chai'; +import {mapUrl} from '../utils/url'; + +describe('mapUrl', () => { + + it('extracts nothing if both params are undefined', () => { + expect(mapUrl(undefined, undefined)).to.deep.equal({ + action: null, + params: [] + }); + }); + + it('extracts nothing if the url is empty', () => { + const url = ''; + const splittedUrlPath = url.split('?')[0].split('/').slice(1); + const availableActions = {a: 1, widget: {c: 1, load: () => 'baz'}}; + + expect(mapUrl(availableActions, splittedUrlPath)).to.deep.equal({ + action: null, + params: [] + }); + }); + + it('extracts nothing if nothing was found', () => { + const url = '/widget/load/?foo=bar'; + const splittedUrlPath = url.split('?')[0].split('/').slice(1); + const availableActions = {a: 1, info: {c: 1, load: () => 'baz'}}; + + expect(mapUrl(availableActions, splittedUrlPath)).to.deep.equal({ + action: null, + params: [] + }); + }); + it('extracts the available actions and the params from an relative url string with GET params', () => { + + const url = '/widget/load/param1/xzy?foo=bar'; + const splittedUrlPath = url.split('?')[0].split('/').slice(1); + const availableActions = {a: 1, widget: {c: 1, load: () => 'baz'}}; + + expect(mapUrl(availableActions, splittedUrlPath)).to.deep.equal({ + action: availableActions.widget.load, + params: ['param1', 'xzy'] + }); + }); + + it('extracts the available actions from an url string without GET params', () => { + const url = '/widget/load/?foo=bar'; + const splittedUrlPath = url.split('?')[0].split('/').slice(1); + const availableActions = {a: 1, widget: {c: 1, load: () => 'baz'}}; + + expect(mapUrl(availableActions, splittedUrlPath)).to.deep.equal({ + action: availableActions.widget.load, + params: [''] + }); + }); + + it('does not find the avaialble action if deeper nesting is required', () => { + const url = '/widget'; + const splittedUrlPath = url.split('?')[0].split('/').slice(1); + const availableActions = {a: 1, widget: {c: 1, load: () => 'baz'}}; + + expect(mapUrl(availableActions, splittedUrlPath)).to.deep.equal({ + action: null, + params: [] + }); + }); +}); diff --git a/api/actions/index.js b/api/actions/index.js new file mode 100644 index 000000000..bc53bd1cb --- /dev/null +++ b/api/actions/index.js @@ -0,0 +1,5 @@ +export loadInfo from './loadInfo'; +export loadAuth from './loadAuth'; +export login from './login'; +export logout from './logout'; +export * as widget from './widget/index'; diff --git a/api/actions/loadAuth.js b/api/actions/loadAuth.js new file mode 100644 index 000000000..34cc97ecb --- /dev/null +++ b/api/actions/loadAuth.js @@ -0,0 +1,3 @@ +export default function loadAuth(req) { + return Promise.resolve(req.session.user || null); +} diff --git a/api/actions/loadInfo.js b/api/actions/loadInfo.js new file mode 100644 index 000000000..073d80dab --- /dev/null +++ b/api/actions/loadInfo.js @@ -0,0 +1,8 @@ +export default function loadInfo() { + return new Promise((resolve) => { + resolve({ + message: 'This came from the api server', + time: Date.now() + }); + }); +} diff --git a/api/actions/login.js b/api/actions/login.js new file mode 100644 index 000000000..6c9a6a113 --- /dev/null +++ b/api/actions/login.js @@ -0,0 +1,7 @@ +export default function login(req) { + const user = { + name: req.body.name + }; + req.session.user = user; + return Promise.resolve(user); +} diff --git a/api/actions/logout.js b/api/actions/logout.js new file mode 100644 index 000000000..a6a69f8be --- /dev/null +++ b/api/actions/logout.js @@ -0,0 +1,8 @@ +export default function logout(req) { + return new Promise((resolve) => { + req.session.destroy(() => { + req.session = null; + return resolve(null); + }); + }); +} diff --git a/api/actions/widget/index.js b/api/actions/widget/index.js new file mode 100644 index 000000000..f92acd6fb --- /dev/null +++ b/api/actions/widget/index.js @@ -0,0 +1,2 @@ +export update from './update'; +export load from './load'; diff --git a/api/actions/widget/load.js b/api/actions/widget/load.js new file mode 100644 index 000000000..c4b5ff2a6 --- /dev/null +++ b/api/actions/widget/load.js @@ -0,0 +1,28 @@ +const initialWidgets = [ + {id: 1, color: 'Red', sprocketCount: 7, owner: 'John'}, + {id: 2, color: 'Taupe', sprocketCount: 1, owner: 'George'}, + {id: 3, color: 'Green', sprocketCount: 8, owner: 'Ringo'}, + {id: 4, color: 'Blue', sprocketCount: 2, owner: 'Paul'} +]; + +export function getWidgets(req) { + let widgets = req.session.widgets; + if (!widgets) { + widgets = initialWidgets; + req.session.widgets = widgets; + } + return widgets; +} + +export default function load(req) { + return new Promise((resolve, reject) => { + // make async call to database + setTimeout(() => { + if (Math.random() < 0.33) { + reject('Widget load fails 33% of the time. You were unlucky.'); + } else { + resolve(getWidgets(req)); + } + }, 1000); // simulate async load + }); +} diff --git a/api/actions/widget/update.js b/api/actions/widget/update.js new file mode 100644 index 000000000..3d50834e6 --- /dev/null +++ b/api/actions/widget/update.js @@ -0,0 +1,24 @@ +import load from './load'; + +export default function update(req) { + return new Promise((resolve, reject) => { + // write to database + setTimeout(() => { + if (Math.random() < 0.2) { + reject('Oh no! Widget save fails 20% of the time. Try again.'); + } else { + const widgets = load(req); + const widget = req.body; + if (widget.color === 'Green') { + reject({ + color: 'We do not accept green widgets' // example server-side validation error + }); + } + if (widget.id) { + widgets[widget.id - 1] = widget; // id is 1-based. please don't code like this in production! :-) + } + resolve(widget); + } + }, 2000); // simulate async db write + }); +} diff --git a/api/api.js b/api/api.js new file mode 100644 index 000000000..c65b1a900 --- /dev/null +++ b/api/api.js @@ -0,0 +1,92 @@ +import express from 'express'; +import session from 'express-session'; +import bodyParser from 'body-parser'; +import config from '../src/config'; +import * as actions from './actions/index'; +import {mapUrl} from 'utils/url.js'; +import PrettyError from 'pretty-error'; +import http from 'http'; + +const pretty = new PrettyError(); +const app = express(); + +const server = new http.Server(app); + +const io = new SocketIo(server); +io.path('/ws'); + +app.use(session({ + secret: 'react and redux rule!!!!', + resave: false, + saveUninitialized: false, + cookie: { maxAge: 60000 } +})); +app.use(bodyParser.json()); + + +app.use((req, res) => { + + const splittedUrlPath = req.url.split('?')[0].split('/').slice(1); + + const {action, params} = mapUrl(actions, splittedUrlPath); + + if (action) { + action(req, params) + .then((result) => { + if (result instanceof Function) { + result(res); + } else { + res.json(result); + } + }, (reason) => { + if (reason && reason.redirect) { + res.redirect(reason.redirect); + } else { + console.error('API ERROR:', pretty.render(reason)); + res.status(reason.status || 500).json(reason); + } + }); + } else { + res.status(404).end('NOT FOUND'); + } +}); + + +const bufferSize = 100; +const messageBuffer = new Array(bufferSize); +let messageIndex = 0; + +if (config.apiPort) { + const runnable = app.listen(config.apiPort, (err) => { + if (err) { + console.error(err); + } + console.info('----\n==> 🌎 API is running on port %s', config.apiPort); + console.info('==> 💻 Send requests to http://%s:%s', config.apiHost, config.apiPort); + }); + + io.on('connection', (socket) => { + socket.emit('news', {msg: `'Hello World!' from server`}); + + socket.on('history', () => { + for (let index = 0; index < bufferSize; index++) { + const msgNo = (messageIndex + index) % bufferSize; + const msg = messageBuffer[msgNo]; + if (msg) { + socket.emit('msg', msg); + } + } + }); + + socket.on('msg', (data) => { + data.id = messageIndex; + messageBuffer[messageIndex % bufferSize] = data; + messageIndex++; + io.emit('msg', data); + }); + }); + io.listen(runnable); + +} else { + console.error('==> ERROR: No PORT environment variable has been specified'); +} diff --git a/api/utils/url.js b/api/utils/url.js new file mode 100644 index 000000000..6e3e78ca8 --- /dev/null +++ b/api/utils/url.js @@ -0,0 +1,26 @@ +export function mapUrl(availableActions = {}, url = []) { + + const notFound = {action: null, params: []}; + + // test for empty input + if (url.length === 0 || Object.keys(availableActions).length === 0) { + return notFound; + } + /*eslint-disable */ + const reducer = (next, current) => { + if (next.action && next.action[current]) { + return {action: next.action[current], params: []}; // go deeper + } else { + if (typeof next.action === 'function') { + return {action: next.action, params: next.params.concat(current)}; // params are found + } else { + return notFound; + } + } + }; + /*eslint-enable */ + + const actionAndParams = url.reduce(reducer, {action: availableActions, params: []}); + + return (typeof actionAndParams.action === 'function') ? actionAndParams : notFound; +} diff --git a/app.js b/app.js deleted file mode 100644 index 10053981d..000000000 --- a/app.js +++ /dev/null @@ -1,27 +0,0 @@ -import Fluxible from 'fluxible'; -import { RouteStore } from 'fluxible-router'; -import Application from 'components/Application'; -import routes from 'configs/routes'; -import ApplicationStore from 'stores/ApplicationStore'; -import SurahsStore from 'stores/SurahsStore'; -import UserStore from 'stores/UserStore'; -import AyahsStore from 'stores/AyahsStore'; -import AudioplayerStore from 'stores/AudioplayerStore'; -import React from 'react'; - -// create new fluxible instance -const app = new Fluxible({ - component: React.createFactory(Application) -}); -// register routes -var MyRouteStore = RouteStore.withStaticRoutes(routes); -app.registerStore(MyRouteStore); - -// register other stores -app.registerStore(ApplicationStore); -app.registerStore(SurahsStore); -app.registerStore(UserStore); -app.registerStore(AudioplayerStore); -app.registerStore(AyahsStore); - -export default app; diff --git a/app.json b/app.json new file mode 100644 index 000000000..a2e1d9ffc --- /dev/null +++ b/app.json @@ -0,0 +1,19 @@ +{ + "name": "react-redux-universal-hot-example", + "description": "Example of an isomorphic (universal) webapp using react redux and hot reloading", + "repository": "https://github.com/erikras/react-redux-universal-hot-example", + "logo": "http://node-js-sample.herokuapp.com/node.svg", + "keywords": [ + "react", + "isomorphic", + "universal", + "webpack", + "express", + "hot reloading", + "react-hot-reloader", + "redux", + "starter", + "boilerplate", + "babel" + ] +} diff --git a/bin/api.js b/bin/api.js new file mode 100644 index 000000000..9498ee5eb --- /dev/null +++ b/bin/api.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node +if (process.env.NODE_ENV !== 'production') { + if (!require('piping')({ + hook: true, + ignore: /(\/\.|~$|\.json$)/i + })) { + return; + } +} +require('../server.babel'); // babel registration (runtime transpilation for node) +require('../api/api'); diff --git a/bin/nightwatch.js b/bin/nightwatch.js new file mode 100644 index 000000000..732ac30a1 --- /dev/null +++ b/bin/nightwatch.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node +var path = require('path'); +var rootDir = path.resolve(__dirname, '.'); + +require('app-module-path').addPath(rootDir); +require('app-module-path').addPath('./src'); + +require('nightwatch/bin/runner.js'); diff --git a/bin/server.js b/bin/server.js new file mode 100644 index 000000000..ddbed7cb1 --- /dev/null +++ b/bin/server.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +require('../server.babel'); // babel registration (runtime transpilation for node) +// require('babel-register'); +var path = require('path'); +var rootDir = path.resolve(__dirname, '..'); + +require('app-module-path').addPath(rootDir); +require('app-module-path').addPath('../src'); +/** + * Define isomorphic constants. + */ +global.__CLIENT__ = false; +global.__SERVER__ = true; +// global.__DISABLE_SSR__ = (process.env.DISABLE_SSR === 'true'); // <----- DISABLES SERVER SIDE RENDERING FOR ERROR DEBUGGING +global.__DISABLE_SSR__ = false; +global.__DEVELOPMENT__ = (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test'); +global.__TEST__ = process.env.NODE_ENV === 'test'; + +if (__DEVELOPMENT__) { + if (!require('piping')({ + hook: true, + ignore: /(\/\.|~$|\.json|\.scss$)/i + })) { + return; + } +} + +if (__TEST__) { + module.exports = function(cb) { + var WebpackIsomorphicTools = require('webpack-isomorphic-tools'); + global.webpackIsomorphicTools = new WebpackIsomorphicTools(require('../webpack/webpack-isomorphic-tools')) + .development(true) + .server(rootDir, function() { + server = require('../src/server')(function(serverInstance) {cb(serverInstance);}); + }); + } +} +else { + var WebpackIsomorphicTools = require('webpack-isomorphic-tools'); + global.webpackIsomorphicTools = new WebpackIsomorphicTools(require('../webpack/webpack-isomorphic-tools')) + .development(__DEVELOPMENT__) + .server(rootDir, function() { + require('../src/server')(); + }); +} diff --git a/bootstrap-sass.config.js b/bootstrap-sass.config.js deleted file mode 100644 index e6b742a3c..000000000 --- a/bootstrap-sass.config.js +++ /dev/null @@ -1,83 +0,0 @@ -var ExtractTextPlugin = require("extract-text-webpack-plugin"); -// Example file. Copy this to your project -module.exports = { - verbose: false, // Set to true to show diagnostic information - debug: false, - - // IMPORTANT: Set next two configuration so you can customize - // bootstrapCustomizations: gets loaded before bootstrap so you can configure the variables used by bootstrap - // mainSass: gets loaded after bootstrap, so you can override a bootstrap style. - // NOTE, these are optional. - - preBootstrapCustomizations: "src/styles/_bootstrap-config.scss", - mainSass: "src/styles/_main.scss", - - // Default for the style loading - // styleLoader: "style-loader!css-loader!sass-loader", - // - // If you want to use the ExtractTextPlugin - // and you want compressed - // styleLoader: ExtractTextPlugin.extract("style-loader", "css-loader!sass-loader"), - // - // If you want expanded CSS - styleLoader: ExtractTextPlugin.extract("style-loader", "css-loader!autoprefixer!sass?outputStyle=expanded"), - - scripts: { - 'transition': false, - 'alert': false, - 'button': false, - 'carousel': false, - 'collapse': false, - 'dropdown': true, - 'modal': true, - 'tooltip': true, - 'popover': true, - 'scrollspy': false, - 'tab': false, - 'affix': false - }, - styles: { - "mixins": true, - - "normalize": true, - "print": false, - - "scaffolding": true, - "type": true, - "code": true, - "grid": true, - "tables": true, - "forms": true, - "buttons": true, - - "component-animations": true, - "glyphicons": false, - "dropdowns": true, - "button-groups": false, - "input-groups": false, - "navs": true, - "navbar": true, - "breadcrumbs": false, - "pagination": true, - "pager": true, - "labels": true, - "badges": true, - "jumbotron": false, - "thumbnails": false, - "alerts": false, - "progress-bars": false, - "media": false, - "list-group": false, - "panels": true, - "wells": false, - "close": true, - - "modals": true, - "tooltip": true, - "popovers": true, - "carousel": false, - - "utilities": true, - "responsive-utilities": true - } -}; diff --git a/client.js b/client.js deleted file mode 100644 index b456475ab..000000000 --- a/client.js +++ /dev/null @@ -1,48 +0,0 @@ -/*global document, window, $ */ -import 'babel-polyfill'; - -import ReactDOM from 'react-dom'; -import app from './app'; -import reactCookie from 'react-cookie'; -import createElementWithContext from 'fluxible-addons-react/createElementWithContext'; -import debug from 'utils/Debug'; - -const dehydratedState = window.App; // Sent from the server - -// expose debug object to browser, so that it can be enabled/disabled from browser: -// https://github.com/visionmedia/debug#browser-support -window.fluxibleDebug = debug; -window.ReactDOM = ReactDOM; // For chrome dev tool support - -window.clearCookies = function() { - reactCookie.remove('quran'); - reactCookie.remove('content'); - reactCookie.remove('audio'); - reactCookie.remove('isFirstTime'); -}; - -// Init tooltip -if (typeof window !== 'undefined') { - $(function () { - $(document.body).tooltip({ - selector: '[data-toggle="tooltip"]', - animation: false - }); - }); -} - -debug('client', 'rehydrating app'); -// pass in the dehydrated server state from server.js -app.rehydrate(dehydratedState, function (err, context) { - if (err) { - throw err; - } - - window.context = context; - const mountNode = document.getElementById('app'); - - debug('client', 'React Rendering'); - ReactDOM.render(createElementWithContext(context), mountNode, function () { - debug('client', 'React Rendered'); - }); -}); diff --git a/development.env b/development.env deleted file mode 100644 index d76a21036..000000000 --- a/development.env +++ /dev/null @@ -1,3 +0,0 @@ -PORT=8000 -CURRENT_URL=http://localhost:8000/api/ -API_URL=http://localhost:3000 diff --git a/docker/pm2.logrotate.conf b/docker/pm2.logrotate.conf deleted file mode 100644 index e2cc34f5d..000000000 --- a/docker/pm2.logrotate.conf +++ /dev/null @@ -1,11 +0,0 @@ -/root/.pm2/logs/*.log { - hourly - missingok - rotate 8760 - dateext - compress - delaycompress - notifempty - create 0640 www-data adm - copytruncate -} diff --git a/docker/supervisord.conf b/docker/supervisord.conf deleted file mode 100644 index 3591b6284..000000000 --- a/docker/supervisord.conf +++ /dev/null @@ -1,11 +0,0 @@ -[supervisord] -nodaemon=true - -[program:pm2] -command=pm2 start /quran/start.js -i 4 --no-daemon -directory=/quran -redirect_stderr=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -auto_start=true -autorestart=true diff --git a/karma.conf.js b/karma.conf.js index ba31ccfc7..b0d9e3e02 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -1,136 +1,90 @@ -module.exports = function(config) { +var webpack = require('webpack'); +var path = require('path'); +var rootDir = path.resolve(__dirname, '.'); +require('app-module-path').addPath(rootDir); +require('app-module-path').addPath('../src'); + +module.exports = function (config) { config.set({ - // base path that will be used to resolve all patterns (eg. files, exclude) - basePath: '', + browsers: ['Chrome'], - plugins: [ - 'karma-mocha', - 'karma-chai-sinon', - 'karma-sinon', - 'karma-webpack', - 'karma-chrome-launcher', - 'karma-phantomjs-launcher' - ], + singleRun: false, - // frameworks to use - // available frameworks: https://npmjs.org/browse/keyword/karma-adapter - frameworks: ['mocha', 'chai-sinon', 'sinon'], + frameworks: [ 'mocha', 'chai', 'chai-sinon' ], - // list of files / patterns to load in the browser files: [ - 'node_modules/babel-core/browser-polyfill.js', './node_modules/phantomjs-polyfill/bind-polyfill.js', - './tests/polyfill/Event.js', - {pattern: "static/images/*", watched: false, included: false, served: true}, - - // Actual tests here - {pattern: 'tests/unit/**/*.spec.js', watched: true, served: true, included: true} + 'tests.webpack.js' ], - // list of files to exclude - exclude: [ - ], - - proxies: { - '/images': __dirname + '/static/images', - '/images/': __dirname + '/static/images/', + preprocessors: { + 'tests.webpack.js': [ 'webpack', 'sourcemap' ] }, - proxyValidateSSL: false, - - // preprocess matching files before serving them to the browser - // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessors + reporters: [ 'mocha', 'coverage' ], - preprocessors: { - 'tests/unit/**/*.spec.js': ['webpack'] - }, + plugins: [ + require("karma-webpack"), + require("karma-mocha"), + require("karma-chai"), + require("karma-chai-sinon"), + require("karma-mocha-reporter"), + require("karma-phantomjs-launcher"), + require("karma-chrome-launcher"), + require("karma-sourcemap-loader"), + require("karma-coverage") + ], webpack: { - resolve: { - root: [ - __dirname + '/node_modules', - __dirname + '/test/client' - ], - alias: { - 'components': __dirname + '/src/scripts/components', - 'actions': __dirname + '/src/scripts/actions', - 'stores': __dirname + '/src/scripts/stores', - 'constants': __dirname + '/src/scripts/constants', - 'mixins': __dirname + '/src/scripts/mixins', - 'configs': __dirname + '/src/scripts/configs', - 'utils': __dirname + '/src/scripts/utils' - }, - extensions: ['', '.js', '.jsx'] - }, - + devtool: 'inline-source-map', module: { + // TODO: When using babel 6 throughout the probject. + // preLoaders: [ + // transpile all files except testing sources with babel as usual + // { + // test: /\.js$/, + // exclude: [ + // path.resolve('./src/'), + // path.resolve('node_modules/') + // ], + // loader: 'babel?' + JSON.stringify(babelLoaderQuery) + // }, + // transpile and instrument only testing sources with isparta + // { + // test: /\.js$/, + // loader: 'isparta' + // } + // ], loaders: [ - { test: /\.js?$/, exclude: [/node_modules/], loader: 'babel-loader' } + { test: /\.(jpe?g|png|gif|svg)$/, loader: 'url', query: {limit: 10240} }, + { test: /\.js$/, exclude: /node_modules/, loaders: ['babel']}, + { test: /\.json$/, loader: 'json-loader' }, + { test: /\.scss$/, loader: 'style!css?modules&importLoaders=2&sourceMap&localIdentName=[local]___[hash:base64:5]!autoprefixer?browsers=last 2 version!sass?outputStyle=expanded&sourceMap' } ] }, - - devtool: 'inline-source-map', - - node: { - // karma watches test/unit/index.js - // webpack watches dependencies of test/unit/index.js - fs: "empty" + resolve: { + modulesDirectories: [ + 'src', + 'node_modules' + ], + extensions: ['', '.json', '.js'] }, - - plugins:[ - //only include moment.js 'en' locale - // new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /en/) + plugins: [ + new webpack.IgnorePlugin(/\.json$/), + new webpack.NoErrorsPlugin(), + new webpack.DefinePlugin({ + __CLIENT__: true, + __SERVER__: false, + __DEVELOPMENT__: true, + __DEVTOOLS__: false // <-------- DISABLE redux-devtools HERE + }) ], - - watch: true - }, - - webpackMiddleware: { noInfo: true }, - - client: { - mocha: { - globals: [] - } - }, - - // test results reporter to use - // possible values: 'dots', 'progress' - // available reporters: https://npmjs.org/browse/keyword/karma-reporter - reporters: ['progress'], - - junitReporter: { - outputFile: 'test-results.xml', - suite: '' }, - // web server port - port: 9876, - - - // enable / disable colors in the output (reporters and logs) - colors: true, - - - // level of logging - // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG - logLevel: config.LOG_INFO, - - - // enable / disable watching file and executing tests whenever any file changes - autoWatch: true, - - - // start these browsers - // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher - // browsers: ['Chrome', 'PhantomJS'], - browsers: ['Chrome'], - - // webpack means that PhantomJS sometimes does not respond in time - browserNoActivityTimeout: 120000, + webpackServer: { + noInfo: true + } - // Continuous Integration mode - // if true, Karma captures browsers, runs the tests and exits - singleRun: false }); }; diff --git a/nightwatch.js b/nightwatch.js deleted file mode 100644 index c0dca9092..000000000 --- a/nightwatch.js +++ /dev/null @@ -1,13 +0,0 @@ -require('dotenv').config({path: (process.env.NODE_ENV || 'development') + '.env'}); -require('app-module-path').addPath(__dirname); -require('app-module-path').addPath('./src/scripts'); - -require("babel-core/register")({ - stage: 0, - plugins: ["typecheck"] -}); - -global.__CLIENT__ = false; -global.__SERVER__ = true; - -require('nightwatch/bin/runner.js'); diff --git a/nightwatch.json b/nightwatch.json index 1b3e60df6..c4e92bcdf 100644 --- a/nightwatch.json +++ b/nightwatch.json @@ -1,9 +1,9 @@ { "src_folders": ["tests/functional/specs"], + "page_objects_path": ["tests/functional/pages"], "output_folder": "tests/functional/output", "custom_commands_path": "tests/functional/commands", "custom_assertions_path": "tests/functional/assertions", - "page_objects_path": "tests/functional/pages", "globals_path": "tests/functional/globals.js", "selenium" : { "start_process" : true, @@ -48,7 +48,8 @@ "browserName" : "phantomjs", "javascriptEnabled" : true, "acceptSslCerts" : true, - "phantomjs.binary.path" : "/usr/local/phantomjs/bin/phantomjs" + "phantomjs.binary.path" : "/usr/local/phantomjs/bin/phantomjs", + "phantomjs.page.viewportSize": "{width:1280,height:680}" } }, "mobile": { diff --git a/package.json b/package.json index 262373fcb..5a3a9699b 100644 --- a/package.json +++ b/package.json @@ -1,145 +1,220 @@ { - "name": "quran", - "version": "0.0.0", - "private": true, + "name": "quran.com", + "description": "quran.com", + "version": "1.0.0", + "keywords": [ + "react", + "isomorphic", + "universal", + "webpack", + "express", + "hot reloading", + "react-hot-reloader", + "redux", + "starter", + "boilerplate", + "babel" + ], + "main": "bin/server.js", "scripts": { - "test": "npm build && npm start && npm run test:lint && npm run test:unit && npm run test:functional", - "test:ci:unit": "./node_modules/karma/bin/karma start --browsers PhantomJS --single-run", - "test:ci:functional": "node ./nightwatch.js -c ./nightwatch.json -e production", - "test:ci:lint": "eslint ./src/scripts/**/*.js", - "test:dev:unit": "./node_modules/karma/bin/karma start", - "test:dev:functional": "node ./nightwatch.js -c ./nightwatch.json", - "test:dev:lint": "eslint ./src/scripts/**/*.js", - "dev": "node webpack-dev-server.js & PORT=8000 nodemon start.js -e js,jsx", - "start": "NODE_PATH=\"./src\" node ./start", - "build": "node ./node_modules/webpack/bin/webpack.js --verbose --colors --display-error-details --config webpack.prod.config.js", - "validate": "npm ls", - "analyze:build": "env NODE_ENV=development webpack --json --config webpack.config.js > bundle-stats.json", - "analyze:json": "analyze-bundle-size bundle-stats.json" + "start": "concurrent --kill-others \"npm run start-prod\"", + "start-prod": "better-npm-run start-prod", + "start-prod-api": "better-npm-run start-prod-api", + "build": "webpack --verbose --colors --display-error-details --config webpack/prod.config.js", + "lint": "eslint -c .eslintrc src api", + "start-dev": "better-npm-run start-dev", + "start-dev-api": "better-npm-run start-dev-api", + "watch-client": "better-npm-run watch-client", + "dev": "npm run watch-client & npm run start-dev", + "test:ci:unit": "./node_modules/karma/bin/karma start ./karma.conf.js --browsers PhantomJS --single-run", + "test:ci:functional": "npm run build; node ./bin/nightwatch.js -c ./nightwatch.json -e production", + "test": "better-npm-run watch-test", + "test:dev:functional": "better-npm-run start-functional-test", + "test:dev:node": "./node_modules/mocha/bin/mocha ./api/**/__tests__/*-test.js --compilers js:babel-core/register" }, - "engines": { - "node": ">= 0.10.0", - "iojs": ">= 1.0.3" + "betterScripts": { + "start-prod": { + "command": "node ./bin/server.js", + "env": { + "NODE_PATH": "./src", + "NODE_ENV": "production" + } + }, + "start-prod-api": { + "command": "node ./bin/api.js", + "env": { + "NODE_PATH": "./api", + "NODE_ENV": "production", + "APIPORT": 3030 + } + }, + "start-dev": { + "command": "node ./bin/server.js", + "env": { + "NODE_PATH": "./src", + "NODE_ENV": "development", + "API_URL": "http://quran.com:3000", + "PORT": 8000 + } + }, + "start-dev-api": { + "command": "node ./bin/api.js", + "env": { + "NODE_PATH": "./api", + "NODE_ENV": "development", + "APIPORT": 3030 + } + }, + "start-functional-test": { + "command": "node ./bin/nightwatch.js -c ../nightwatch.json", + "env": { + "PORT": 8000, + "NODE_PATH": "./src" + } + }, + "watch-client": { + "command": "node webpack/webpack-dev-server.js", + "env": { + "UV_THREADPOOL_SIZE": 100, + "NODE_PATH": "./src", + "PORT": 8000, + "API_URL": "http://quran.com:3000" + } + }, + "watch-test": { + "command": "karma start", + "env": { + "NODE_ENV": "test" + } + } }, "dependencies": { "app-module-path": "^1.0.4", - "autoprefixer-loader": "^3.1.0", - "babel": "^6.1.18", - "babel-loader": "^6.1.0", - "babel-plugin-transform-object-assign": "^6.1.18", - "babel-plugin-typecheck": "^3.0.0", - "babel-polyfill": "^6.2.0", - "body-parser": "^1.14.1", - "bootstrap-sass": "^3.3.5", - "bootstrap-sass-loader": "^1.0.9", - "bundle-loader": "~0.5.0", - "classnames": "^2.2.0", - "compression": "^1.6.0", - "cookie-parser": "^1.4.0", + "babel": "^6.3.26", + "babel-plugin-add-module-exports": "^0.1.2", + "babel-plugin-transform-decorators-legacy": "^1.3.4", + "babel-plugin-transform-react-display-name": "^6.4.0", + "babel-plugin-transform-runtime": "^6.4.3", + "babel-plugin-typecheck": "^3.6.1", + "babel-polyfill": "^6.3.14", + "babel-register": "^6.4.3", + "babel-runtime": "^6.3.19", + "body-parser": "^1.14.2", + "bootstrap-loader": "^1.0.7", + "bootstrap-sass": "^3.3.6", + "chromedriver": "^2.21.2", + "compression": "^1.6.1", + "cookie-parser": "^1.4.1", "copy-to-clipboard": "^1.1.1", - "cors": "^2.7.1", - "css-loader": "^0.18.0", - "csurf": "^1.8.3", "debug": "^2.2.0", - "dotenv": "^1.2.0", - "errorhandler": "^1.4.2", - "express": "^4.13.3", - "express-state": "^1.3.0", - "express-useragent": "^0.2.0", - "extract-text-webpack-plugin": "^0.8.0", - "fluxible": "^1.0.3", - "fluxible-addons-react": "^0.2.0", - "fluxible-plugin-fetchr": "^0.3.8", - "fluxible-router": "^0.3.0", - "html-webpack-plugin": "^1.6.2", - "immutable": "^3.7.5", - "imports-loader": "^0.6.3", - "jquery": "^2.1.4", - "json-loader": "~0.5.1", - "jsx-loader": "^0.13.2", - "loopstacks": "0.0.3", + "express": "^4.13.4", + "express-session": "^1.13.0", + "express-useragent": "^0.2.4", + "file-loader": "^0.8.4", + "history": "^2.0.0", + "hoist-non-react-statics": "^1.0.5", + "http-proxy": "^1.13.1", + "humps": "^1.0.0", + "imports-loader": "^0.6.5", + "jquery": "^2.2.0", + "less": "^2.6.0", + "less-loader": "^2.2.2", + "lru-memoize": "^1.0.0", + "map-props": "^1.0.0", + "moment": "^2.11.2", "morgan": "^1.6.1", - "node-sass": "^3.4.2", - "phantomjs": "^1.9.18", - "promise": "^7.0.4", - "proxy-middleware": "^0.14.0", - "raw-loader": "^0.5.1", - "react": "^0.14.3", - "react-cookie": "^0.3.4", - "react-dom": "^0.14.3", - "react-google-analytics": "^0.2.0", - "react-i13n": "^1.0.0", - "react-i13n-ga": "^0.1.3", - "react-paginate": "^0.4.1", - "sass-loader": "^3.1.1", + "multireducer": "^2.0.0", + "nightwatch": "^0.8.16", + "normalizr": "^2.0.0", + "piping": "^0.3.0", + "pretty-error": "^2.0.0", + "qs": "^6.1.0", + "react": "^0.14.7", + "react-bootstrap": "^0.28.2", + "react-document-meta": "^2.0.2", + "react-dom": "^0.14.7", + "react-inline-css": "^2.1.0", + "react-paginate": "^0.5.2", + "react-redux": "^4.4.0", + "react-router": "^2.0.0", + "react-router-bootstrap": "^0.20.1", + "react-router-redux": "^3.0.0", + "react-scroll": "git@github.com:mmahalwy/react-scroll.git#master", + "redux": "^3.3.1", + "redux-async-connect": "^0.1.12", + "redux-form": "^4.1.6", + "resolve-url-loader": "^1.4.3", + "scroll-behavior": "^0.3.1", + "selenium-server": "^2.50.1", "serialize-javascript": "^1.1.2", "serve-favicon": "^2.3.0", - "style-loader": "~0.12.2", - "superagent": "^1.4.0", - "superagent-promise": "^1.0.3", - "url": "^0.11.0", - "url-loader": "~0.5.5", - "webpack": "^1.12.6", - "webpack-isomorphic-tools": "^2.2.15", - "winston": "^2.1.1" + "superagent": "^1.7.2", + "url-loader": "^0.5.6", + "webpack-isomorphic-tools": "^2.2.26" }, "devDependencies": { - "autoprefixer-loader": "^3.1.0", - "babel-core": "^6.2.0", - "babel-eslint": "^4.1.5", - "babel-loader": "^6.2.0", - "babel-plugin-react-transform": "^1.1.1", - "babel-plugin-typecheck": "^3.0.0", - "babel-preset-es2015": "^6.1.18", - "babel-preset-react": "^6.1.18", - "babel-preset-stage-0": "^6.1.18", - "babel-runtime": "^6.2.0", - "bundle-loader": "^0.5.4", - "chai": "^3.4.1", - "chromedriver": "^2.20.0", - "clean-webpack-plugin": "^0.1.4", - "del": "^2.1.0", - "eslint": "^1.9.0", - "eslint-loader": "^1.1.1", - "eslint-plugin-react": "^3.9.0", - "extract-text-webpack-plugin": "^0.8.0", - "file-loader": "^0.8.4", - "gulp": "^3.9.0", - "gulp-nodemon": "^2.0.4", - "gulp-util": "^3.0.7", - "imports-loader": "^0.6.5", - "jscs": "^2.5.1", + "autoprefixer-loader": "^3.2.0", + "babel-core": "^6.4.5", + "babel-eslint": "5.0.0-beta10", + "babel-loader": "^6.2.2", + "babel-plugin-react-transform": "^2.0.0", + "babel-plugin-rewire": "^0.1.22", + "babel-preset-es2015": "^6.3.13", + "babel-preset-react": "^6.3.13", + "babel-preset-react-hmre": "^1.1.0", + "babel-preset-stage-0": "^6.3.13", + "babel-runtime": "^6.3.19", + "better-npm-run": "0.0.6", + "bootstrap-sass": "^3.3.5", + "bootstrap-sass-loader": "^1.0.10", + "chai": "^3.5.0", + "clean-webpack-plugin": "^0.1.8", + "concurrently": "^1.0.0", + "css-loader": "^0.23.1", + "eslint": "^1.10.3", + "eslint-config-airbnb": "^5.0.0", + "eslint-loader": "^1.2.1", + "eslint-plugin-import": "^0.12.1", + "eslint-plugin-react": "^3.16.1", + "extract-text-webpack-plugin": "^1.0.1", + "font-awesome": "^4.4.0", + "font-awesome-webpack": "0.0.4", + "isparta": "^4.0.0", + "isparta-loader": "^2.0.0", + "istanbul-instrumenter-loader": "^0.1.3", "json-loader": "^0.5.3", - "karma": "^0.13.15", + "karma": "^0.13.19", "karma-chai": "^0.1.0", "karma-chai-sinon": "^0.1.5", "karma-chrome-launcher": "^0.2.1", - "karma-junit-reporter": "^0.3.8", - "karma-mocha": "^0.2.1", - "karma-phantomjs-launcher": "^0.2.1", - "karma-script-launcher": "^0.1.0", - "karma-sinon": "^1.0.4", - "karma-sourcemap-loader": "^0.3.6", + "karma-cli": "^0.1.2", + "karma-coverage": "^0.5.3", + "karma-mocha": "^0.2.0", + "karma-mocha-reporter": "^1.1.5", + "karma-phantomjs-launcher": "^1.0.0", + "karma-sourcemap-loader": "^0.3.7", "karma-webpack": "^1.7.0", - "mocha": "^2.3.4", - "nightwatch": "^0.8.6", + "mocha": "^2.4.5", + "node-sass": "^3.4.2", "nodemon": "^1.8.1", - "path": "^0.11.14", + "phantomjs": "^2.1.3", "phantomjs-polyfill": "0.0.1", - "raw-loader": "^0.5.1", - "react-transform-catch-errors": "^1.0.0", - "react-transform-hmr": "^1.0.1", - "redbox-react": "^1.2.0", - "selenium-server": "^2.48.2", - "sinon": "^1.17.2", + "react-a11y": "^0.2.6", + "react-addons-perf": "^0.14.7", + "react-addons-test-utils": "^0.14.7", + "redux-devtools": "^3.1.0", + "redux-devtools-dock-monitor": "^1.0.1", + "redux-devtools-log-monitor": "^1.0.4", + "sass-loader": "^3.0.0", + "sinon": "^1.17.3", "sinon-chai": "^2.8.0", - "style-loader": "^0.12.4", - "url-loader": "^0.5.6", - "webpack-dev-server": "^1.12.1" + "strip-loader": "^0.1.2", + "style-loader": "^0.13.0", + "webpack": "^1.12.13", + "webpack-dev-middleware": "^1.5.1", + "webpack-hot-middleware": "^2.6.4" }, - "pre-commit": [ - "lint", - "validate", - "test" - ] + "engines": { + "node": "5.1.0" + } } diff --git a/production.env b/production.env deleted file mode 100644 index 3258a84b0..000000000 --- a/production.env +++ /dev/null @@ -1,3 +0,0 @@ -PORT=8000 -CURRENT_URL=http://quran.com/api/ -API_URL=http://api.quran.com:3000 diff --git a/server.babel.js b/server.babel.js new file mode 100755 index 000000000..c391a927f --- /dev/null +++ b/server.babel.js @@ -0,0 +1,15 @@ +// enable runtime transpilation to use ES6/7 in node + +var fs = require('fs'); + +var babelrc = fs.readFileSync('./.babelrc'); +var config; + +try { + config = JSON.parse(babelrc); +} catch (err) { + console.error('==> ERROR: Error parsing your .babelrc.'); + console.error(err); +} + +require('babel-register')(config); diff --git a/server.js b/server.js deleted file mode 100644 index 89ee76360..000000000 --- a/server.js +++ /dev/null @@ -1,91 +0,0 @@ -import express from 'express'; -import expressConfig from 'server/config/express'; -const server = express(); -expressConfig(server); - -import serialize from 'serialize-javascript'; -import {navigateAction} from 'fluxible-router'; -import createElementWithContext from 'fluxible-addons-react/createElementWithContext'; -import React from 'react'; -import ReactDOM from 'react-dom/server'; - -import debugLib from 'debug'; -const debug = debugLib('quran'); - -import app from './app'; -import Settings from 'constants/Settings'; -import * as ExpressActions from 'actions/ExpressActions'; -import * as Fonts from 'utils/FontFace'; - -import NotFound from 'components/NotFound'; -import Errored from 'components/Error'; -import ErroredMessage from 'components/ErrorMessage'; -import HtmlComponent from 'components/Html'; -const htmlComponent = React.createFactory(HtmlComponent); - -// Use varnish for the static routes, which will cache too - -server.use((req, res, next) => { - if (process.env.NODE_ENV === 'development') { - webpack_isomorphic_tools.refresh() - } - - let context = app.createContext(); - - context.getActionContext().executeAction(ExpressActions.userAgent, req.useragent); - context.getActionContext().executeAction(ExpressActions.cookies, req.cookies); - - debug('Executing navigate action'); - context.getActionContext().executeAction(navigateAction, { - url: req.url - }, (err) => { - - if (err) { - if (err.statusCode && err.statusCode === 404) { - res.write('' + ReactDOM.renderToStaticMarkup(React.createElement(NotFound))); - res.end(); - } - else if (err.message) { - res.write('' + ReactDOM.renderToStaticMarkup(React.createElement(ErroredMessage, {error: err}))); - res.end(); - } - else { - res.write('' + ReactDOM.renderToStaticMarkup(React.createElement(Errored))); - res.end(); - } - return; - } - - debug('Exposing context state'); - const exposed = 'window.App=' + serialize(app.dehydrate(context)) + ';'; - - debug('Rendering Application component into html'); - const html = ReactDOM.renderToStaticMarkup(htmlComponent({ - context: context.getComponentContext(), - state: exposed, - assets: webpack_isomorphic_tools.assets(), - markup: ReactDOM.renderToString(createElementWithContext(context)), - fontFaces: Fonts.createFontFacesArray(context.getComponentContext().getStore('AyahsStore').getAyahs()) - })); - - debug('Sending markup'); - res.type('html'); - res.setHeader('Cache-Control', 'public, max-age=31557600'); - res.status(200).send('' + html); - res.end(); - }); -}); - -const port = process.env.PORT || 8000; - -export default function serve(cb) { - return server.listen(port, function() { - console.info(` - ==> 🌎 ENV=${process.env.NODE_ENV} - ==> ✅ Server is listening at http://localhost:${port} - ==> 🎯 API at ${Settings.api} - `); - - cb && cb(this); - }); -}; diff --git a/server/api/surahs/controller.js b/server/api/surahs/controller.js deleted file mode 100644 index a4593a34d..000000000 --- a/server/api/surahs/controller.js +++ /dev/null @@ -1,7 +0,0 @@ -import request from 'request'; - -export function index(req, res) { - request('http://api.quran.com:3000/surahs', (error, response, body) => { - res.status(200).json(JSON.parse(body)); - }); -} diff --git a/server/api/surahs/index.js b/server/api/surahs/index.js deleted file mode 100644 index 560cce975..000000000 --- a/server/api/surahs/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import express from 'express'; -const router = express.Router(); - -const controller = require('./controller'); - -router.get('/', controller.index) - - -export default router; diff --git a/server/config/environment/development.js b/server/config/environment/development.js deleted file mode 100644 index efba7fa69..000000000 --- a/server/config/environment/development.js +++ /dev/null @@ -1,2 +0,0 @@ -export default { -} diff --git a/server/config/environment/index.js b/server/config/environment/index.js deleted file mode 100644 index b1d1358dd..000000000 --- a/server/config/environment/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import development from './development'; - -export default development; diff --git a/server/config/express.js b/server/config/express.js deleted file mode 100644 index 6aefc69b4..000000000 --- a/server/config/express.js +++ /dev/null @@ -1,34 +0,0 @@ -import express from 'express'; -import path from 'path'; -import compression from 'compression'; -import bodyParser from 'body-parser'; -import logger from 'morgan'; -import favicon from 'serve-favicon'; -import errorhandler from 'errorhandler'; -import useragent from 'express-useragent'; -import cookieParser from 'cookie-parser'; -import cors from 'cors'; - -import config from './environment'; -import routes from '../routes'; - -export default function(server) { - server.use(compression()); - server.use(bodyParser.json()); - server.use(logger('dev')); - server.use(useragent.express()); - server.use(cookieParser()); - server.use(cors()); - - // Static content - server.use(favicon(path.join((process.env.PWD || process.env.pm_cwd) , '/static/images/favicon.ico'))); - server.use('/public', express.static(path.join((process.env.PWD || process.env.pm_cwd), '/build'))); - server.use('/build', express.static(path.join((process.env.PWD || process.env.pm_cwd), '/build'))); - - server.set('state namespace', 'App'); - server.set('view cache', true); - - routes(server); - - server.use(errorhandler()); // Must be last! -} diff --git a/server/errors.js b/server/errors.js deleted file mode 100644 index b7db80b13..000000000 --- a/server/errors.js +++ /dev/null @@ -1,14 +0,0 @@ -module.exports[404] = function pageNotFound(req, res) { - var viewFilePath = '404'; - var statusCode = 404; - var result = { - status: statusCode - }; - - res.status(result.status); - res.render(viewFilePath, function (err) { - if (err) { return res.json(result, result.status); } - - res.render(viewFilePath); - }); -}; diff --git a/server/index.js b/server/index.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/routes.js b/server/routes.js deleted file mode 100644 index 75e3f59b8..000000000 --- a/server/routes.js +++ /dev/null @@ -1,31 +0,0 @@ -var Promise = require('promise'); -var request = require('superagent-promise')(require('superagent'), Promise); - -import ls from 'loopstacks'; -import Settings from 'constants/Settings'; -import debug from 'utils/Debug'; - -export default function(server) { - server.use(ls({ - app: server, - path: '/loopstacks' - })); - - server.get(/^\/(images|fonts)\/.*/, function(req, res) { - res.redirect(301, '//quran-1f14.kxcdn.com' + req.path); - }); - - server.all('/api/*', function(req, res) { - debug('api:API', `Request: ${req.url}`); - - request.get(`${Settings.api}/${req.url.substr(4)}`) - .end() - .then(function(response) { - debug('api:API', `Respond: ${req.url}`); - return res.status(200).send(response.body); - }, function() { - console.info('Errored API at: ' + req.url); - return res.status(500).send(response); - }); - }); -}; diff --git a/src/client.js b/src/client.js new file mode 100755 index 000000000..0ca44ddbc --- /dev/null +++ b/src/client.js @@ -0,0 +1,77 @@ +/** + * THIS IS THE ENTRY POINT FOR THE CLIENT, JUST LIKE server.js IS THE ENTRY POINT FOR THE SERVER. + */ +import 'babel-polyfill'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import useScroll from 'scroll-behavior/lib/useStandardScroll'; +import createStore from './redux/create'; +import ApiClient from './helpers/ApiClient'; +import debug from 'debug'; +import jquery from 'jquery'; +import { Provider } from 'react-redux'; +import { Router, browserHistory } from 'react-router'; +import { ReduxAsyncConnect } from 'redux-async-connect'; + +import getRoutes from './routes'; + +jquery(document.body).tooltip({ + selector: '[data-toggle="tooltip"]', + animation: false +}); + +const client = new ApiClient(); + +// Three different types of scroll behavior available. +// Documented here: https://github.com/rackt/scroll-behavior +const scrollHistory = useScroll(() => browserHistory)(); + +window.quranDebug = debug; + +const dest = document.getElementById('content'); +const store = createStore(getRoutes, scrollHistory, client, window.__data); +window.__store = store; + +const component = ( + } // eslint-disable-line react/jsx-no-bind + history={scrollHistory} + > + {getRoutes(store)} + +); + +ReactDOM.render( + + {component} + , + dest +); + +if (process.env.NODE_ENV !== 'production') { + window.Perf = require('react-addons-perf'); + window.React = React; // enable debugger + + if (!dest || + !dest.firstChild || + !dest.firstChild.attributes || + !dest.firstChild.attributes['data-react-checksum']) { + console.error( + `Server-side React render was discarded. Make sure that your + initial render does not contain any client-side code.` + ); + } +} + +if (__DEVTOOLS__ && !window.devToolsExtension) { + const DevTools = require('./containers/DevTools'); + ReactDOM.render( + +
+ {component} + +
+
, + dest + ); +} diff --git a/src/components/Audioplayer/Track/Tracker/index.js b/src/components/Audioplayer/Track/Tracker/index.js new file mode 100644 index 000000000..55853b7ec --- /dev/null +++ b/src/components/Audioplayer/Track/Tracker/index.js @@ -0,0 +1,26 @@ +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; + +const style = require('./style.scss'); + +export default class Tracker extends Component { + componentWillReceiveProps(nextProps) { + const element = ReactDOM.findDOMNode(this); + + element.style.left = `${( + nextProps.progress * + element.parentElement.getBoundingClientRect().width / + 100 + )}px`; + + element.parentElement.style.background = ( + `linear-gradient(to right, #2CA4AB 0%,#2CA4AB ${nextProps.progress}%,#635e49 ${nextProps.progress}%,#635e49 100%)` + ); + } + + render() { + return ( +
+ ); + } +} diff --git a/src/components/Audioplayer/Track/Tracker/style.scss b/src/components/Audioplayer/Track/Tracker/style.scss new file mode 100644 index 000000000..0eafe0046 --- /dev/null +++ b/src/components/Audioplayer/Track/Tracker/style.scss @@ -0,0 +1,23 @@ +@import '../../../../theme/variables'; + +:local .tracker{ + height: 100%; // This is optional if you'd like your tracker to fit nicely within the track. We don't right now. + width: $tracker-width; + background-color: darken($brand-primary, 20%); + display: inline-block; + position: absolute; + user-select: none; + top: 50%; + transform: translate(-50%, -50%) scale(1.2); + transition: transform 0.1s cubic-bezier(0,1.15,.76,3.0); // This is if your tracker is a circle and you want that bounce effect. + + &:hover{ + cursor: -webkit-grab; cursor: -moz-grab; + } + + &.active{ + transform: translate(-50%, -50%); + box-shadow: 0px 1px 1px 1px rgba(5,5,5,0); + cursor: -webkit-grabbing; cursor: -moz-grabbing; + } +} diff --git a/src/components/Audioplayer/Track/index.js b/src/components/Audioplayer/Track/index.js new file mode 100644 index 000000000..306d3fce5 --- /dev/null +++ b/src/components/Audioplayer/Track/index.js @@ -0,0 +1,137 @@ +import React, { Component, PropTypes } from 'react'; +import ReactDOM from 'react-dom'; + +import Tracker from './Tracker'; +import debug from 'helpers/debug'; + +const style = require('./style.scss'); + +export default class Track extends Component { + static propTypes = { + file: PropTypes.object.isRequired, + isPlaying: PropTypes.bool.isRequired, + shouldRepeat: PropTypes.bool.isRequired, + onPlay: PropTypes.func.isRequired, + onPause: PropTypes.func.isRequired, + onEnd: PropTypes.func.isRequired + }; + + constructor() { + super(...arguments); + + this.state = { + progress: 0, + currentTime: 0 + }; + } + + componentDidMount() { + this.onFileLoad(this.props.file); + } + + shouldComponentUpdate(nextProps, nextState) { + return [ + this.props.file.src !== nextProps.file.src, + this.props.isPlaying !== nextProps.isPlaying, + this.props.shouldRepeat !== nextProps.shouldRepeat, + this.state.progress !== nextState.progress, + this.state.currentTime !== nextState.currentTime + ].some(test => test); + } + + componentWillUpdate(nextProps) { + if (this.props.file.src !== nextProps.file.src) { + this.props.file.pause(); + + this.setState({ + progress: 0 + }); + + this.onFileLoad(nextProps.file); + } + } + + onFileLoad(file) { + debug('component:Track', `File loaded with src ${file.src}`); + + file.addEventListener('loadeddata', () => { + // Default current time to zero. This will change + file.currentTime = 0; // eslint-disable-line no-param-reassign + + // this.setState({isAudioLoaded: true}); + }); + + file.addEventListener('timeupdate', () => { + const progress = ( + file.currentTime / + file.duration * 100 + ); + + this.setState({ + progress + }); + }, false); + + file.addEventListener('ended', () => { + const { shouldRepeat, onEnd } = this.props; + + if (shouldRepeat) { + file.pause(); + file.currentTime = 0; // eslint-disable-line no-param-reassign + file.play(); + } else { + file.pause(); + onEnd(); + } + }, false); + + file.addEventListener('play', () => { + const { progress } = this.state; + + const currentTime = ( + progress / 100 * file.duration + ); + + this.setState({ + currentTime + }); + }, false); + } + + onTrackerMove(event) { + const { file } = this.props; + + const fraction = ( + event.nativeEvent.offsetX / + ReactDOM.findDOMNode(this).parentElement.getBoundingClientRect().width + ); + + this.setState({ + progress: fraction * 100, + currentTime: fraction * file.duration + }); + + file.currentTime = ( + fraction * file.duration + ); + } + + render() { + debug('component:Track', 'render'); + + const { progress } = this.state; + const { isPlaying, file } = this.props; + + if (isPlaying) { + file.play(); + } else { + file.pause(); + } + + return ( +
+ +
+ ); + } +} diff --git a/src/components/Audioplayer/Track/style.scss b/src/components/Audioplayer/Track/style.scss new file mode 100644 index 000000000..51bceea1b --- /dev/null +++ b/src/components/Audioplayer/Track/style.scss @@ -0,0 +1,10 @@ +@import '../../../theme/variables.scss'; + +:local .track{ + background: $track-bg; + display: block; + height: 100%; + width: 100%; + user-select: none; + cursor: pointer; +} diff --git a/src/components/Audioplayer/index.js b/src/components/Audioplayer/index.js new file mode 100644 index 000000000..d7a438177 --- /dev/null +++ b/src/components/Audioplayer/index.js @@ -0,0 +1,301 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { Col } from 'react-bootstrap'; + +import { play, pause, repeat, setCurrentFile, buildOnClient } from 'redux/modules/audioplayer'; + +import Track from './Track'; + +import debug from 'helpers/debug'; + +const style = require('./style.scss'); + +@connect( + state => ({ + files: state.audioplayer.files, + currentFile: state.audioplayer.currentFile, + surahId: state.audioplayer.surahId, + isSupported: state.audioplayer.isSupported, + isPlaying: state.audioplayer.isPlaying, + isLoadedOnClient: state.audioplayer.isLoadedOnClient, + shouldRepeat: state.audioplayer.shouldRepeat + }), + (dispatch) => ({ + play: bindActionCreators(play, dispatch), + pause: bindActionCreators(pause, dispatch), + repeat: bindActionCreators(repeat, dispatch), + setCurrentFile: bindActionCreators(setCurrentFile, dispatch), + buildOnClient: bindActionCreators(buildOnClient, dispatch) + }), + (stateProps, dispatchProps, ownProps) => { + if (!stateProps.isSupported) { + return { + ...stateProps, ...dispatchProps, ...ownProps + }; + } + + const files = stateProps.files[stateProps.surahId]; + const ayahIds = files ? Object.keys(files) : []; + + return { + ...stateProps, ...dispatchProps, ...ownProps, + files, + ayahIds + }; + } +) +export default class Audioplayer extends Component { + static propTypes = { + surah: PropTypes.object, + files: PropTypes.object, + currentFile: PropTypes.string, + buildOnClient: PropTypes.func.isRequired, + lazyLoadAyahs: PropTypes.func.isRequired, + isPlaying: PropTypes.bool.isRequired, + isLoadedOnClient: PropTypes.bool.isRequired, + isSupported: PropTypes.bool.isRequired, + shouldRepeat: PropTypes.bool.isRequired, + setCurrentFile: PropTypes.func.isRequired, + play: PropTypes.func.isRequired, + pause: PropTypes.func.isRequired, + repeat: PropTypes.func.isRequired, + ayahIds: PropTypes.array + }; + + constructor() { + super(...arguments); + + this.state = { + isAudioLoaded: false, + currentAudio: null, + currentAyah: null + }; + } + + componentDidMount() { + const { isLoadedOnClient, buildOnClient, surah } = this.props; // eslint-disable-line no-shadow + + if (!isLoadedOnClient && __CLIENT__) { + return buildOnClient(surah.id); + } + } + + componentWillUnmount() { + this.props.pause(); + // this.props.currentAudio.src = null; + } + + onPreviousAyah() { + const { play, pause, setCurrentFile, isPlaying } = this.props; // eslint-disable-line no-shadow + const previous = this.getPrevious(); + + if (previous) { + const wasPlaying = isPlaying; + + pause(); + + setCurrentFile(previous); + + if (wasPlaying) { + play(); + } + } + } + + onNextAyah() { + const { play, pause, setCurrentFile, isPlaying } = this.props; // eslint-disable-line no-shadow + const wasPlaying = isPlaying; + + pause(); + + setCurrentFile(this.getNext()); + + if (wasPlaying) { + play(); + } + } + + getPrevious() { + const { currentFile, ayahIds } = this.props; + const index = ayahIds.findIndex(id => id === currentFile) - 1; + + return ayahIds[index]; + } + + getNext() { + const { currentFile, ayahIds, lazyLoadAyahs } = this.props; + const index = ayahIds.findIndex(id => id === currentFile) + 1; + + if ((ayahIds.length - 3) <= index) { + lazyLoadAyahs(); + } + + return ayahIds[index]; + } + + startStopPlayer(event) { + const { isPlaying } = this.props; + + event.preventDefault(); + + if (isPlaying) { + return this.pause(); + } + + return this.play(); + } + + pause() { + this.props.pause(); + } + + play() { + this.props.play(); + } + + repeat(event) { + event.preventDefault(); + + this.props.repeat(); + } + + renderLoader() { + return ( +
+
+ + + + + +
+
+ ); + } + + renderPlayStopButtons() { + const { isPlaying } = this.props; + + let icon = ; + + if (isPlaying) { + icon = ; + } + + return ( + + {icon} + + ); + } + + renderPreviousButton() { + const { currentFile, ayahIds } = this.props; + const index = ayahIds.findIndex(id => id === currentFile); + + return ( + + + + ); + } + + renderNextButton() { + return ( + + + + ); + } + + renderRepeatButton() { + const { shouldRepeat } = this.props; + + return ( + + + + + ); + } + + render() { + debug('component:Audioplayer', 'Render'); + + const { + play, // eslint-disable-line no-shadow + pause, // eslint-disable-line no-shadow + files, + currentFile, + isPlaying, + shouldRepeat, + isSupported, + isLoadedOnClient + } = this.props; // eslint-disable-line no-shadow + + if (!isSupported) { + return ( +
  • + Your browser does not support this audio. +
  • + ); + } + + let content = ( +
      + {this.renderLoader()} +
    + ); + + content = ( +
    + + {this.renderPreviousButton()} + + + {this.renderPlayStopButtons()} + + + {this.renderNextButton()} + + + {this.renderRepeatButton()} +
    + ); + + if (!currentFile) { + return ( +
  • + Loading... +
  • + ); + } + + return ( +
  • +
    {currentFile.split(':')[1]}
    + {content} +
    + {isLoadedOnClient ? + : + null + } +
    +
  • + ); + } +} diff --git a/src/components/Audioplayer/style.scss b/src/components/Audioplayer/style.scss new file mode 100644 index 000000000..98fed6c66 --- /dev/null +++ b/src/components/Audioplayer/style.scss @@ -0,0 +1,70 @@ +@import '../../theme/variables.scss'; + +:local .container{ + position:relative; + display: block; + user-select: none; + height: 100%; + padding: 16px 40px 8px; + + @media(min-width: $navbar-collapse-width) { + border-right: 1px solid $beige; + border-left: 1px solid $beige; + } +} + +:local .wrapper{ + width: 100%; + position: absolute; + top: 90%; + left: 0px; + height: 10%; + transition: all 0.5s; + &:hover{ + height: 20%; + top: 80%; + } +} + +:local .options{ + border-radius: 4px; + width: 100%; + display: inline-block; + margin: 0px; + height: 90%; + text-align: center; + + + .buttons{ + cursor: pointer; + padding-right: 1.5%; + color: $olive; + outline: none; + + &.playing{ + color: $brand-primary; + } + i.fa{ + color: inherit; + font-size: 100%; + } + } + .checkbox{ + display: none; + } + .repeat{ + color: $brand-primary; + } +} + +:local .disabled{ + opacity: 0.5; + cursor: not-allowed !important; +} + +:local .verse{ + position: absolute; + left: 20px; + color: $olive; + opacity: 0.5; +} diff --git a/src/components/Ayah/index.js b/src/components/Ayah/index.js new file mode 100644 index 000000000..e6362c7b9 --- /dev/null +++ b/src/components/Ayah/index.js @@ -0,0 +1,164 @@ +/* eslint-disable */ +import React, { Component, PropTypes } from 'react'; +import copy from 'copy-to-clipboard'; +import { Col } from 'react-bootstrap'; +import { Element } from 'react-scroll'; + +import debug from 'helpers/debug'; + +const style = require('./style.scss'); + +export default class Ayah extends Component { + static propTypes = { + ayah: PropTypes.object.isRequired + }; + + shouldComponentUpdate(nextProps) { + return this.props.ayah !== nextProps.ayah; + } + + onAudioChange(ayah, event) { + event.preventDefault(); + + // this.setState({ + // open: false + // }); + // this.context.executeAction(AudioplayerActions.changeAyah, { + // ayah: ayah, + // shouldPlay: true + // }); + } + + onCopy(text) { + return copy(text); + } + + translations() { + const { ayah } = this.props; + + if (!ayah.content && ayah.match) { + return ayah.match.map((content, index) => { + const arabic = new RegExp(/[\u0600-\u06FF]/); + const character = content.text; + const flag = arabic.test(character); + + if (flag) { + return ( +
    +

    {content.name}

    +

    + +

    +
    + ); + } + + return ( +
    +

    + {content.name} + + Copy + +

    +

    + +

    +
    + ); + }); + } + + if (!ayah.content) { + return []; + } + + return ayah.content.map((content, index) => { + return ( +
    +

    + {content.name} +

    + + Copy + +

    + {content.text} +

    +
    + ); + }); + } + + text() { + const { ayah } = this.props; + + if (!ayah.quran[0].char) { + return false; + } + + const text = ayah.quran.map(word => { + if (word.word.translation) { + return ( + + ); + } + + return ( + + ); + }); + + return ( +

    + {text} +

    + ); + } + + controls() { + const { ayah } = this.props; + + return ( + +

    + + {ayah.surahId}:{ayah.ayahNum} + +

    + + Play + + + Copy + + + ); + } + + render() { + const { ayah } = this.props; + + debug(`Component:Ayah`, `Render ${ayah.ayahNum}`); + + return ( + + {this.controls()} + + {this.text()} + {this.translations()} + + + ); + } +} diff --git a/src/components/Ayah/style.scss b/src/components/Ayah/style.scss new file mode 100644 index 000000000..41c59c208 --- /dev/null +++ b/src/components/Ayah/style.scss @@ -0,0 +1,159 @@ +@import '../../theme/variables.scss'; + +:local .ayah{ + padding: 2.5% 0%; + border-bottom: 1px solid rgba($text-muted, 0.5); + + .text-info{ + color: $brand-info; + &:hover{ + color: $brand-primary; + } + } + + &:hover{ + .toggle-copy{ + visibility: visible; + } + } + + .toggle-copy{ + visibility: hidden; + } + + :global(span){ + @extend .montserrat; + } + + .translation{ + @extend .times-new; + + h4{ + color: $light-green; + margin-bottom: 5px; + @extend .montserrat; + text-transform: uppercase; + font-size: 14px; + font-weight: 400; + display: inline-block; + margin-right: 10px; + } + small{ + @extend .times-new; + } + h2{ + margin-top: 5px; + margin-bottom: 25px; + } + } + + .arabicTranslation{ + text-align: right; + @extend .times-new; + h4{ + color: $light-green; + margin-bottom: 5px; + @extend .montserrat; + text-transform: uppercase; + font-size: 14px; + font-weight: 400; + } + small{ + @extend .times-new; + } + h2{ + margin-top: 5px; + margin-bottom: 25px; + } + } + .controls{ + button, & > a{ + color: #d1d0d0 !important; + padding: 0; + margin-bottom: 15px; + display: block; + text-decoration: none; + font-size: 12px; + + &:hover{ + cursor: pointer; + } + } + .label{ + padding: .65em 1.1em; + border-radius: 0px; + display: inline-block; + margin-bottom: 15px; + color: darken($text-muted, 30%); + font-weight: 300; + font-size: 0.75em; + background-color: $label-default-bg; + + &:hover{ + opacity: 0.7; + } + } + + @media (max-width: $screen-xs-max) { + h4, + a{ + display: inline-block; + margin: 0px 10px; + } + } + } +} + +:local .font{ + white-space: pre-line; + color: #000; + width: 100%; + overflow-wrap: break-word; + line-height: 150%; + word-break: break-all; + text-align: right; + float: left; + + b{ + float: right; + } + + b, a{ + font-weight: 100; + margin: 0px 2px; + white-space: pre; + color: #000; + &:hover{ + color: $brand-primary; + cursor: pointer; + } + } + + @media (max-width: $screen-xs-max) { + font-size: 300%; + line-height: 130%; + } +} + +:local .translation{ + @extend .montserrat; + + h4{ + color: $light-green; + margin-bottom: 5px; + } + small{ + @extend .montserrat; + } + h2{ + margin-top: 5px; + margin-bottom: 25px; + } +} + +.line{ + line-height: 150%; + display: block; + width: 100%; + margin: 0px auto; +} diff --git a/src/components/CoreLoader/index.js b/src/components/CoreLoader/index.js new file mode 100644 index 000000000..b9f7004dd --- /dev/null +++ b/src/components/CoreLoader/index.js @@ -0,0 +1,29 @@ +/* eslint-disable */ +import React, { Component, PropTypes } from 'react'; +const style = require('./style.scss'); + +export default class CoreLoader extends Component { + static propTypes = { + children: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.object + ]), + minHeight: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.object + ]) + }; + + render() { + const { children, minHeight } = this.props; + + return ( +
    +
    + {children} +
    + ); + } +} diff --git a/src/components/CoreLoader/style.scss b/src/components/CoreLoader/style.scss new file mode 100644 index 000000000..9c0b68d40 --- /dev/null +++ b/src/components/CoreLoader/style.scss @@ -0,0 +1,8 @@ +:local .container{ + text-align: center; +} +:local .loader { + background: transparent url('../../../static/images/loading.gif') no-repeat center center; + background-size: contain; + min-height: 70px; +} diff --git a/src/components/FontStyles/index.js b/src/components/FontStyles/index.js new file mode 100644 index 000000000..474752e60 --- /dev/null +++ b/src/components/FontStyles/index.js @@ -0,0 +1,23 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; + +const bismillah = `@font-face {font-family: 'bismillah'; + src: url('http://quran-1f14.kxcdn.com/fonts/ttf/bismillah.ttf') format('truetype')} + .bismillah{font-family: 'bismillah'; font-size: 36px !important; color: #000; padding-top: 25px;}`; + +@connect( + state => ({ + fontFaces: [...state.ayahs.fontFaces, ...state.searchResults.fontFaces, bismillah] + }) +) +export default class FontStyles extends Component { + static propTypes = { + fontFaces: PropTypes.array + }; + + render() { + return ( +