Add Youtube videos to the map (#13592)
This commit is contained in:
committed by
Quincy Larson
parent
0d9db6ea35
commit
4a3fcba501
@ -6,6 +6,7 @@ import { createSelector } from 'reselect';
|
|||||||
import ns from './ns.json';
|
import ns from './ns.json';
|
||||||
import {
|
import {
|
||||||
fetchUser,
|
fetchUser,
|
||||||
|
fetchYoutube,
|
||||||
updateAppLang,
|
updateAppLang,
|
||||||
trackEvent,
|
trackEvent,
|
||||||
loadCurrentChallenge,
|
loadCurrentChallenge,
|
||||||
@ -22,6 +23,7 @@ import { userSelector } from './redux/selectors';
|
|||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
closeDropdown,
|
closeDropdown,
|
||||||
fetchUser,
|
fetchUser,
|
||||||
|
fetchYoutube,
|
||||||
loadCurrentChallenge,
|
loadCurrentChallenge,
|
||||||
openDropdown,
|
openDropdown,
|
||||||
submitChallenge,
|
submitChallenge,
|
||||||
@ -35,11 +37,13 @@ const mapStateToProps = createSelector(
|
|||||||
state => state.app.isSignInAttempted,
|
state => state.app.isSignInAttempted,
|
||||||
state => state.app.toast,
|
state => state.app.toast,
|
||||||
state => state.challengesApp.toast,
|
state => state.challengesApp.toast,
|
||||||
|
state => state.entities.youtube,
|
||||||
(
|
(
|
||||||
{ user: { username, points, picture } },
|
{ user: { username, points, picture } },
|
||||||
isNavDropdownOpen,
|
isNavDropdownOpen,
|
||||||
isSignInAttempted,
|
isSignInAttempted,
|
||||||
toast,
|
toast,
|
||||||
|
youtube
|
||||||
) => ({
|
) => ({
|
||||||
username,
|
username,
|
||||||
points,
|
points,
|
||||||
@ -47,7 +51,8 @@ const mapStateToProps = createSelector(
|
|||||||
toast,
|
toast,
|
||||||
isNavDropdownOpen,
|
isNavDropdownOpen,
|
||||||
showLoading: !isSignInAttempted,
|
showLoading: !isSignInAttempted,
|
||||||
isSignedIn: !!username
|
isSignedIn: !!username,
|
||||||
|
isYoutubeLoaded: Object.keys(youtube).length > 0
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -55,8 +60,10 @@ const propTypes = {
|
|||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
closeDropdown: PropTypes.func.isRequired,
|
closeDropdown: PropTypes.func.isRequired,
|
||||||
fetchUser: PropTypes.func,
|
fetchUser: PropTypes.func,
|
||||||
|
fetchYoutube: PropTypes.func.isRequired,
|
||||||
isNavDropdownOpen: PropTypes.bool,
|
isNavDropdownOpen: PropTypes.bool,
|
||||||
isSignedIn: PropTypes.bool,
|
isSignedIn: PropTypes.bool,
|
||||||
|
isYoutubeLoaded: PropTypes.bool,
|
||||||
loadCurrentChallenge: PropTypes.func.isRequired,
|
loadCurrentChallenge: PropTypes.func.isRequired,
|
||||||
openDropdown: PropTypes.func.isRequired,
|
openDropdown: PropTypes.func.isRequired,
|
||||||
params: PropTypes.object,
|
params: PropTypes.object,
|
||||||
@ -82,6 +89,9 @@ export class FreeCodeCamp extends React.Component {
|
|||||||
if (!this.props.isSignedIn) {
|
if (!this.props.isSignedIn) {
|
||||||
this.props.fetchUser();
|
this.props.fetchUser();
|
||||||
}
|
}
|
||||||
|
if (!this.props.isYoutubeLoaded) {
|
||||||
|
this.props.fetchYoutube();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderChallengeComplete() {
|
renderChallengeComplete() {
|
||||||
|
@ -145,3 +145,9 @@ export const addThemeToBody = createAction(types.addThemeToBody);
|
|||||||
|
|
||||||
export const openDropdown = createAction(types.openDropdown, noop);
|
export const openDropdown = createAction(types.openDropdown, noop);
|
||||||
export const closeDropdown = createAction(types.closeDropdown, 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);
|
||||||
|
@ -6,9 +6,32 @@ const initialState = {
|
|||||||
superBlock: {},
|
superBlock: {},
|
||||||
block: {},
|
block: {},
|
||||||
challenge: {},
|
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(
|
const userReducer = handleActions(
|
||||||
{
|
{
|
||||||
[types.updateUserPoints]: (state, { payload: { username, points } }) => ({
|
[types.updateUserPoints]: (state, { payload: { username, points } }) => ({
|
||||||
@ -91,8 +114,19 @@ function metaReducer(state = initialState, action) {
|
|||||||
export default function entitiesReducer(state, action) {
|
export default function entitiesReducer(state, action) {
|
||||||
const newState = metaReducer(state, action);
|
const newState = metaReducer(state, action);
|
||||||
const user = userReducer(newState.user, action);
|
const user = userReducer(newState.user, action);
|
||||||
|
const youtube = youtubeReducer(newState.youtube, action);
|
||||||
|
const blocks = blocksReducer(newState, action);
|
||||||
if (newState.user !== user) {
|
if (newState.user !== user) {
|
||||||
return { ...newState, 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;
|
return newState;
|
||||||
}
|
}
|
||||||
|
83
common/app/redux/fetch-youtube-saga.js
Normal file
83
common/app/redux/fetch-youtube-saga.js
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
}
|
@ -1,10 +1,12 @@
|
|||||||
import fetchUserSaga from './fetch-user-saga';
|
import fetchUserSaga from './fetch-user-saga';
|
||||||
import loadCurrentChallengeSaga from './load-current-challenge-saga';
|
import loadCurrentChallengeSaga from './load-current-challenge-saga';
|
||||||
|
import fetchYoutubeSaga from './fetch-youtube-saga';
|
||||||
|
|
||||||
export { default as reducer } from './reducer';
|
export { default as reducer } from './reducer';
|
||||||
export * as actions from './actions';
|
export * as actions from './actions';
|
||||||
export { default as types } from './types';
|
export { default as types } from './types';
|
||||||
export const sagas = [
|
export const sagas = [
|
||||||
fetchUserSaga,
|
fetchUserSaga,
|
||||||
loadCurrentChallengeSaga
|
loadCurrentChallengeSaga,
|
||||||
|
fetchYoutubeSaga
|
||||||
];
|
];
|
||||||
|
@ -17,6 +17,12 @@ export default createTypes([
|
|||||||
'loadCurrentChallenge',
|
'loadCurrentChallenge',
|
||||||
'updateMyCurrentChallenge',
|
'updateMyCurrentChallenge',
|
||||||
|
|
||||||
|
'fetchYoutube',
|
||||||
|
'updateYoutube',
|
||||||
|
|
||||||
|
'updateBlock',
|
||||||
|
'updateSuperBlock',
|
||||||
|
|
||||||
'handleError',
|
'handleError',
|
||||||
// used to hit the server
|
// used to hit the server
|
||||||
'hardGoTo',
|
'hardGoTo',
|
||||||
|
@ -26,6 +26,7 @@ export const fetchChallengeCompleted = createAction(
|
|||||||
(_, challenge) => challenge,
|
(_, challenge) => challenge,
|
||||||
entities => ({ entities })
|
entities => ({ entities })
|
||||||
);
|
);
|
||||||
|
export const updateSuperBlocks = createAction(types.updateSuperBlocks);
|
||||||
export const closeChallengeModal = createAction(types.closeChallengeModal);
|
export const closeChallengeModal = createAction(types.closeChallengeModal);
|
||||||
export const resetUi = createAction(types.resetUi);
|
export const resetUi = createAction(types.resetUi);
|
||||||
export const updateHint = createAction(types.updateHint);
|
export const updateHint = createAction(types.updateHint);
|
||||||
|
@ -134,7 +134,11 @@ const mainReducer = handleActions(
|
|||||||
}),
|
}),
|
||||||
[types.fetchChallengesCompleted]: (state, { payload = [] }) => ({
|
[types.fetchChallengesCompleted]: (state, { payload = [] }) => ({
|
||||||
...state,
|
...state,
|
||||||
superBlocks: payload
|
superBlocks: [ ...payload ]
|
||||||
|
}),
|
||||||
|
[types.updateSuperBlocks]: (state, { payload = [] }) => ({
|
||||||
|
...state,
|
||||||
|
superBlocks: [ ...payload ]
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// step
|
// step
|
||||||
|
@ -24,6 +24,7 @@ export default createTypes([
|
|||||||
'unlockUntrustedCode',
|
'unlockUntrustedCode',
|
||||||
'closeChallengeModal',
|
'closeChallengeModal',
|
||||||
'updateSuccessMessage',
|
'updateSuccessMessage',
|
||||||
|
'updateSuperBlocks',
|
||||||
|
|
||||||
// map
|
// map
|
||||||
'updateFilter',
|
'updateFilter',
|
||||||
|
@ -506,7 +506,7 @@ export function applyFilterToMap(tree, filterRegex) {
|
|||||||
// if leaf (challenge) then test if regex is a match
|
// if leaf (challenge) then test if regex is a match
|
||||||
if (!Array.isArray(node.children)) {
|
if (!Array.isArray(node.children)) {
|
||||||
// does challenge name meet filter criteria?
|
// does challenge name meet filter criteria?
|
||||||
if (filterRegex.test(node.title)) {
|
if (filterRegex.test(node.title) || filterRegex.test(node.name)) {
|
||||||
// is challenge currently hidden?
|
// is challenge currently hidden?
|
||||||
if (node.isHidden) {
|
if (node.isHidden) {
|
||||||
// unhide challenge, it matches
|
// unhide challenge, it matches
|
||||||
|
@ -39,6 +39,10 @@ export class Header extends PureComponent {
|
|||||||
this.handleClearButton = this.handleClearButton.bind(this);
|
this.handleClearButton = this.handleClearButton.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.props.clearFilter();
|
||||||
|
}
|
||||||
|
|
||||||
handleKeyDown(e) {
|
handleKeyDown(e) {
|
||||||
if (e.keyCode === ESC) {
|
if (e.keyCode === ESC) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -7,6 +7,7 @@ import { Col, Row } from 'react-bootstrap';
|
|||||||
|
|
||||||
import MapHeader from './Header.jsx';
|
import MapHeader from './Header.jsx';
|
||||||
import SuperBlock from './Super-Block.jsx';
|
import SuperBlock from './Super-Block.jsx';
|
||||||
|
import YoutubeSuperBlock from './youtube/YoutubeSuperBlock.jsx';
|
||||||
import { fetchChallenges } from '../../redux/actions';
|
import { fetchChallenges } from '../../redux/actions';
|
||||||
import { updateTitle } from '../../../../redux/actions';
|
import { updateTitle } from '../../../../redux/actions';
|
||||||
|
|
||||||
@ -22,12 +23,12 @@ const fetchOptions = {
|
|||||||
};
|
};
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
fetchChallenges: PropTypes.func.isRequired,
|
fetchChallenges: PropTypes.func.isRequired,
|
||||||
|
isYoutubeLoaded: PropTypes.bool,
|
||||||
params: PropTypes.object,
|
params: PropTypes.object,
|
||||||
superBlocks: PropTypes.array,
|
superBlocks: PropTypes.array,
|
||||||
updateTitle: PropTypes.func.isRequired
|
updateTitle: PropTypes.func.isRequired
|
||||||
};
|
};
|
||||||
|
class ShowMap extends PureComponent {
|
||||||
export class ShowMap extends PureComponent {
|
|
||||||
componentWillMount() {
|
componentWillMount() {
|
||||||
// if no params then map is open in drawer
|
// if no params then map is open in drawer
|
||||||
// do not update title
|
// do not update title
|
||||||
@ -43,12 +44,23 @@ export class ShowMap extends PureComponent {
|
|||||||
if (!Array.isArray(superBlocks) || !superBlocks.length) {
|
if (!Array.isArray(superBlocks) || !superBlocks.length) {
|
||||||
return <div>No Super Blocks</div>;
|
return <div>No Super Blocks</div>;
|
||||||
}
|
}
|
||||||
return superBlocks.map(dashedName => (
|
return superBlocks.map(dashedName => {
|
||||||
|
if (dashedName === 'youtube') {
|
||||||
|
return (
|
||||||
|
<YoutubeSuperBlock
|
||||||
|
dashedName={ dashedName }
|
||||||
|
key={ dashedName }
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<SuperBlock
|
<SuperBlock
|
||||||
dashedName={ dashedName }
|
dashedName={ dashedName }
|
||||||
key={ dashedName }
|
key={ dashedName }
|
||||||
/>
|
/>
|
||||||
));
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
118
common/app/routes/challenges/views/map/youtube/YoutubeBlock.jsx
Normal file
118
common/app/routes/challenges/views/map/youtube/YoutubeBlock.jsx
Normal file
@ -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 (
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<FA
|
||||||
|
className='no-link-underline'
|
||||||
|
name={ isOpen ? 'caret-down' : 'caret-right' }
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{ title }
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVideos(options) {
|
||||||
|
const { videos, dashedName: block } = options;
|
||||||
|
const videosArray = Object.keys(videos).map(video => videos[video]);
|
||||||
|
if (!videosArray.length) {
|
||||||
|
return (<p>No videos found for this playlist</p>);
|
||||||
|
}
|
||||||
|
return videosArray
|
||||||
|
.map((video, i) => (
|
||||||
|
<YoutubeVideo
|
||||||
|
block={ block }
|
||||||
|
dashedName={ video.dashedName }
|
||||||
|
key={ video.dashedName + i }
|
||||||
|
title={ video.title }
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
dashedName,
|
||||||
|
isOpen,
|
||||||
|
isHidden,
|
||||||
|
title,
|
||||||
|
videos
|
||||||
|
} = this.props;
|
||||||
|
if (isHidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
bsClass='map-accordion-panel-nested'
|
||||||
|
collapsible={ true }
|
||||||
|
eventKey={ dashedName || title }
|
||||||
|
expanded={ isOpen }
|
||||||
|
header={ this.renderHeader(isOpen, title) }
|
||||||
|
id={ title }
|
||||||
|
key={ title }
|
||||||
|
onSelect={ this.handleSelect }
|
||||||
|
>
|
||||||
|
{ this.renderVideos({ videos, dashedName }) }
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
YoutubeBlock.displayName = 'YoutubeBlock';
|
||||||
|
YoutubeBlock.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(YoutubeBlock);
|
@ -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 (
|
||||||
|
<h2>
|
||||||
|
<FA
|
||||||
|
className='no-link-underline'
|
||||||
|
name={ isOpen ? 'caret-down' : 'caret-right' }
|
||||||
|
/>
|
||||||
|
{ title }
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPlaylists(playlists) {
|
||||||
|
return Object.keys(playlists).map((playlist) => {
|
||||||
|
const { dashedName, title, videos } = playlists[playlist];
|
||||||
|
return (
|
||||||
|
<YoutubeBlock
|
||||||
|
dashedName={ dashedName }
|
||||||
|
key={ dashedName }
|
||||||
|
title= { title }
|
||||||
|
videos={ videos }
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
isHidden,
|
||||||
|
youtube
|
||||||
|
} = this.props;
|
||||||
|
const title = 'YouTube';
|
||||||
|
if (isHidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
bsClass='map-accordion-panel'
|
||||||
|
collapsible={ true }
|
||||||
|
eventKey={ title }
|
||||||
|
expanded={ isOpen }
|
||||||
|
header={ this.renderHeader(isOpen, title) }
|
||||||
|
id={ title }
|
||||||
|
key={ title }
|
||||||
|
onSelect={ this.handleSelect }
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='map-accordion-block'
|
||||||
|
>
|
||||||
|
{ this.renderPlaylists(youtube) }
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
YoutubeSuperBlock.dsiplayName = 'YoutubeSuperBlock';
|
||||||
|
YoutubeSuperBlock.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(YoutubeSuperBlock);
|
@ -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 (
|
||||||
|
<p
|
||||||
|
className={ challengeClassName }
|
||||||
|
key={ title }
|
||||||
|
>
|
||||||
|
<Link to={ `/youtube/${playlist}/${video}` }>
|
||||||
|
<span>
|
||||||
|
{ title }
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
YoutubeVideo.displayName = 'YoutubeVideo';
|
||||||
|
YoutubeVideo.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(YoutubeVideo);
|
@ -6,6 +6,7 @@ import {
|
|||||||
import NotFound from '../components/NotFound/index.jsx';
|
import NotFound from '../components/NotFound/index.jsx';
|
||||||
import { addLang } from '../utils/lang';
|
import { addLang } from '../utils/lang';
|
||||||
import settingsRoute from './settings';
|
import settingsRoute from './settings';
|
||||||
|
import youtubeRoute from './youtube';
|
||||||
|
|
||||||
export default function createChildRoute(deps) {
|
export default function createChildRoute(deps) {
|
||||||
return {
|
return {
|
||||||
@ -22,6 +23,7 @@ export default function createChildRoute(deps) {
|
|||||||
modernChallengesRoute(deps),
|
modernChallengesRoute(deps),
|
||||||
mapRoute(deps),
|
mapRoute(deps),
|
||||||
settingsRoute(deps),
|
settingsRoute(deps),
|
||||||
|
youtubeRoute(deps),
|
||||||
{
|
{
|
||||||
path: '*',
|
path: '*',
|
||||||
component: NotFound
|
component: NotFound
|
||||||
|
92
common/app/routes/youtube/components/YoutubeVideo.jsx
Normal file
92
common/app/routes/youtube/components/YoutubeVideo.jsx
Normal file
@ -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 (
|
||||||
|
<div >
|
||||||
|
<Col md={8} mdOffset={2} xs={12}>
|
||||||
|
<h2>
|
||||||
|
{ `${playlistTitle} - ${videoTitle}` }
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<YouTube
|
||||||
|
opts={ options }
|
||||||
|
videoId={ videoId }
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={ descriptionContainer }>
|
||||||
|
<p style={ descriptionText }>
|
||||||
|
{ description }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
YoutubeVideo.displayName = 'YoutubeVideo';
|
||||||
|
YoutubeVideo.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapActionsToDispatch)(YoutubeVideo);
|
8
common/app/routes/youtube/index.js
Normal file
8
common/app/routes/youtube/index.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import YoutubeVideo from './components/YoutubeVideo.jsx';
|
||||||
|
|
||||||
|
export default function youtubeVideoRoute() {
|
||||||
|
return {
|
||||||
|
path: 'youtube/:playlist/:video',
|
||||||
|
component: YoutubeVideo
|
||||||
|
};
|
||||||
|
}
|
8
common/app/routes/youtube/styles/index.js
Normal file
8
common/app/routes/youtube/styles/index.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export const descriptionContainer = {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const descriptionText = {
|
||||||
|
width: '90%'
|
||||||
|
};
|
@ -1,6 +1,8 @@
|
|||||||
import request from 'request';
|
import request from 'request';
|
||||||
|
|
||||||
import constantStrings from '../utils/constantStrings.json';
|
import constantStrings from '../utils/constantStrings.json';
|
||||||
import testimonials from '../resources/testimonials.json';
|
import testimonials from '../resources/testimonials.json';
|
||||||
|
import { serveYoutubeApiResponse } from '../openApi';
|
||||||
|
|
||||||
const githubClient = process.env.GITHUB_ID;
|
const githubClient = process.env.GITHUB_ID;
|
||||||
const githubSecret = process.env.GITHUB_SECRET;
|
const githubSecret = process.env.GITHUB_SECRET;
|
||||||
@ -10,6 +12,7 @@ module.exports = function(app) {
|
|||||||
const User = app.models.User;
|
const User = app.models.User;
|
||||||
const noLangRouter = app.loopback.Router();
|
const noLangRouter = app.loopback.Router();
|
||||||
noLangRouter.get('/api/github', githubCalls);
|
noLangRouter.get('/api/github', githubCalls);
|
||||||
|
noLangRouter.get('/api/youtube', serveYoutubeApiResponse);
|
||||||
noLangRouter.get('/chat', chat);
|
noLangRouter.get('/chat', chat);
|
||||||
noLangRouter.get('/twitch', twitch);
|
noLangRouter.get('/twitch', twitch);
|
||||||
noLangRouter.get('/unsubscribe/:email', unsubscribeAll);
|
noLangRouter.get('/unsubscribe/:email', unsubscribeAll);
|
||||||
|
3
server/boot/react.js
vendored
3
server/boot/react.js
vendored
@ -30,6 +30,9 @@ export default function reactSubRouter(app) {
|
|||||||
(req, res) => res.redirect(`/challenges/${req.params.dashedName}`)
|
(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
|
// These routes are in production
|
||||||
routes.forEach((route) => {
|
routes.forEach((route) => {
|
||||||
router.get(route, serveReactApp);
|
router.get(route, serveReactApp);
|
||||||
|
119
server/openApi/index.js
Normal file
119
server/openApi/index.js
Normal file
@ -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.'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,8 @@ var _ = require('lodash'),
|
|||||||
boot = require('loopback-boot'),
|
boot = require('loopback-boot'),
|
||||||
expressState = require('express-state'),
|
expressState = require('express-state'),
|
||||||
path = require('path'),
|
path = require('path'),
|
||||||
setupPassport = require('./component-passport');
|
setupPassport = require('./component-passport'),
|
||||||
|
openApi = require('./openApi');
|
||||||
|
|
||||||
// polyfill for webpack bundle splitting
|
// polyfill for webpack bundle splitting
|
||||||
const requireProto = Object.getPrototypeOf(require);
|
const requireProto = Object.getPrototypeOf(require);
|
||||||
@ -49,6 +50,7 @@ setupPassport(app);
|
|||||||
app.start = _.once(function() {
|
app.start = _.once(function() {
|
||||||
app.listen(app.get('port'), function() {
|
app.listen(app.get('port'), function() {
|
||||||
app.emit('started');
|
app.emit('started');
|
||||||
|
openApi.startSubscription();
|
||||||
console.log(
|
console.log(
|
||||||
'freeCodeCamp server listening on port %d in %s',
|
'freeCodeCamp server listening on port %d in %s',
|
||||||
app.get('port'),
|
app.get('port'),
|
||||||
|
Reference in New Issue
Block a user