diff --git a/common/app/App.jsx b/common/app/App.jsx index bb488ad925..fb5e239288 100644 --- a/common/app/App.jsx +++ b/common/app/App.jsx @@ -6,6 +6,7 @@ import { createSelector } from 'reselect'; import ns from './ns.json'; import { fetchUser, + fetchYoutube, updateAppLang, trackEvent, loadCurrentChallenge, @@ -22,6 +23,7 @@ import { userSelector } from './redux/selectors'; const mapDispatchToProps = { closeDropdown, fetchUser, + fetchYoutube, loadCurrentChallenge, openDropdown, submitChallenge, @@ -35,11 +37,13 @@ const mapStateToProps = createSelector( state => state.app.isSignInAttempted, state => state.app.toast, state => state.challengesApp.toast, + state => state.entities.youtube, ( { user: { username, points, picture } }, isNavDropdownOpen, isSignInAttempted, toast, + youtube ) => ({ username, points, @@ -47,7 +51,8 @@ const mapStateToProps = createSelector( toast, isNavDropdownOpen, showLoading: !isSignInAttempted, - isSignedIn: !!username + isSignedIn: !!username, + isYoutubeLoaded: Object.keys(youtube).length > 0 }) ); @@ -55,8 +60,10 @@ const propTypes = { children: PropTypes.node, closeDropdown: PropTypes.func.isRequired, fetchUser: PropTypes.func, + fetchYoutube: PropTypes.func.isRequired, isNavDropdownOpen: PropTypes.bool, isSignedIn: PropTypes.bool, + isYoutubeLoaded: PropTypes.bool, loadCurrentChallenge: PropTypes.func.isRequired, openDropdown: PropTypes.func.isRequired, params: PropTypes.object, @@ -82,6 +89,9 @@ export class FreeCodeCamp extends React.Component { if (!this.props.isSignedIn) { this.props.fetchUser(); } + if (!this.props.isYoutubeLoaded) { + this.props.fetchYoutube(); + } } renderChallengeComplete() { diff --git a/common/app/redux/actions.js b/common/app/redux/actions.js index 6768f58627..df30c432f9 100644 --- a/common/app/redux/actions.js +++ b/common/app/redux/actions.js @@ -145,3 +145,9 @@ export const addThemeToBody = createAction(types.addThemeToBody); export const openDropdown = createAction(types.openDropdown, noop); export const closeDropdown = createAction(types.closeDropdown, noop); + +export const fetchYoutube = createAction(types.fetchYoutube); +export const updateYoutube = createAction(types.updateYoutube); + +export const updateBlock = createAction(types.updateBlock); +export const updateSuperBlock = createAction(types.updateSuperBlock); diff --git a/common/app/redux/entities-reducer.js b/common/app/redux/entities-reducer.js index 9e3df2c6c4..8dcbb104c3 100644 --- a/common/app/redux/entities-reducer.js +++ b/common/app/redux/entities-reducer.js @@ -6,9 +6,32 @@ const initialState = { superBlock: {}, block: {}, challenge: {}, - user: {} + user: {}, + youtube: {} }; +const blocksReducer = handleActions( + { + [types.updateBlock]: (state, { payload }) => ({ + ...state, + block: payload + }), + [types.updateSuperBlock]: (state, { payload }) => ({ + ...state, + superBlock: payload + }) + }, initialState + ); + +const youtubeReducer = handleActions( + { + [types.updateYoutube]: (state, { payload }) => ({ + ...state, + ...payload + }) + }, initialState.youtube + ); + const userReducer = handleActions( { [types.updateUserPoints]: (state, { payload: { username, points } }) => ({ @@ -91,8 +114,19 @@ function metaReducer(state = initialState, action) { export default function entitiesReducer(state, action) { const newState = metaReducer(state, action); const user = userReducer(newState.user, action); + const youtube = youtubeReducer(newState.youtube, action); + const blocks = blocksReducer(newState, action); if (newState.user !== user) { return { ...newState, user }; } + if ( + newState.block !== blocks.block || + newState.superBlock !== blocks.superBlock + ) { + return { ...newState, ...blocks }; + } + if (newState.youtube !== youtube) { + return { ...newState, youtube }; + } return newState; } diff --git a/common/app/redux/fetch-youtube-saga.js b/common/app/redux/fetch-youtube-saga.js new file mode 100644 index 0000000000..84bbce4cdd --- /dev/null +++ b/common/app/redux/fetch-youtube-saga.js @@ -0,0 +1,83 @@ +import { Observable } from 'rx'; + +import { get$ } from '../../utils/ajax-stream'; +import types from './types'; +import { + createErrorObservable, + updateBlock, + updateSuperBlock, + updateYoutube +} from './actions'; +import { + initMap, + updateSuperBlocks +} from '../routes/challenges/redux/actions'; +import { + createMapUi, + searchableChallengeTitles +} from '../routes/challenges/utils'; + +const { fetchYoutube } = types; +export default function fetchYoutubeSaga(action$, getState) { + return action$ + .filter(action => action.type === fetchYoutube) + .flatMap(() => { + const url = '/api/youtube'; + return get$(url) + // allow fetchChallenges to complete + .delay(1000) + .flatMap(result => { + const { youtube } = JSON.parse(result.response); + const store = getState(); + const { entities } = store; + const { block, superBlock } = entities; + const superBlockWithYoutube = { + ...superBlock, + youtube: { + blocks: Object.keys(youtube), + dashedName: 'youtube', + name: 'YouTube', + order: 9, + title: 'YouTube' + } + }; + const youtubeBlocks = Object.keys(youtube) + .map(playlist => youtube[playlist]) + .reduce((accu, current) => { + const videosForPlaylist = Object.keys(current.videos) + .map(video => current.videos[video].dashedName); + return { + ...accu, + [current.dashedName]: { + challenges: videosForPlaylist + } + }; + }, {}); + const blockWithYoutube = { + ...block, + ...youtubeBlocks + }; + const updatedEntities = { + ...entities, + block: blockWithYoutube, + superBlock: superBlockWithYoutube + }; + return Observable.of( + updateBlock(blockWithYoutube), + // update entities.superBlock + updateSuperBlock(superBlockWithYoutube), + // update challengesApp.superblocks + updateSuperBlocks(Object.keys(superBlockWithYoutube)), + updateYoutube(youtube), + initMap( + createMapUi( + updatedEntities, + Object.keys(superBlockWithYoutube), + searchableChallengeTitles(updatedEntities) + ) + ) + ); + }) + .catch(createErrorObservable); + }); +} diff --git a/common/app/redux/index.js b/common/app/redux/index.js index 4f29ce7e0d..4cf905e4d6 100644 --- a/common/app/redux/index.js +++ b/common/app/redux/index.js @@ -1,10 +1,12 @@ import fetchUserSaga from './fetch-user-saga'; import loadCurrentChallengeSaga from './load-current-challenge-saga'; +import fetchYoutubeSaga from './fetch-youtube-saga'; export { default as reducer } from './reducer'; export * as actions from './actions'; export { default as types } from './types'; export const sagas = [ fetchUserSaga, - loadCurrentChallengeSaga + loadCurrentChallengeSaga, + fetchYoutubeSaga ]; diff --git a/common/app/redux/types.js b/common/app/redux/types.js index 766b695c85..aeef2b4738 100644 --- a/common/app/redux/types.js +++ b/common/app/redux/types.js @@ -17,6 +17,12 @@ export default createTypes([ 'loadCurrentChallenge', 'updateMyCurrentChallenge', + 'fetchYoutube', + 'updateYoutube', + + 'updateBlock', + 'updateSuperBlock', + 'handleError', // used to hit the server 'hardGoTo', diff --git a/common/app/routes/challenges/redux/actions.js b/common/app/routes/challenges/redux/actions.js index ca411382b3..8f1766dc70 100644 --- a/common/app/routes/challenges/redux/actions.js +++ b/common/app/routes/challenges/redux/actions.js @@ -26,6 +26,7 @@ export const fetchChallengeCompleted = createAction( (_, challenge) => challenge, entities => ({ entities }) ); +export const updateSuperBlocks = createAction(types.updateSuperBlocks); export const closeChallengeModal = createAction(types.closeChallengeModal); export const resetUi = createAction(types.resetUi); export const updateHint = createAction(types.updateHint); diff --git a/common/app/routes/challenges/redux/reducer.js b/common/app/routes/challenges/redux/reducer.js index cda99f4fb2..fc99eae324 100644 --- a/common/app/routes/challenges/redux/reducer.js +++ b/common/app/routes/challenges/redux/reducer.js @@ -134,7 +134,11 @@ const mainReducer = handleActions( }), [types.fetchChallengesCompleted]: (state, { payload = [] }) => ({ ...state, - superBlocks: payload + superBlocks: [ ...payload ] + }), + [types.updateSuperBlocks]: (state, { payload = [] }) => ({ + ...state, + superBlocks: [ ...payload ] }), // step diff --git a/common/app/routes/challenges/redux/types.js b/common/app/routes/challenges/redux/types.js index d04cd0bb38..1c5ab7d2dd 100644 --- a/common/app/routes/challenges/redux/types.js +++ b/common/app/routes/challenges/redux/types.js @@ -24,6 +24,7 @@ export default createTypes([ 'unlockUntrustedCode', 'closeChallengeModal', 'updateSuccessMessage', + 'updateSuperBlocks', // map 'updateFilter', diff --git a/common/app/routes/challenges/utils.js b/common/app/routes/challenges/utils.js index c58dad00a0..68fb536668 100644 --- a/common/app/routes/challenges/utils.js +++ b/common/app/routes/challenges/utils.js @@ -506,7 +506,7 @@ export function applyFilterToMap(tree, filterRegex) { // if leaf (challenge) then test if regex is a match if (!Array.isArray(node.children)) { // does challenge name meet filter criteria? - if (filterRegex.test(node.title)) { + if (filterRegex.test(node.title) || filterRegex.test(node.name)) { // is challenge currently hidden? if (node.isHidden) { // unhide challenge, it matches diff --git a/common/app/routes/challenges/views/map/Header.jsx b/common/app/routes/challenges/views/map/Header.jsx index 712189bdda..dde449eeb6 100644 --- a/common/app/routes/challenges/views/map/Header.jsx +++ b/common/app/routes/challenges/views/map/Header.jsx @@ -39,6 +39,10 @@ export class Header extends PureComponent { this.handleClearButton = this.handleClearButton.bind(this); } + componentWillUnmount() { + this.props.clearFilter(); + } + handleKeyDown(e) { if (e.keyCode === ESC) { e.preventDefault(); diff --git a/common/app/routes/challenges/views/map/Map.jsx b/common/app/routes/challenges/views/map/Map.jsx index e40701e45c..e18eae07a7 100644 --- a/common/app/routes/challenges/views/map/Map.jsx +++ b/common/app/routes/challenges/views/map/Map.jsx @@ -7,6 +7,7 @@ import { Col, Row } from 'react-bootstrap'; import MapHeader from './Header.jsx'; import SuperBlock from './Super-Block.jsx'; +import YoutubeSuperBlock from './youtube/YoutubeSuperBlock.jsx'; import { fetchChallenges } from '../../redux/actions'; import { updateTitle } from '../../../../redux/actions'; @@ -22,12 +23,12 @@ const fetchOptions = { }; const propTypes = { fetchChallenges: PropTypes.func.isRequired, + isYoutubeLoaded: PropTypes.bool, params: PropTypes.object, superBlocks: PropTypes.array, updateTitle: PropTypes.func.isRequired }; - -export class ShowMap extends PureComponent { +class ShowMap extends PureComponent { componentWillMount() { // if no params then map is open in drawer // do not update title @@ -43,12 +44,23 @@ export class ShowMap extends PureComponent { if (!Array.isArray(superBlocks) || !superBlocks.length) { return
No videos found for this playlist
); + } + return videosArray + .map((video, i) => ( ++ + + { title } + + +
+ ); + } +} + +YoutubeVideo.displayName = 'YoutubeVideo'; +YoutubeVideo.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(YoutubeVideo); diff --git a/common/app/routes/index.js b/common/app/routes/index.js index c006b3b21e..80c31574d0 100644 --- a/common/app/routes/index.js +++ b/common/app/routes/index.js @@ -6,6 +6,7 @@ import { import NotFound from '../components/NotFound/index.jsx'; import { addLang } from '../utils/lang'; import settingsRoute from './settings'; +import youtubeRoute from './youtube'; export default function createChildRoute(deps) { return { @@ -22,6 +23,7 @@ export default function createChildRoute(deps) { modernChallengesRoute(deps), mapRoute(deps), settingsRoute(deps), + youtubeRoute(deps), { path: '*', component: NotFound diff --git a/common/app/routes/youtube/components/YoutubeVideo.jsx b/common/app/routes/youtube/components/YoutubeVideo.jsx new file mode 100644 index 0000000000..93519abe72 --- /dev/null +++ b/common/app/routes/youtube/components/YoutubeVideo.jsx @@ -0,0 +1,92 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import PureComponent from 'react-pure-render/component'; +import { Col } from 'react-bootstrap'; +import YouTube from 'react-youtube'; + +import { updateTitle } from '../../../redux/actions'; + +import * as styles from '../styles'; + +const mapActionsToDispatch = { + updateTitle +}; +const mapStateToProps = createSelector( + (_, props) => props.params.playlist, + (_, props) => props.params.video, + state => state.entities.youtube, + (selectedPlaylist, selectedVideo, youtube = {}) => { + const playlist = youtube[selectedPlaylist]; + return { + playlist, + video: playlist.videos[selectedVideo] + }; + } +); + +const propTypes = { + playlist: PropTypes.object, + updateTitle: PropTypes.func.isRequired, + video: PropTypes.object +}; + +class YoutubeVideo extends PureComponent { + componentWillMount() { + const { updateTitle, playlist, video } = this.props; + const title = playlist.title && video.title ? + `${playlist.title} - ${video.title}` : + 'freeCodeCamp on YouTube'; + updateTitle(title); + } + componentWillReceiveProps(nextProps) { + const { updateTitle, playlist, video } = nextProps; + if (this.props.video !== video) { + updateTitle(`${playlist.title} - ${video.title}`); + } + } + + render() { + const { + playlist: { title: playlistTitle }, + video: { + description, + title: videoTitle, + videoId + } + } = this.props; + const { + descriptionContainer, + descriptionText + } = styles; + const options = { + width: '100%', + playerVars: { autoplay: 1 } + }; + return ( ++ { description } +
+