diff --git a/src/components/Ayah/index.js b/src/components/Ayah/index.js index 980cd1ccd..8d8fde7cb 100644 --- a/src/components/Ayah/index.js +++ b/src/components/Ayah/index.js @@ -20,13 +20,15 @@ export default class Ayah extends Component { static propTypes = { isSearched: PropTypes.bool, ayah: PropTypes.object.isRequired, + bookmarked: PropTypes.bool.isRequired, + bookmarkActions: PropTypes.object, match: PropTypes.array, isSearch: PropTypes.bool, isPlaying: PropTypes.bool, + isAuthenticated: PropTypes.bool, tooltip: PropTypes.string, currentWord: PropTypes.any, // gets passed in an integer, null by default - onWordClick: PropTypes.func, - actions: PropTypes.object + audioActions: PropTypes.object.isRequired }; static defaultProps = { @@ -37,6 +39,7 @@ export default class Ayah extends Component { shouldComponentUpdate(nextProps) { const conditions = [ this.props.ayah !== nextProps.ayah, + this.props.bookmarked !== nextProps.bookmarked, this.props.tooltip !== nextProps.tooltip, this.props.currentWord !== nextProps.currentWord ]; @@ -49,8 +52,8 @@ export default class Ayah extends Component { } handlePlay(ayah) { - const {isPlaying, actions} = this.props; - const {pause, setAyah, play} = actions; + const { isPlaying, audioActions } = this.props; + const { pause, setAyah, play } = audioActions; if (isPlaying) { pause(); @@ -85,7 +88,7 @@ export default class Ayah extends Component { } renderText() { - const { ayah, onWordClick, tooltip } = this.props; + const { ayah, audioActions: { setCurrentWord }, tooltip } = this.props; if (!ayah.words[0].code) { return false; @@ -93,7 +96,7 @@ export default class Ayah extends Component { // position is important as it will differentiate between words and symbols, see 2:25:13 let position = -1; - let text = ayah.words.map((word, index) => { + const text = ayah.words.map((word, index) => { let id = null; const isLast = ayah.words.length === index + 1; const className = `${word.className} ${word.highlight ? word.highlight : ''}`; @@ -106,13 +109,13 @@ export default class Ayah extends Component { } if (word.translation || word.transliteration) { - let tooltipContent = word[tooltip]; + const tooltipContent = word[tooltip]; return ( onWordClick(event.target.dataset.key)} + onClick={(event) => setCurrentWord(event.target.dataset.key)} data-key={`${word.ayahKey}:${position}`} className={`${className} ${styles.Tooltip}`} aria-label={tooltipContent} @@ -125,7 +128,7 @@ export default class Ayah extends Component { return ( onWordClick(event.target.dataset.key)} + onClick={(event) => setCurrentWord(event.target.dataset.key)} data-key={`${word.ayahKey}:${position}`} className={`${className} ${isLast && styles.Tooltip} pointer`} key={word.code} @@ -177,6 +180,32 @@ export default class Ayah extends Component { return false; } + renderBookmark() { + const { ayah, bookmarked, isAuthenticated, bookmarkActions } = this.props; + + if (!isAuthenticated) return false; + + if (bookmarked) { + return ( + bookmarkActions.removeBookmark(ayah.ayahKey)} + className="text-muted" + > + Bookmarked + + ); + } + + return ( + bookmarkActions.addBookmark(ayah.ayahKey)} + className="text-muted" + > + Bookmark + + ); + } + renderAyahBadge() { const { isSearched } = this.props; const content = ( @@ -213,6 +242,7 @@ export default class Ayah extends Component { {this.renderAyahBadge()} {this.renderPlayLink()} {this.renderCopyLink()} + {this.renderBookmark()} ); } diff --git a/src/containers/App/connect.js b/src/containers/App/connect.js new file mode 100644 index 000000000..f88c80141 --- /dev/null +++ b/src/containers/App/connect.js @@ -0,0 +1,12 @@ +import { isLoaded as isAuthLoaded } from 'redux/actions/auth'; +import { load as loadBookmarks } from 'redux/actions/bookmarks'; + +export const authConnect = ({ store: { getState, dispatch } }) => { + const promises = []; + + if (isAuthLoaded(getState())) { + promises.push(dispatch(loadBookmarks())); + } + + return Promise.all(promises); +}; diff --git a/src/containers/App/index.js b/src/containers/App/index.js index 1d0e752cb..3753570a0 100644 --- a/src/containers/App/index.js +++ b/src/containers/App/index.js @@ -2,6 +2,7 @@ import React, { Component, PropTypes } from 'react'; import { metrics } from 'react-metrics'; import { connect } from 'react-redux'; +import { asyncConnect } from 'redux-connect'; import Link from 'react-router/lib/Link'; import Helmet from 'react-helmet'; @@ -12,6 +13,7 @@ import Col from 'react-bootstrap/lib/Col'; import debug from '../../helpers/debug'; import config from '../../config'; import metricsConfig from '../../helpers/metrics'; +import { authConnect } from './connect'; import FontStyles from 'components/FontStyles'; @@ -108,4 +110,6 @@ class App extends Component { const metricsApp = metrics(metricsConfig)(App); -export default connect(state => ({surahs: state.surahs.entities }))(metricsApp); +const AsyncApp = asyncConnect([{ promise: authConnect }])(metricsApp); + +export default connect(state => ({surahs: state.surahs.entities }))(AsyncApp); diff --git a/src/containers/Profile/index.js b/src/containers/Profile/index.js index 54247368b..22859f15f 100644 --- a/src/containers/Profile/index.js +++ b/src/containers/Profile/index.js @@ -1,41 +1,72 @@ import React, { Component, PropTypes } from 'react'; import Helmet from 'react-helmet'; import { connect } from 'react-redux'; +import { Link } from 'react-router'; + import Grid from 'react-bootstrap/lib/Grid'; import Row from 'react-bootstrap/lib/Row'; import Col from 'react-bootstrap/lib/Col'; import Image from 'react-bootstrap/lib/Image'; +import Tabs from 'react-bootstrap/lib/Tabs'; +import Tab from 'react-bootstrap/lib/Tab'; import QuranNav from 'components/QuranNav'; import userType from 'types/userType'; const styles = require('./style.scss'); -export const Profile = ({ user }) => ( -
- - -
- - - - -

- {user.name} -

- -
-
-
-); +class Profile extends Component { + static propTypes = { + user: PropTypes.shape(userType), + bookmarks: PropTypes.object.isRequired + }; + render() { + const { user, bookmarks } = this.props; -Profile.propTypes = { - user: PropTypes.shape(userType) -}; + return ( +
+ + +
+ + + + +

+ {user.name} +

+ +
+ + + + +
    + { + Object.values(bookmarks).map(bookmark => ( + + {bookmark.ayahKey} + + )) + } +
+
+ + Notes... + +
+ +
+
+
+ ); + } +} export default connect( state => ({ - user: state.auth.user + user: state.auth.user, + bookmarks: state.bookmarks.entities }) )(Profile); diff --git a/src/containers/Profile/style.scss b/src/containers/Profile/style.scss index 35cbded3f..2bbe4f7a8 100644 --- a/src/containers/Profile/style.scss +++ b/src/containers/Profile/style.scss @@ -11,3 +11,42 @@ display: block; height: 10rem; } + +.tabs{ + :global(.nav-pills){ + border-bottom: 1px solid $brand-primary; + + :global(li){ + :global(a){ + background: #fff; + color: $text-color; + font-size: 1.2rem; + + &:hover{ + background: #f7f7f7; + color: $brand-primary; + } + + &:active, &:focus, &:visited{ + color: $brand-primary; + background: #fff; + outline: none; + } + } + } + + :global(li.active){ + border-bottom: 2px solid $brand-primary; + + + :global(a){ + color: $brand-primary; + + &:hover{ + background: transparent; + color: $brand-primary; + } + } + } + } +} diff --git a/src/containers/Surah/index.js b/src/containers/Surah/index.js index e754816d1..027d91dd8 100644 --- a/src/containers/Surah/index.js +++ b/src/containers/Surah/index.js @@ -40,6 +40,7 @@ import { surahsConnect, ayahsConnect } from './connect'; import * as AudioActions from '../../redux/actions/audioplayer.js'; import * as AyahActions from '../../redux/actions/ayahs.js'; +import * as BookmarkActions from '../../redux/actions/bookmarks.js'; import * as OptionsActions from '../../redux/actions/options.js'; const style = require('./style.scss'); @@ -55,8 +56,10 @@ class Surah extends Component { ayahIds: PropTypes.any, currentWord: PropTypes.string, surahs: PropTypes.object.isRequired, + bookmarks: PropTypes.object.isRequired, isLoading: PropTypes.bool.isRequired, isLoaded: PropTypes.bool.isRequired, + isAuthenticated: PropTypes.bool.isRequired, options: PropTypes.object.isRequired, params: PropTypes.object.isRequired, ayahs: PropTypes.object.isRequired, @@ -99,6 +102,7 @@ class Surah extends Component { this.props.isEndOfSurah !== nextProps.isEndOfSurah, this.props.ayahIds.length !== nextProps.ayahIds.length, this.props.surahs !== nextProps.surahs, + this.props.bookmarks !== nextProps.bookmarks, this.props.isLoading !== nextProps.isLoading, this.props.isLoaded !== nextProps.isLoaded, this.props.options !== nextProps.options @@ -286,15 +290,24 @@ class Surah extends Component { } renderAyahs() { - const { ayahs, actions, options, isPlaying } = this.props; // eslint-disable-line no-shadow + const { + ayahs, + actions, + options, + bookmarks, + isPlaying, + isAuthenticated + } = this.props; // eslint-disable-line no-shadow return Object.values(ayahs).map(ayah => ( )); @@ -435,9 +448,11 @@ function mapStateToProps(state, ownProps) { ayahIds, isStarted: state.audioplayer.isStarted, isPlaying: state.audioplayer.isPlaying, + isAuthenticated: state.auth.loaded, currentWord: state.ayahs.currentWord, isEndOfSurah: ayahIds.size === surah.ayat, surahs: state.surahs.entities, + bookmarks: state.bookmarks.entities, isLoading: state.ayahs.loading, isLoaded: state.ayahs.loaded, lines: state.lines.lines, @@ -451,7 +466,8 @@ function mapDispatchToProps(dispatch) { options: bindActionCreators(OptionsActions, dispatch), ayah: bindActionCreators(AyahActions, dispatch), audio: bindActionCreators(AudioActions, dispatch), - push: bindActionCreators(push, dispatch) + push: bindActionCreators(push, dispatch), + bookmark: bindActionCreators(BookmarkActions, dispatch) } }; } diff --git a/src/helpers/ApiClient.js b/src/helpers/ApiClient.js index 84e3e4bc7..934bdaff8 100644 --- a/src/helpers/ApiClient.js +++ b/src/helpers/ApiClient.js @@ -43,7 +43,7 @@ export default class { } if (data) { - request.send(data); + request.send(decamelizeKeys(data)); } request.end((err, { body } = {}) => { diff --git a/src/redux/actions/bookmarks.js b/src/redux/actions/bookmarks.js new file mode 100644 index 000000000..1dcb234c9 --- /dev/null +++ b/src/redux/actions/bookmarks.js @@ -0,0 +1,45 @@ +import { bookmarksSchema } from '../schemas'; +import { arrayOf } from 'normalizr'; +import { + LOAD, + LOAD_SUCCESS, + LOAD_FAILURE, + ADD_BOOKMARK, + ADD_BOOKMARK_SUCCESS, + ADD_BOOKMARK_FAILURE, + REMOVE_BOOKMARK, + REMOVE_BOOKMARK_SUCCESS, + REMOVE_BOOKMARK_FAILURE +} from '../constants/bookmarks'; + +export function isLoaded(globalState) { + return globalState.auth && globalState.auth.user; +} + +export function load() { + return { + types: [LOAD, LOAD_SUCCESS, LOAD_FAILURE], + schema: arrayOf(bookmarksSchema), + promise: (client) => client.get('/onequran/api/v1/bookmarks') + }; +} + +export function addBookmark(ayahKey) { + return { + types: [ADD_BOOKMARK, ADD_BOOKMARK_SUCCESS, ADD_BOOKMARK_FAILURE], + promise: (client) => client.post('/onequran/api/v1/bookmarks', { + data: { + bookmark: { ayahKey } + } + }), + ayahKey + }; +} + +export function removeBookmark(ayahKey) { + return { + types: [REMOVE_BOOKMARK, REMOVE_BOOKMARK_SUCCESS, REMOVE_BOOKMARK_FAILURE], + promise: (client) => client.del(`/onequran/api/v1/bookmarks/${ayahKey}`), + ayahKey + }; +} diff --git a/src/redux/constants/bookmarks.js b/src/redux/constants/bookmarks.js new file mode 100644 index 000000000..04cad939e --- /dev/null +++ b/src/redux/constants/bookmarks.js @@ -0,0 +1,9 @@ +export const LOAD = '@@quran/bookmarks/LOAD'; +export const LOAD_SUCCESS = '@@quran/bookmarks/LOAD_SUCCESS'; +export const LOAD_FAILURE = '@@quran/bookmarks/LOAD_FAILURE'; +export const ADD_BOOKMARK = '@@quran/bookmarks/ADD_BOOKMARK'; +export const ADD_BOOKMARK_SUCCESS = '@@quran/bookmarks/ADD_BOOKMARK_SUCCESS'; +export const ADD_BOOKMARK_FAILURE = '@@quran/bookmarks/ADD_BOOKMARK_FAILURE'; +export const REMOVE_BOOKMARK = '@@quran/bookmarks/REMOVE_BOOKMARK'; +export const REMOVE_BOOKMARK_SUCCESS = '@@quran/bookmarks/REMOVE_BOOKMARK_SUCCESS'; +export const REMOVE_BOOKMARK_FAILURE = '@@quran/bookmarks/REMOVE_BOOKMARK_FAILURE'; diff --git a/src/redux/modules/auth.js b/src/redux/modules/auth.js index 96e575815..4fa4629b3 100644 --- a/src/redux/modules/auth.js +++ b/src/redux/modules/auth.js @@ -18,6 +18,7 @@ export default function reducer(state = initialState, action = {}) { return { ...state, loading: false, + loaded: true, user: action.result.user }; case FACEBOOK_SUCCESS: @@ -26,10 +27,10 @@ export default function reducer(state = initialState, action = {}) { return { ...state, loading: false, + loaded: true, user: action.result.user }; case FACEBOOK_FAILURE: - return state; case LOAD_FAILURE: case LOGOUT_SUCCESS: cookie.remove('accessToken'); @@ -37,6 +38,8 @@ export default function reducer(state = initialState, action = {}) { return { ...state, loggingOut: false, + loaded: false, + loading: false, user: null }; default: diff --git a/src/redux/modules/bookmarks.js b/src/redux/modules/bookmarks.js new file mode 100644 index 000000000..8ee48f840 --- /dev/null +++ b/src/redux/modules/bookmarks.js @@ -0,0 +1,56 @@ +import { + LOAD, + LOAD_SUCCESS, + LOAD_FAILURE, + ADD_BOOKMARK, + ADD_BOOKMARK_SUCCESS, + ADD_BOOKMARK_FAILURE, + REMOVE_BOOKMARK, + REMOVE_BOOKMARK_SUCCESS, + REMOVE_BOOKMARK_FAILURE +} from '../constants/bookmarks'; + +const initialState = { + loaded: false, + entities: {} +}; + +export default function reducer(state = initialState, action = {}) { + switch (action.type) { + case LOAD_SUCCESS: { + const entities = state.entities; + const { bookmarks } = action.result.entities; + return { + ...state, + loaded: true, + errored: false, + entities: { + ...entities, + ...bookmarks + } + }; + } + + case ADD_BOOKMARK_SUCCESS: { + return { + ...state, + entities: { + ...state.entities, + [action.ayahKey]: action.result + } + }; + } + + case REMOVE_BOOKMARK_SUCCESS: { + return { + ...state, + entities: { + ...state.entities, + [action.ayahKey]: null + } + }; + } + default: + return state; + } +} diff --git a/src/redux/modules/reducer.js b/src/redux/modules/reducer.js index 5336d010c..4b1aa7a95 100644 --- a/src/redux/modules/reducer.js +++ b/src/redux/modules/reducer.js @@ -10,11 +10,13 @@ import options from './options'; import searchResults from './searchResults'; import fontFaces from './fontFaces'; import auth from './auth'; +import bookmarks from './bookmarks'; export default combineReducers({ routing: routerReducer, reduxAsyncConnect, auth, + bookmarks, surahs, ayahs, audioplayer, diff --git a/src/redux/schemas.js b/src/redux/schemas.js index ce761f8e5..f46e00cca 100644 --- a/src/redux/schemas.js +++ b/src/redux/schemas.js @@ -2,10 +2,12 @@ import { Schema } from 'normalizr'; const surahsSchema = new Schema('surahs'); const ayahsSchema = new Schema('ayahs', { idAttribute: 'ayahKey' }); +const bookmarksSchema = new Schema('bookmarks', { idAttribute: 'ayahKey' }); const schemas = { surahsSchema, - ayahsSchema + ayahsSchema, + bookmarksSchema }; export default schemas; diff --git a/src/server/config/express.js b/src/server/config/express.js index 8bca539fb..5c46e9d19 100644 --- a/src/server/config/express.js +++ b/src/server/config/express.js @@ -19,7 +19,8 @@ const proxyApi = httpProxy.createProxyServer({ const proxyOneQuran = httpProxy.createProxyServer({ target: process.env.ONE_QURAN_URL, - secure: true + secure: false, + proxyTimeout: 15000 }); proxyApi.on('error', (error, req, res) => { @@ -35,7 +36,29 @@ proxyApi.on('error', (error, req, res) => { res.end(JSON.stringify(json)); }); +proxyOneQuran.on('error', (error, req, res) => { + let json; + if (error.code !== 'ECONNRESET') { + console.error('proxy error', error); + } + if (!res.headersSent) { + res.writeHead(500, {'content-type': 'application/json'}); + } + + json = {error: 'proxy_error', reason: error.message}; + res.end(JSON.stringify(json)); +}); + export default function(server) { + // Must be first thing. See: https://github.com/nodejitsu/node-http-proxy/issues/180#issuecomment-3677221 + server.use('/api/onequran', (req, res) => { + proxyOneQuran.web(req, res); + }); + + server.use('/api', (req, res) => { + proxyApi.web(req, res); + }); + server.use(compression()); server.use(bodyParser.json()); server.use(logger('dev')); @@ -55,12 +78,4 @@ export default function(server) { server.get(/^\/(images|fonts)\/.*/, function(req, res) { res.redirect(301, '//quran-1f14.kxcdn.com' + req.path); }); - - server.use('/api/onequran', (req, res) => { - proxyOneQuran.web(req, res); - }); - - server.use('/api', (req, res) => { - proxyApi.web(req, res); - }); } diff --git a/src/styles/bootstrap.config.js b/src/styles/bootstrap.config.js index f15d048c9..e64c16908 100644 --- a/src/styles/bootstrap.config.js +++ b/src/styles/bootstrap.config.js @@ -49,7 +49,7 @@ module.exports = { "alerts": false, "progress-bars": false, "media": false, - "list-group": false, + "list-group": true, "panels": true, "wells": false, "close": true,