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 Super Blocks
; } - return superBlocks.map(dashedName => ( - - )); + return superBlocks.map(dashedName => { + if (dashedName === 'youtube') { + return ( + + ); + } + return ( + + ); + } + ); } render() { diff --git a/common/app/routes/challenges/views/map/youtube/YoutubeBlock.jsx b/common/app/routes/challenges/views/map/youtube/YoutubeBlock.jsx new file mode 100644 index 0000000000..b66fd33736 --- /dev/null +++ b/common/app/routes/challenges/views/map/youtube/YoutubeBlock.jsx @@ -0,0 +1,118 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import FA from 'react-fontawesome'; +import PureComponent from 'react-pure-render/component'; +import { Panel } from 'react-bootstrap'; + +import YoutubeVideo from './YoutubeVideo.jsx'; + +import { toggleThisPanel } from '../../../redux/actions'; +import { + makePanelOpenSelector, + makePanelHiddenSelector +} from '../../../redux/selectors'; + +const mapStateToProps = () => createSelector( + makePanelOpenSelector(), + makePanelHiddenSelector(), + (isOpen, isHidden) => { + return { + isOpen, + isHidden + }; + } +); + +const mapDispatchToProps = { + toggleThisPanel +}; + +const propTypes = { + dashedName: PropTypes.string, + isHidden: PropTypes.bool, + isOpen: PropTypes.bool, + title: PropTypes.string, + toggleThisPanel: PropTypes.func.isRequired, + videos: PropTypes.object +}; + +export class YoutubeBlock extends PureComponent { + constructor(...props) { + super(...props); + this.handleSelect = this.handleSelect.bind(this); + } + + handleSelect(eventKey, e) { + e.preventDefault(); + this.props.toggleThisPanel(eventKey); + } + + renderHeader(isOpen, title) { + return ( +
+

+ + + { title } + +

+
+ ); + } + + renderVideos(options) { + const { videos, dashedName: block } = options; + const videosArray = Object.keys(videos).map(video => videos[video]); + if (!videosArray.length) { + return (

No videos found for this playlist

); + } + return videosArray + .map((video, i) => ( + + )); + } + + render() { + const { + dashedName, + isOpen, + isHidden, + title, + videos + } = this.props; + if (isHidden) { + return null; + } + return ( + + { this.renderVideos({ videos, dashedName }) } + + ); + } +} + +YoutubeBlock.displayName = 'YoutubeBlock'; +YoutubeBlock.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(YoutubeBlock); diff --git a/common/app/routes/challenges/views/map/youtube/YoutubeSuperBlock.jsx b/common/app/routes/challenges/views/map/youtube/YoutubeSuperBlock.jsx new file mode 100644 index 0000000000..e2834cf590 --- /dev/null +++ b/common/app/routes/challenges/views/map/youtube/YoutubeSuperBlock.jsx @@ -0,0 +1,117 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import PureComponent from 'react-pure-render/component'; +import FA from 'react-fontawesome'; +import { Panel } from 'react-bootstrap'; + +import YoutubeBlock from './YoutubeBlock.jsx'; + +import { toggleThisPanel } from '../../../redux/actions'; +import { + makePanelOpenSelector, + makePanelHiddenSelector +} from '../../../redux/selectors'; + +const mapDispatchToProps = { + toggleThisPanel +}; +const mapStateToProps = () => { + const panelOpenSelector = makePanelOpenSelector(); + const panelHiddenSelector = makePanelHiddenSelector(); + return createSelector( + (_, props) => props.dashedName, + state => state.entities.youtube, + panelOpenSelector, + panelHiddenSelector, + (dashedName, youtube, isOpen, isHidden) => ({ + dashedName, + isOpen, + isHidden, + youtube + }) + ); +}; +const propTypes = { + dashedName: PropTypes.string, + isHidden: PropTypes.bool, + isOpen: PropTypes.bool, + toggleThisPanel: PropTypes.func.isRequired, + youtube: PropTypes.object +}; + +export class YoutubeSuperBlock extends PureComponent { + constructor(...props) { + super(...props); + this.handleSelect = this.handleSelect.bind(this); + } + + handleSelect(eventKey, e) { + e.preventDefault(); + this.props.toggleThisPanel(eventKey.slice(0).toLowerCase()); + } + + renderHeader(isOpen, title) { + return ( +

+ + { title } +

+ ); + } + + renderPlaylists(playlists) { + return Object.keys(playlists).map((playlist) => { + const { dashedName, title, videos } = playlists[playlist]; + return ( + + ); + }); + } + + render() { + const { + isOpen, + isHidden, + youtube + } = this.props; + const title = 'YouTube'; + if (isHidden) { + return null; + } + return ( + +
+ { this.renderPlaylists(youtube) } +
+
+ ); + } +} + +YoutubeSuperBlock.dsiplayName = 'YoutubeSuperBlock'; +YoutubeSuperBlock.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(YoutubeSuperBlock); diff --git a/common/app/routes/challenges/views/map/youtube/YoutubeVideo.jsx b/common/app/routes/challenges/views/map/youtube/YoutubeVideo.jsx new file mode 100644 index 0000000000..c1211f3513 --- /dev/null +++ b/common/app/routes/challenges/views/map/youtube/YoutubeVideo.jsx @@ -0,0 +1,62 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { Link } from 'react-router'; +import PureComponent from 'react-pure-render/component'; +import classnames from 'classnames'; + +import { makePanelHiddenSelector } from '../../../redux/selectors'; + +const mapDispatchToProps = {}; + +const mapStateToProps = () => createSelector( + makePanelHiddenSelector(), + ( isHidden ) => ({ isHidden }) +); + +const propTypes = { + block: PropTypes.string, + dashedName: PropTypes.string, + isHidden: PropTypes.bool, + title: PropTypes.string +}; + +export class YoutubeVideo extends PureComponent { + render() { + const { + block: playlist, + dashedName: video, + isHidden, + title + } = this.props; + const challengeClassName = classnames({ + 'text-primary': true, + 'padded-ionic-icon': true, + 'negative-15': true, + 'challenge-title': true + }); + if (isHidden) { + return null; + } + return ( +

+ + + { 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 ( +
+ +

+ { `${playlistTitle} - ${videoTitle}` } +

+
+ +
+
+

+ { description } +

+
+ +
+ ); + } +} + +YoutubeVideo.displayName = 'YoutubeVideo'; +YoutubeVideo.propTypes = propTypes; + +export default connect(mapStateToProps, mapActionsToDispatch)(YoutubeVideo); diff --git a/common/app/routes/youtube/index.js b/common/app/routes/youtube/index.js new file mode 100644 index 0000000000..efe5a82190 --- /dev/null +++ b/common/app/routes/youtube/index.js @@ -0,0 +1,8 @@ +import YoutubeVideo from './components/YoutubeVideo.jsx'; + +export default function youtubeVideoRoute() { + return { + path: 'youtube/:playlist/:video', + component: YoutubeVideo + }; +} diff --git a/common/app/routes/youtube/styles/index.js b/common/app/routes/youtube/styles/index.js new file mode 100644 index 0000000000..1a3fe08bd4 --- /dev/null +++ b/common/app/routes/youtube/styles/index.js @@ -0,0 +1,8 @@ +export const descriptionContainer = { + display: 'flex', + justifyContent: 'center' +}; + +export const descriptionText = { + width: '90%' +}; diff --git a/server/boot/randomAPIs.js b/server/boot/randomAPIs.js index c874a98a50..e7a92d8737 100644 --- a/server/boot/randomAPIs.js +++ b/server/boot/randomAPIs.js @@ -1,6 +1,8 @@ import request from 'request'; + import constantStrings from '../utils/constantStrings.json'; import testimonials from '../resources/testimonials.json'; +import { serveYoutubeApiResponse } from '../openApi'; const githubClient = process.env.GITHUB_ID; const githubSecret = process.env.GITHUB_SECRET; @@ -10,6 +12,7 @@ module.exports = function(app) { const User = app.models.User; const noLangRouter = app.loopback.Router(); noLangRouter.get('/api/github', githubCalls); + noLangRouter.get('/api/youtube', serveYoutubeApiResponse); noLangRouter.get('/chat', chat); noLangRouter.get('/twitch', twitch); noLangRouter.get('/unsubscribe/:email', unsubscribeAll); diff --git a/server/boot/react.js b/server/boot/react.js index be4e86d8c5..619afc49f9 100644 --- a/server/boot/react.js +++ b/server/boot/react.js @@ -30,6 +30,9 @@ export default function reactSubRouter(app) { (req, res) => res.redirect(`/challenges/${req.params.dashedName}`) ); + // youtube data not available on app load, send to the map + router.get(/youtube.*/, (req, res) => res.redirect('/map')); + // These routes are in production routes.forEach((route) => { router.get(route, serveReactApp); diff --git a/server/openApi/index.js b/server/openApi/index.js new file mode 100644 index 0000000000..0765273c3e --- /dev/null +++ b/server/openApi/index.js @@ -0,0 +1,119 @@ +import { Observable } from 'rx'; +import debug from 'debug'; + +import request from 'request'; +import { dasherize } from '../utils'; + +const log = debug('fcc:openApi'); + +let dbTimestamp, cachedYoutubeResponse; + +function apiCall(uri) { + return new Promise((resolve, reject) => { + request(uri, (err, res, body) => { + if (err) { reject(err); } + resolve(body); + }); + }); +} + +function sortByDashedName(a, b) { + if (a.dashedName < b.dashedName) { return -1; } + if (a.dashedName > b.dashedName) { return 1; } + return 0; +} +const source = Observable.timer(200000, 3600000) + .selectMany(() => { + const today = new Date().getDay(); + if (dbTimestamp !== today) { + hydrateCache(); + return Observable.of(log('Updating cache')); + } + return Observable.of(null); + }); + +function hydrateCache() { + log('requesting data from openApi'); + Observable.fromPromise( + apiCall('https://glitter-organisation.gomix.me/api/v1/all') + ) + .subscribe( + result => { + const { + lastUpdated, + youtube: { playlists, videos } + } = JSON.parse(result); + dbTimestamp = lastUpdated; + const youtubeVideos = videos + .map(video => { + const { + snippet: { + title, + description, + playlistId, + resourceId: { videoId } + } + } = video; + + return { + description, + playlistId, + title, + videoId, + dashedName: dasherize(title) + }; + }); + const youtube = playlists + .map(list => { + const { + id, + snippet: { title } + } = list; + return { + id, + title, + dashedName: dasherize(title) + }; + }) + .sort(sortByDashedName) + .reduce((accu, current) => { + const videosForCurrent = youtubeVideos + .filter(video => video.playlistId === current.id) + .sort(sortByDashedName) + .reduce((accu, current) => ({ + ...accu, + [current.dashedName]: { ...current } + }), {}); + return { + ...accu, + [current.dashedName]: { + ...current, + videos: videosForCurrent + } + }; + }, {}); + cachedYoutubeResponse = { youtube }; + log('finished updating cache'); + }, + err => log(err) + ); +} + +export function startSubscription() { + log('Hydrating cache'); + hydrateCache(); + source.subscribe(() => {}); +} + +export function serveYoutubeApiResponse(req, res) { + if (cachedYoutubeResponse) { + res.json(cachedYoutubeResponse); + } else { + res.status(500).json( + { + error: 'Something went wrong at our end, please try again.' + } + ); + } +} + diff --git a/server/server.js b/server/server.js index 1f71e292f8..5fa9a87208 100755 --- a/server/server.js +++ b/server/server.js @@ -9,7 +9,8 @@ var _ = require('lodash'), boot = require('loopback-boot'), expressState = require('express-state'), path = require('path'), - setupPassport = require('./component-passport'); + setupPassport = require('./component-passport'), + openApi = require('./openApi'); // polyfill for webpack bundle splitting const requireProto = Object.getPrototypeOf(require); @@ -49,6 +50,7 @@ setupPassport(app); app.start = _.once(function() { app.listen(app.get('port'), function() { app.emit('started'); + openApi.startSubscription(); console.log( 'freeCodeCamp server listening on port %d in %s', app.get('port'),