feat(app): new layout (#14707)
* feat(app): Restructure app to be more flexible and redux idiomatic BREAKING CHANGE: Lots of breaking changes * refactor(challenges): Redux to started file structure * fix(app): lint issues due to refactor * fix(settings): Refactor settings to use folder structure * refactor(challenges): Move step redux stuff into step folder * fix(challenges): Remove fetchchallenges actions * refactor(challenges): Move project redux logic into project view subdirectory * refactor(app): %s/sagas/epics/g * refactor(redux): Use new redux-epic with combineEpic and ofType * refactor(app): Move challenge selector to app level * fix(app): Move loading challenge info into challenge route This moves a lot of the logic needed to load challenge info into the challenge app. This decouples the main app from the challenge route * refactor(map): Map is now decoupled from challenges * refactor(challenges): Use selectors everywhere instead of guessing state shape * refactor(client): refactor client epics to use selectors * refactor(app): Refactor userSelector to return user object instead of object.user * refactor(entities): Move entities logic into it's own file * fix(redux): combineTypes should be combineActions * fix(app): reducer namespacing and import * fix(Map): Fix undefined type and update redux-action * fix(redux): Refactor fetchUser to be more declarative Use rxjs methods instead of imperative if/else. Also prevent non-actions from being emitted * fix(redux): toString multi phase action types * fix(redux): typecast multiphase type, fix typo in reducer toString multiphase types in fetch challenge epic. Add epic to epics lists. Fix type in fetch challenge complete handler * fix(redux): updateCurrentChallengelogic should be centerlized Move route changes to one location. * fix(Nav): Prevent event object from hanging around closeDropDown/openDropDown where handing on to the event object. This was causing issues with react since event objects are recycled in React. * fix(Map.Challenge): decouple map selector * fix(Map): Decouple panel selectors from props Panel Selectors no longer need to know the shape of a components props. Refactored component selectors to decouple them entities state shape * fix(Map.redux): Add select challenge epic and connect map epics * fix(redux.analytics): Fix meta creator and nav/map events * fix(redux): Update current challenge ajax * fix(challenges): ssr fetch challenge should update challenge ui Was using an epic to update challenge ui on fetch complete, but this was not working on ssr due to the way ssr disables epics to wait for completion. This commit fixes this by causing the complete to directly update state in the challenge ui * fix(challenges): wrong import of types, refactor epic name * fix(redux): Prevent fetch challenge epic from emitting null to dispatch * fix(redux): prevent executechallenge from emitting null * fix(challenges.redux): testsSelector returns just tests * fix(challenges.redux): Prevent completion challenge from emitting null * refactor(Challenges.Step): Refactor step challenge to release event object * fix(redux): wrap reducers in factories reducers exported from features need to be factories this helps avoid cyclic requires messing up reducer creation We end up with exports from files being undefined as node tries to resolve cyclic dependencies. This prevents that by wrapping the `handleActions` call so that the ref to types imported from parent features are closures and can be resolved by node before we need them. * fix(Map): createUi not working correctly map utils should receive just map ui state, createMapUi needs to add title to challenge * feat(Challenges): Adds Panes and panes backend challenge * fix: Create child container to wrap children Create a ChildContainer comp' to wrap all children that represent the view for the current route. This let's the child route define if they want a full width view or if they want the standard max-width view. * feat(Panes): panes now render dividers * feat(Panes): Get divider to move currectly * fix(Nav): Add top margin to contained childs Move margin-bottom from nav to child container as margin top. This let's the jsbin style views fit snug with navbar * fix(Panes): Should be contained within their borders * feat(Panes): Update navbar height of pane on app mount * feat(Panes): Toggle map on map nav btn click * fix(gulpfile): Ensure nodemon exits on restart On process exit, wait for nodemon to shutdown before process.exit * feat(Panes): Make Panes redux first * fix(Panes): Fix divider positioning * fix(Panes): Update divider moved handler dividerMoved action now uses new panesByName structure * feat(Panes): Pane nav button will hide panes * chore(package-lock): Update package lock * feat(Panes.redux): Recaculate dividers on pane toggle * fix(Challenges): Update challenge on dashedName change This fixes backwards navigation not updating the redux state current challenge * feat(Panes.redux): Clear panes on unmount Clearing panes on unmount will clear bin buttons in nav * refactor(Map): Colocate styles * feat(Map): New map layout * fix(Map): No longer has it's own page * fix: FetchChallenges on appMounted * feat: Normalize fetchChallenge(s) results This allows superblocks to be sent with both fetchChallenge and fetchChallenges so the map is always populated on first load * feat(Map): Show blocks on first load * fix(less): Remove old css * feat(Nav): Reduce nav height * fix(Nav): Render nav after content Render nav after content and use css to reverse again on screen. We do this so the panes can render first and update redux panes state which will then update the nav ui state before nav has a chance to render * fix(Panes): Add container This adds a Panes Container that will allow it to udpate redux state so Panes Component will have redux state ready to actually render panes * feat(Challenges.Classic): Add panes * fix(Challenge.Classic): Editor onchange should not need to know about file * fix(Panes): Index on panes hide should account for hidden pane * fix(Challanges.Classic): Fix panes types * fix(Challenges): Add completion modal to all challenges Change classic modal to completion modal * fix(Panes): Dividers live on top of planes * fix(Challenges): Remove codemirror theme Remove codemirror theme and remove borders from preview frame * fix(Challenges.Classic): Remove old component * feat(Challenges.Step): Add panes to step challenge * feat(Challenges.Project): Add panes to projects * fix(Challenges.Projects): Remove row * fix(Modals): Move modal text color to challenge less This text color is dependent on the actual header color * fix(Map): Use Superblock title for ui * fix(Map): Reduce panel header height * fix(app): Capitalize Toasts folder Feature folders should be campitalized * chore(Map): Remove unused epic file * fix(Step): Fix tests * test(Map): Update createMapUi tests input
This commit is contained in:
committed by
Quincy Larson
parent
7a75e25475
commit
42bfa2e64d
@ -1,5 +1,5 @@
|
||||
import { Observable } from 'rx';
|
||||
import { createErrorObservable } from '../../common/app/redux/actions';
|
||||
import { createErrorObservable } from '../../common/app/redux';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
|
||||
// analytics types
|
||||
@ -27,7 +27,7 @@ function formatFields({ type, ...fields }) {
|
||||
}, { type });
|
||||
}
|
||||
|
||||
export default function analyticsSaga(actions, getState, { window }) {
|
||||
export default function analyticsSaga(actions, { getState }, { window }) {
|
||||
const { ga } = window;
|
||||
if (typeof ga !== 'function') {
|
||||
console.log('GA not found');
|
||||
@ -39,5 +39,6 @@ export default function analyticsSaga(actions, getState, { window }) {
|
||||
.filter(Boolean)
|
||||
// ga always returns undefined
|
||||
.map(({ type, ...fields }) => ga('send', type, fields))
|
||||
.ignoreElements()
|
||||
.catch(createErrorObservable);
|
||||
}
|
@ -1,22 +1,26 @@
|
||||
import { Observable } from 'rx';
|
||||
import { combineEpics, ofType } from 'redux-epic';
|
||||
import store from 'store';
|
||||
|
||||
import { removeCodeUri, getCodeUri } from '../utils/code-uri';
|
||||
import { ofType } from '../../common/utils/get-actions-of-type';
|
||||
import { setContent } from '../../common/utils/polyvinyl';
|
||||
import combineSagas from '../../common/utils/combine-sagas';
|
||||
|
||||
import { userSelector } from '../../common/app/redux/selectors';
|
||||
import { makeToast } from '../../common/app/toasts/redux/actions';
|
||||
import types from '../../common/app/routes/challenges/redux/types';
|
||||
import { setContent } from '../../common/utils/polyvinyl';
|
||||
|
||||
import {
|
||||
userSelector,
|
||||
challengeSelector
|
||||
} from '../../common/app/redux';
|
||||
import { makeToast } from '../../common/app/Toasts/redux';
|
||||
import {
|
||||
types,
|
||||
savedCodeFound,
|
||||
updateMain,
|
||||
lockUntrustedCode
|
||||
} from '../../common/app/routes/challenges/redux/actions';
|
||||
import {
|
||||
challengeSelector
|
||||
} from '../../common/app/routes/challenges/redux/selectors';
|
||||
lockUntrustedCode,
|
||||
|
||||
keySelector,
|
||||
filesSelector,
|
||||
codeLockedSelector
|
||||
} from '../../common/app/routes/challenges/redux';
|
||||
|
||||
const legacyPrefixes = [
|
||||
'Bonfire: ',
|
||||
@ -54,43 +58,39 @@ function legacyToFile(code, files, key) {
|
||||
return { [key]: setContent(code, files[key]) };
|
||||
}
|
||||
|
||||
export function clearCodeSaga(actions, getState) {
|
||||
return actions
|
||||
::ofType(types.clearSavedCode)
|
||||
export function clearCodeEpic(actions, { getState }) {
|
||||
return actions::ofType(types.clearSavedCode)
|
||||
.map(() => {
|
||||
const { challengesApp: { id = '' } } = getState();
|
||||
const { id } = challengeSelector(getState());
|
||||
store.remove(id);
|
||||
return null;
|
||||
});
|
||||
})
|
||||
.ignoreElements();
|
||||
}
|
||||
export function saveCodeSaga(actions, getState) {
|
||||
return actions
|
||||
::ofType(types.saveCode)
|
||||
export function saveCodeEpic(actions, { getState }) {
|
||||
return actions::ofType(types.saveCode)
|
||||
// do not save challenge if code is locked
|
||||
.filter(() => !getState().challengesApp.isCodeLocked)
|
||||
.filter(() => !codeLockedSelector(getState()))
|
||||
.map(() => {
|
||||
const { challengesApp: { id = '', files = {} } } = getState();
|
||||
const { id } = challengeSelector(getState());
|
||||
const files = filesSelector(getState());
|
||||
store.set(id, files);
|
||||
return null;
|
||||
});
|
||||
})
|
||||
.ignoreElements();
|
||||
}
|
||||
|
||||
export function loadCodeSaga(actions, getState, { window, location }) {
|
||||
return actions
|
||||
::ofType(types.loadCode)
|
||||
export function loadCodeEpic(actions, { getState }, { window, location }) {
|
||||
return actions::ofType(types.loadCode)
|
||||
.flatMap(() => {
|
||||
let finalFiles;
|
||||
const state = getState();
|
||||
const { user } = userSelector(state);
|
||||
const { challenge } = challengeSelector(state);
|
||||
const user = userSelector(state);
|
||||
const challenge = challengeSelector(state);
|
||||
const key = keySelector(state);
|
||||
const files = filesSelector(state);
|
||||
const {
|
||||
challengesApp: {
|
||||
id = '',
|
||||
files = {},
|
||||
legacyKey = '',
|
||||
key
|
||||
}
|
||||
} = state;
|
||||
id,
|
||||
name: legacyKey
|
||||
} = challenge;
|
||||
const codeUriFound = getCodeUri(
|
||||
location,
|
||||
window.decodeURIComponent
|
||||
@ -149,4 +149,4 @@ export function loadCodeSaga(actions, getState, { window, location }) {
|
||||
});
|
||||
}
|
||||
|
||||
export default combineSagas(saveCodeSaga, loadCodeSaga, clearCodeSaga);
|
||||
export default combineEpics(saveCodeEpic, loadCodeEpic, clearCodeEpic);
|
@ -1,8 +1,7 @@
|
||||
import { makeToast } from '../../common/app/toasts/redux/actions';
|
||||
import { makeToast } from '../../common/app/Toasts/redux';
|
||||
|
||||
export default function errorSaga(action$) {
|
||||
return action$
|
||||
.filter(({ error }) => !!error)
|
||||
export default function errorSaga(actions) {
|
||||
return actions.filter(({ error }) => !!error)
|
||||
.map(({ error }) => error)
|
||||
.doOnNext(error => console.error(error))
|
||||
.map(() => makeToast({
|
@ -1,37 +1,40 @@
|
||||
import { Scheduler, Observable } from 'rx';
|
||||
|
||||
import { ofType } from 'redux-epic';
|
||||
|
||||
import {
|
||||
buildClassic,
|
||||
buildBackendChallenge
|
||||
} from '../utils/build.js';
|
||||
import { ofType } from '../../common/utils/get-actions-of-type.js';
|
||||
import {
|
||||
createErrorObservable,
|
||||
|
||||
challengeSelector
|
||||
} from '../../common/app/routes/challenges/redux/selectors';
|
||||
import types from '../../common/app/routes/challenges/redux/types';
|
||||
import { createErrorObservable } from '../../common/app/redux/actions';
|
||||
} from '../../common/app/redux';
|
||||
import {
|
||||
types,
|
||||
|
||||
frameMain,
|
||||
frameTests,
|
||||
initOutput,
|
||||
saveCode
|
||||
} from '../../common/app/routes/challenges/redux/actions';
|
||||
saveCode,
|
||||
|
||||
export default function buildChallengeEpic(actions, getState) {
|
||||
return actions
|
||||
::ofType(types.executeChallenge, types.updateMain)
|
||||
filesSelector,
|
||||
codeLockedSelector
|
||||
} from '../../common/app/routes/challenges/redux';
|
||||
|
||||
export default function executeChallengeEpic(actions, { getState }) {
|
||||
return actions::ofType(types.executeChallenge, types.updateMain)
|
||||
// if isCodeLocked do not run challenges
|
||||
.filter(() => !getState().challengesApp.isCodeLocked)
|
||||
.filter(() => !codeLockedSelector(getState()))
|
||||
.debounce(750)
|
||||
.flatMapLatest(({ type }) => {
|
||||
const shouldProxyConsole = type === types.updateMain;
|
||||
const state = getState();
|
||||
const { files } = state.challengesApp;
|
||||
const files = filesSelector(state);
|
||||
const {
|
||||
challenge: {
|
||||
required = [],
|
||||
type: challengeType
|
||||
}
|
||||
required = [],
|
||||
type: challengeType
|
||||
} = challengeSelector(state);
|
||||
if (challengeType === 'backend') {
|
||||
return buildBackendChallenge(state)
|
||||
@ -53,6 +56,7 @@ export default function buildChallengeEpic(actions, getState) {
|
||||
initOutput('// running test') :
|
||||
null
|
||||
))
|
||||
.filter(Boolean)
|
||||
.catch(createErrorObservable);
|
||||
});
|
||||
}
|
@ -1,14 +1,18 @@
|
||||
import Rx, { Observable, Subject } from 'rx';
|
||||
import { ofType } from 'redux-epic';
|
||||
/* eslint-disable import/no-unresolved */
|
||||
import loopProtect from 'loop-protect';
|
||||
/* eslint-enable import/no-unresolved */
|
||||
import { ofType } from '../../common/utils/get-actions-of-type';
|
||||
import types from '../../common/app/routes/challenges/redux/types';
|
||||
import {
|
||||
types,
|
||||
|
||||
updateOutput,
|
||||
checkChallenge,
|
||||
updateTests
|
||||
} from '../../common/app/routes/challenges/redux/actions';
|
||||
updateTests,
|
||||
|
||||
codeLockedSelector,
|
||||
testsSelector
|
||||
} from '../../common/app/routes/challenges/redux';
|
||||
|
||||
// we use two different frames to make them all essentially pure functions
|
||||
// main iframe is responsible rendering the preview and is where we proxy the
|
||||
@ -86,7 +90,7 @@ function frameTests({ build, sources, checkChallengePayload } = {}, document) {
|
||||
tests.close();
|
||||
}
|
||||
|
||||
export default function frameEpic(actions, getState, { window, document }) {
|
||||
export default function frameEpic(actions, { getState }, { window, document }) {
|
||||
// we attach a common place for the iframes to pull in functions from
|
||||
// the main process
|
||||
window.__common = {};
|
||||
@ -95,10 +99,9 @@ export default function frameEpic(actions, getState, { window, document }) {
|
||||
const proxyLogger = new Subject();
|
||||
// frameReady will let us know when the test iframe is ready to run
|
||||
const frameReady = window.__common[testId + 'Ready'] = new Subject();
|
||||
const result = actions
|
||||
::ofType(types.frameMain, types.frameTests)
|
||||
const result = actions::ofType(types.frameMain, types.frameTests)
|
||||
// if isCodeLocked is true do not frame user code
|
||||
.filter(() => !getState().challengesApp.isCodeLocked)
|
||||
.filter(() => !codeLockedSelector(getState()))
|
||||
.map(action => {
|
||||
if (action.type === types.frameMain) {
|
||||
return frameMain(action.payload, document, proxyLogger);
|
||||
@ -111,7 +114,7 @@ export default function frameEpic(actions, getState, { window, document }) {
|
||||
proxyLogger.map(updateOutput),
|
||||
frameReady.flatMap(({ checkChallengePayload }) => {
|
||||
const { frame } = getFrameDocument(document, testId);
|
||||
const { tests } = getState().challengesApp;
|
||||
const tests = testsSelector(getState());
|
||||
const postTests = Observable.of(
|
||||
updateOutput('// tests completed'),
|
||||
checkChallenge(checkChallengePayload)
|
10
client/epics/hard-go-to-epic.js
Normal file
10
client/epics/hard-go-to-epic.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { types } from '../../common/app/redux';
|
||||
import { ofType } from 'redux-epic';
|
||||
|
||||
export default function hardGoToSaga(actions, { getState }, { history }) {
|
||||
return actions::ofType(types.hardGoTo)
|
||||
.map(({ payload = '/settings' }) => {
|
||||
history.pushState(history.state, null, payload);
|
||||
return null;
|
||||
});
|
||||
}
|
21
client/epics/index.js
Normal file
21
client/epics/index.js
Normal file
@ -0,0 +1,21 @@
|
||||
import analyticsEpic from './analytics-epic.js';
|
||||
import codeStorageEpic from './code-storage-epic.js';
|
||||
import errEpic from './err-epic.js';
|
||||
import executeChallengeEpic from './execute-challenge-epic.js';
|
||||
import frameEpic from './frame-epic.js';
|
||||
import hardGoToEpic from './hard-go-to-epic.js';
|
||||
import mouseTrapEpic from './mouse-trap-epic.js';
|
||||
import nightModeEpic from './night-mode-epic.js';
|
||||
import titleEpic from './title-epic.js';
|
||||
|
||||
export default [
|
||||
analyticsEpic,
|
||||
codeStorageEpic,
|
||||
errEpic,
|
||||
executeChallengeEpic,
|
||||
frameEpic,
|
||||
hardGoToEpic,
|
||||
mouseTrapEpic,
|
||||
nightModeEpic,
|
||||
titleEpic
|
||||
];
|
@ -4,7 +4,7 @@ import { push } from 'react-router-redux';
|
||||
import {
|
||||
toggleNightMode,
|
||||
hardGoTo
|
||||
} from '../../common/app/redux/actions';
|
||||
} from '../../common/app/redux';
|
||||
|
||||
function bindKey(key, actionCreator) {
|
||||
return Observable.fromEventPattern(
|
||||
@ -21,8 +21,8 @@ const softRedirects = {
|
||||
'g n o': '/settings'
|
||||
};
|
||||
|
||||
export default function mouseTrapSaga(actions$) {
|
||||
const traps$ = [
|
||||
export default function mouseTrapSaga(actions) {
|
||||
const traps = [
|
||||
...Object.keys(softRedirects)
|
||||
.map(key => bindKey(key, () => push(softRedirects[key]))),
|
||||
bindKey(
|
||||
@ -39,5 +39,5 @@ export default function mouseTrapSaga(actions$) {
|
||||
),
|
||||
bindKey('g t n', toggleNightMode)
|
||||
];
|
||||
return Observable.merge(traps$).takeUntil(actions$.last());
|
||||
return Observable.merge(traps).takeUntil(actions.last());
|
||||
}
|
@ -2,12 +2,17 @@ import { Observable } from 'rx';
|
||||
import store from 'store';
|
||||
|
||||
import { postJSON$ } from '../../common/utils/ajax-stream';
|
||||
import types from '../../common/app/redux/types';
|
||||
import {
|
||||
types,
|
||||
|
||||
addThemeToBody,
|
||||
updateTheme,
|
||||
createErrorObservable
|
||||
} from '../../common/app/redux/actions';
|
||||
|
||||
createErrorObservable,
|
||||
|
||||
themeSelector,
|
||||
csrfSelector
|
||||
} from '../../common/app/redux';
|
||||
|
||||
function persistTheme(theme) {
|
||||
store.set('fcc-theme', theme);
|
||||
@ -15,7 +20,7 @@ function persistTheme(theme) {
|
||||
|
||||
export default function nightModeSaga(
|
||||
actions,
|
||||
getState,
|
||||
{ getState },
|
||||
{ document: { body } }
|
||||
) {
|
||||
const toggleBodyClass = actions
|
||||
@ -35,7 +40,7 @@ export default function nightModeSaga(
|
||||
|
||||
const optimistic = toggle
|
||||
.flatMap(() => {
|
||||
const { app: { theme } } = getState();
|
||||
const { theme } = themeSelector(getState());
|
||||
const newTheme = !theme || theme === 'default' ? 'night' : 'default';
|
||||
persistTheme(newTheme);
|
||||
return Observable.of(
|
||||
@ -47,7 +52,8 @@ export default function nightModeSaga(
|
||||
const ajax = toggle
|
||||
.debounce(250)
|
||||
.flatMapLatest(() => {
|
||||
const { app: { theme, csrfToken: _csrf } } = getState();
|
||||
const _csrf = csrfSelector(getState());
|
||||
const theme = themeSelector(getState());
|
||||
return postJSON$('/update-my-theme', { _csrf, theme })
|
||||
.catch(createErrorObservable);
|
||||
});
|
10
client/epics/title-epic.js
Normal file
10
client/epics/title-epic.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { ofType } from 'redux-epic';
|
||||
import { types, titleSelector } from '../../common/app/redux';
|
||||
|
||||
export default function titleSage(actions, { getState }, { document }) {
|
||||
return actions::ofType(types.updateTitle)
|
||||
.do(() => {
|
||||
document.title = titleSelector(getState());
|
||||
})
|
||||
.ignoreElements();
|
||||
}
|
@ -18,8 +18,8 @@ import createApp from '../common/app';
|
||||
import provideStore from '../common/app/provide-store';
|
||||
import { getLangFromPath } from '../common/app/utils/lang';
|
||||
|
||||
// client specific sagas
|
||||
import sagas from './sagas';
|
||||
// client specific epics
|
||||
import epics from './epics';
|
||||
|
||||
import {
|
||||
isColdStored,
|
||||
@ -51,7 +51,7 @@ sendPageAnalytics(history, window.ga);
|
||||
const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f;
|
||||
const adjustUrlOnReplay = !!window.devToolsExtension;
|
||||
|
||||
const sagaOptions = {
|
||||
const epicOptions = {
|
||||
isDev,
|
||||
window,
|
||||
document: window.document,
|
||||
@ -66,8 +66,8 @@ createApp({
|
||||
serviceOptions,
|
||||
initialState,
|
||||
middlewares: [ routerMiddleware(history) ],
|
||||
sagas: [...sagas ],
|
||||
sagaOptions,
|
||||
epics,
|
||||
epicOptions,
|
||||
reducers: { routing },
|
||||
enhancers: [ devTools ]
|
||||
})
|
||||
|
@ -1,3 +1,12 @@
|
||||
.ReactCodeMirror {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
height: 100%;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.CodeMirror span {
|
||||
font-size: 18px;
|
||||
font-family: "Ubuntu Mono";
|
||||
@ -6,12 +15,9 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
border-radius: 5px;
|
||||
height: auto;
|
||||
line-height: 1 !important;
|
||||
.CodeMirror-gutters {
|
||||
background-color: @body-bg;
|
||||
}
|
||||
|
||||
.CodeMirror-linenumber {
|
||||
font-size: 18px;
|
||||
font-family: "Ubuntu Mono";
|
||||
|
@ -90,7 +90,6 @@
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
line-height: @modal-title-line-height;
|
||||
color: @gray-lighter;
|
||||
}
|
||||
|
||||
// Modal body
|
||||
|
@ -11,7 +11,6 @@
|
||||
.navbar {
|
||||
position: relative;
|
||||
min-height: @navbar-height; // Ensure a navbar always shows (e.g., without a .navbar-brand in collapsed mode)
|
||||
margin-bottom: @navbar-margin-bottom;
|
||||
border: 1px solid transparent;
|
||||
|
||||
// Prevent floats from breaking the navbar
|
||||
@ -191,12 +190,13 @@
|
||||
// Custom button for toggling the `.navbar-collapse`, powered by the collapse
|
||||
// JavaScript plugin.
|
||||
|
||||
@navbar-toggle-height: 30px;
|
||||
.navbar-toggle {
|
||||
position: relative;
|
||||
float: right;
|
||||
margin-right: @navbar-padding-horizontal;
|
||||
padding: 9px 10px;
|
||||
.navbar-vertical-align(34px);
|
||||
padding: 6px 10px;
|
||||
.navbar-vertical-align(@navbar-toggle-height);
|
||||
background-color: transparent;
|
||||
background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214
|
||||
border: 1px solid transparent;
|
||||
|
@ -358,8 +358,9 @@
|
||||
//##
|
||||
|
||||
// Basics of a navbar
|
||||
@navbar-height: 50px;
|
||||
@navbar-height: 36px;
|
||||
@navbar-margin-bottom: @line-height-computed+10px;
|
||||
@navbar-total-height: @navbar-height + @navbar-margin-bottom;
|
||||
@navbar-border-radius: @border-radius-base;
|
||||
@navbar-padding-horizontal: floor((@grid-gutter-width / 2));
|
||||
@navbar-padding-vertical: ((@navbar-height - @line-height-computed) / 2);
|
||||
|
@ -112,10 +112,6 @@ h1, h2, h3, h4, h5, h6, p, li {
|
||||
box-shadow: 2px 4px 1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-nav {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.large-li > li {
|
||||
list-style: none;
|
||||
}
|
||||
@ -130,29 +126,6 @@ h1, h2, h3, h4, h5, h6, p, li {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.navbar > .container {
|
||||
width: auto;
|
||||
padding-right: 0px;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
// container default padding size
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
// defined in bootstrap
|
||||
@navbar-total-height: @navbar-height + @navbar-margin-bottom;
|
||||
.nav-height {
|
||||
border: none;
|
||||
height: @navbar-height;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.landing-icon {
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
@ -268,46 +241,6 @@ h1, h2, h3, h4, h5, h6, p, li {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
height: 40px;
|
||||
margin-top: -10px;
|
||||
|
||||
@media (max-width: 397px) {
|
||||
height: 30px;
|
||||
margin-top: -5px;
|
||||
}
|
||||
@media (max-width: 335px) {
|
||||
height: 25px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
@media (min-width: 767px) {
|
||||
margin-right:0;
|
||||
}
|
||||
@media (max-width: 991px) and (min-width: 767px) {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-right: 0px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
background-color: @brand-primary;
|
||||
text-align: center;
|
||||
}
|
||||
.navbar {
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
line-height: 1;
|
||||
@media (min-width: 767px) {
|
||||
padding-left: 15px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
li.avatar-points, li.avatar-points > a { padding:0; margin:0 }
|
||||
|
||||
.thin-progress-bar {
|
||||
height: 8px;
|
||||
margin-top: 3px;
|
||||
@ -336,10 +269,6 @@ li.avatar-points, li.avatar-points > a { padding:0; margin:0 }
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: @brand-primary;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: bold;
|
||||
}
|
||||
@ -352,13 +281,6 @@ p {
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.navbar-nav > li > a {
|
||||
color: @body-bg;
|
||||
&:hover {
|
||||
color: @brand-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.hug-top {
|
||||
margin-top: -35px;
|
||||
margin-bottom: -10px;
|
||||
@ -456,58 +378,6 @@ thead {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.profile-picture {
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.brownie-points-nav {
|
||||
@media (min-width: 991px) and (max-width: 1030px) {
|
||||
float: none !important;
|
||||
padding-right: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
@media (min-width: 991px) {
|
||||
float: left;
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-nav a {
|
||||
color: @body-bg;
|
||||
font-size: 20px;
|
||||
margin-top: -5px;
|
||||
margin-bottom: -5px;
|
||||
}
|
||||
|
||||
.navbar-toggle {
|
||||
color: @body-bg;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: #4a2b0f;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.signup-btn-nav {
|
||||
margin-top: -2px !important;
|
||||
padding-top: 10px !important;
|
||||
padding-bottom: 10px !important;
|
||||
margin-right: -12px;
|
||||
@media (min-width: 991px) and (max-width: 1010px) {
|
||||
margin-left: -10px;
|
||||
margin-right: -5px;
|
||||
}
|
||||
}
|
||||
|
||||
a[href="/email-signup"], a[href="/email-signin"] {
|
||||
&.btn-social.btn-lg > :first-child {
|
||||
line-height:43px;
|
||||
font-size:26px;
|
||||
}
|
||||
}
|
||||
|
||||
form.update-email .btn{
|
||||
margin:0;
|
||||
width:40%;
|
||||
@ -517,19 +387,6 @@ form.update-email .btn{
|
||||
}
|
||||
}
|
||||
|
||||
.public-profile-img {
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.ng-invalid.ng-dirty {
|
||||
border-color: #FA787E;
|
||||
}
|
||||
.ng-valid.ng-dirty {
|
||||
border-color: #78FA89;
|
||||
}
|
||||
|
||||
.flat-top {
|
||||
margin-top: -5px;
|
||||
}
|
||||
@ -596,7 +453,6 @@ form.update-email .btn{
|
||||
color: #009900
|
||||
}
|
||||
|
||||
|
||||
.default-border-radius {
|
||||
border-radius: 5px;
|
||||
}
|
||||
@ -635,10 +491,6 @@ form.update-email .btn{
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-collapse {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.challenge-list-header {
|
||||
background-color: @brand-primary;
|
||||
color: @gray-lighter;
|
||||
@ -655,53 +507,10 @@ form.update-email .btn{
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fcc-footer {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
text-align: center;
|
||||
background-color: @brand-primary;
|
||||
padding: 12px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
a {
|
||||
font-size: 20px;
|
||||
color: @gray-lighter;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-top: 14px;
|
||||
padding-bottom: 12px;
|
||||
&:hover {
|
||||
color: @brand-primary;
|
||||
background-color: @gray-lighter;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.embed-responsive-twitch-chat {
|
||||
padding-bottom: 117%;
|
||||
}
|
||||
|
||||
.graph-rect {
|
||||
fill: #ddd !important
|
||||
}
|
||||
|
||||
.scroll-locker {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.test-vertical-center {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.courseware-height {
|
||||
min-height: 650px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-weight: 400;
|
||||
white-space: normal;
|
||||
@ -739,65 +548,6 @@ form.update-email .btn{
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.navbar-header {
|
||||
float: none;
|
||||
}
|
||||
|
||||
.navbar-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar-collapse.collapse {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.navbar-nav > li {
|
||||
float: none;
|
||||
}
|
||||
|
||||
.navbar-nav > li > a {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.navbar-text {
|
||||
float: none;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
/* since 3.1.0 */
|
||||
.navbar-collapse.collapse.in {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.collapsing {
|
||||
overflow: hidden !important;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-toggle {
|
||||
width: 80px;
|
||||
padding-left: 0;
|
||||
padding-right: 8px;
|
||||
margin: 7px 2px 7px 0;
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.hamburger-text {
|
||||
line-height: 0.75em;
|
||||
margin-top: 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.story-list {
|
||||
padding-bottom: 30px;
|
||||
margin-bottom: 30px;
|
||||
@ -888,8 +638,8 @@ hr {
|
||||
}
|
||||
|
||||
.checklist-element {
|
||||
margin-left: -60px;
|
||||
margin-right: -20px;
|
||||
margin-left: -60px;
|
||||
margin-right: -20px;
|
||||
}
|
||||
|
||||
.profile-social-icons {
|
||||
@ -1048,33 +798,6 @@ code {
|
||||
hr {
|
||||
background-image: linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0));
|
||||
}
|
||||
.navbar-default {
|
||||
.navbar-nav {
|
||||
& > li > a {
|
||||
color: #CCC;
|
||||
}
|
||||
.dropdown-menu {
|
||||
background-color: @gray;
|
||||
a {
|
||||
color: @night-text-color !important;
|
||||
}
|
||||
}
|
||||
a:focus,
|
||||
a:hover,
|
||||
.open #nav-Community-dropdown {
|
||||
background-color: #666 !important;
|
||||
color: @link-hover-color !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.navbar-toggle {
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: #666;
|
||||
color: @link-hover-color;
|
||||
border-color: #666;
|
||||
}
|
||||
}
|
||||
.modal-dialog {
|
||||
.modal-content {
|
||||
background-color: @gray;
|
||||
@ -1228,9 +951,5 @@ and (max-width : 400px) {
|
||||
// creates locally scoped imports
|
||||
// and prevents vaiables from overwriting each other
|
||||
&{ @import "./code-mirror.less"; }
|
||||
&{ @import "./challenge.less"; }
|
||||
&{ @import "./toastr.less"; }
|
||||
&{ @import "./map.less"; }
|
||||
&{ @import "./sk-wave.less"; }
|
||||
&{ @import "./skeleton-shimmer.less"; }
|
||||
&{ @import "../../common/index.less"; }
|
||||
|
@ -1,238 +0,0 @@
|
||||
/*
|
||||
* based off of https://github.com/gitterHQ/sidecar
|
||||
* license: MIT
|
||||
*/
|
||||
.map-buttons {
|
||||
margin-top: -10px;
|
||||
& button,
|
||||
& .input-group{
|
||||
width:300px;
|
||||
}
|
||||
.input-group{
|
||||
margin-top: 15px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.map-filter {
|
||||
background:#fff;
|
||||
border-color: darkgreen;
|
||||
}
|
||||
|
||||
.map-filter + .input-group-addon {
|
||||
min-width: inherit;
|
||||
width: 40px;
|
||||
color: darkgreen;
|
||||
background-color: #fff;
|
||||
border-color: darkgreen;
|
||||
.fa {
|
||||
position:absolute;
|
||||
top:50%;
|
||||
-webkit-transform: translateY(-50%);
|
||||
transform: translateY(-50%);
|
||||
right:10px;
|
||||
}
|
||||
}
|
||||
|
||||
.map-filter.filled + span.input-group-addon {
|
||||
background: darkgreen;
|
||||
border-color: #000d00;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
span {
|
||||
display: inline-block;
|
||||
min-height: 30px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map-accordion {
|
||||
max-width: 700px;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
|
||||
.map-accordion-panel-title {
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.map-accordion-panel-collapse {
|
||||
transition: height 0.001s;
|
||||
}
|
||||
|
||||
.map-accordion-panel-nested-collapse {
|
||||
transition: height 0.001s;
|
||||
}
|
||||
|
||||
.map-accordion-panel-nested {
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
.map-accordion-panel-nested-body {
|
||||
padding-left: 36px;
|
||||
}
|
||||
|
||||
.map-accordion-panel-nested-title > a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.map-accordion-panel-nested {
|
||||
margin: 0;
|
||||
}
|
||||
.map-accordion-panel-nested-title {
|
||||
padding-left: 9px;
|
||||
}
|
||||
.map-accordion-panel-nested-body {
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
a:focus {
|
||||
text-decoration: none;
|
||||
color:darkgreen;
|
||||
}
|
||||
a:focus:hover {
|
||||
text-decoration: underline;
|
||||
color:#001800;
|
||||
}
|
||||
|
||||
h2 > a {
|
||||
width:100%;
|
||||
display:block;
|
||||
background:#efefef;
|
||||
padding:10px 0;
|
||||
padding-left:50px;
|
||||
padding-right:20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a > h3 {
|
||||
padding-left: 40px;
|
||||
padding-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.map-accordion-block {
|
||||
:before {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
p {
|
||||
text-indent: -15px;
|
||||
margin-left: 60px;
|
||||
padding-right: 20px;
|
||||
@media (max-width: 400px) {
|
||||
margin-left:30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
top: 195px;
|
||||
bottom: 0;
|
||||
// position:absolute;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
h2 {
|
||||
margin:15px 0;
|
||||
padding:0;
|
||||
&:first-of-type {
|
||||
margin-top:0;
|
||||
}
|
||||
> a {
|
||||
padding: 10px 0;
|
||||
padding-left: 50px;
|
||||
padding-right: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
a {
|
||||
margin: 10px 0;
|
||||
padding: 0;
|
||||
> h3 {
|
||||
clear:both;
|
||||
font-size:20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.challenge-block-description {
|
||||
margin:0;
|
||||
margin-top:-10px;
|
||||
padding:0 15px 23px 30px;
|
||||
}
|
||||
|
||||
span.no-link-underline {
|
||||
margin-left:-30px;
|
||||
color: #666;
|
||||
}
|
||||
div > div:last-child {
|
||||
margin-bottom:30px
|
||||
}
|
||||
}
|
||||
|
||||
.challenge-block-time {
|
||||
font-size: 18px;
|
||||
color: #BBBBBB;
|
||||
margin-bottom: 20px;
|
||||
@media (min-width: 721px) {
|
||||
// margin-right: 20px;
|
||||
// margin-top: -30px;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
#noneFound {
|
||||
display:none;
|
||||
margin:60px 30px 0;
|
||||
font-size:30px;
|
||||
text-align: center;
|
||||
color:darkgreen;
|
||||
.fa {
|
||||
display:block;
|
||||
font-size:300px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.night {
|
||||
.map-fixed-header {
|
||||
background-color: @night-body-bg;
|
||||
}
|
||||
#map-filter, .input-group-addon {
|
||||
border-color: #292929;
|
||||
background-color: #666;
|
||||
color:#ABABAB;
|
||||
}
|
||||
.map-accordion span.no-link-underline {
|
||||
color: @brand-primary;
|
||||
}
|
||||
.map-accordion h2 > a {
|
||||
background:#666;
|
||||
}
|
||||
.map-accordion a:focus, #noneFound {
|
||||
color: #ABABAB;
|
||||
}
|
||||
.input-group-addon {
|
||||
&.filled{
|
||||
background: @gray;
|
||||
border-color: #292929;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
.challenge-title {
|
||||
color: @night-text-color;
|
||||
}
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
// original source:
|
||||
// https://github.com/tobiasahlin/SpinKit
|
||||
@duration: 3s;
|
||||
@delayRange: 1s;
|
||||
|
||||
.create-wave-child(@numOfCol, @iter: 2) when (@iter <= @numOfCol) {
|
||||
div:nth-child(@{iter}) {
|
||||
animation-delay: -(@duration - (@delayRange / (@numOfCol - 1)) * (@iter - 1));
|
||||
}
|
||||
.create-wave-child(@numOfCol, (@iter + 1));
|
||||
}
|
||||
|
||||
.sk-wave {
|
||||
height: 100px;
|
||||
margin: 100px auto;
|
||||
text-align: center;
|
||||
width: 50px;
|
||||
> div {
|
||||
animation: sk-stretchdelay @duration infinite ease-in-out;
|
||||
background-color: @brand-primary;
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
margin-right: 2px;
|
||||
width: 6px;
|
||||
}
|
||||
.create-wave-child(5)
|
||||
}
|
||||
|
||||
@keyframes sk-stretchdelay {
|
||||
0%, 40%, 100% {
|
||||
transform: scaleY(0.4);
|
||||
}
|
||||
20% {
|
||||
transform: scaleY(1.0);
|
||||
}
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
@keyframes skeletonShimmer{
|
||||
0% {
|
||||
transform: translateX(-48px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(1000px);
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
position: relative;
|
||||
min-height: 18px;
|
||||
|
||||
.row {
|
||||
height: 18px;
|
||||
|
||||
.col-xs-12 {
|
||||
padding-right: 12px;
|
||||
height: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.sprite-wrapper {
|
||||
background-color: #333;
|
||||
height: 17px;
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.sprite {
|
||||
animation-name: skeletonShimmer;
|
||||
animation-duration: 2.5s;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: normal;
|
||||
background: white;
|
||||
box-shadow: 0 0 3px 2px;
|
||||
height: 17px;
|
||||
width: 2px;
|
||||
z-index: 5;
|
||||
}
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
import types from '../../common/app/redux/types';
|
||||
|
||||
const { hardGoTo } = types;
|
||||
export default function hardGoToSaga(action$, getState, { history }) {
|
||||
return action$
|
||||
.filter(({ type }) => type === hardGoTo)
|
||||
.map(({ payload = '/settings' }) => {
|
||||
history.pushState(history.state, null, payload);
|
||||
return null;
|
||||
});
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
import analyticsSaga from './analytics-saga.js';
|
||||
import codeStorageSaga from './code-storage-saga.js';
|
||||
import errSaga from './err-saga.js';
|
||||
import executeChallengeSaga from './build-challenge-epic.js';
|
||||
import frameEpic from './frame-epic.js';
|
||||
import hardGoToSaga from './hard-go-to-saga.js';
|
||||
import mouseTrapSaga from './mouse-trap-saga.js';
|
||||
import nightModeSaga from './night-mode-saga.js';
|
||||
import titleSaga from './title-saga.js';
|
||||
|
||||
export default [
|
||||
analyticsSaga,
|
||||
codeStorageSaga,
|
||||
errSaga,
|
||||
executeChallengeSaga,
|
||||
frameEpic,
|
||||
hardGoToSaga,
|
||||
mouseTrapSaga,
|
||||
nightModeSaga,
|
||||
titleSaga
|
||||
];
|
@ -1,10 +0,0 @@
|
||||
export default function titleSage(action$, getState, { document }) {
|
||||
return action$
|
||||
.filter(action => action.type === 'app.updateTitle')
|
||||
.map(() => {
|
||||
const state = getState();
|
||||
const newTitle = state.app.title;
|
||||
document.title = newTitle;
|
||||
return null;
|
||||
});
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { makeToast } from '../../common/app/toasts/redux/actions';
|
||||
import { makeToast } from '../../common/app/Toasts/redux';
|
||||
|
||||
export default function flashToToast(flash) {
|
||||
return Object.keys(flash)
|
||||
|
@ -1,73 +1,40 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import ns from './ns.json';
|
||||
import {
|
||||
appMounted,
|
||||
fetchUser,
|
||||
updateAppLang,
|
||||
trackEvent,
|
||||
loadCurrentChallenge,
|
||||
openDropdown,
|
||||
closeDropdown
|
||||
} from './redux/actions';
|
||||
|
||||
import { submitChallenge } from './routes/challenges/redux/actions';
|
||||
userSelector
|
||||
} from './redux';
|
||||
|
||||
import Nav from './components/Nav';
|
||||
import Toasts from './toasts/Toasts.jsx';
|
||||
import { userSelector } from './redux/selectors';
|
||||
import Nav from './Nav';
|
||||
import Toasts from './Toasts';
|
||||
|
||||
const mapDispatchToProps = {
|
||||
closeDropdown,
|
||||
appMounted,
|
||||
fetchUser,
|
||||
loadCurrentChallenge,
|
||||
openDropdown,
|
||||
submitChallenge,
|
||||
trackEvent,
|
||||
updateAppLang
|
||||
};
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
userSelector,
|
||||
state => state.app.isNavDropdownOpen,
|
||||
state => state.app.isSignInAttempted,
|
||||
state => state.app.toast,
|
||||
state => state.challengesApp.toast,
|
||||
(
|
||||
{ user: { username, points, picture } },
|
||||
isNavDropdownOpen,
|
||||
isSignInAttempted,
|
||||
toast,
|
||||
) => ({
|
||||
username,
|
||||
points,
|
||||
picture,
|
||||
toast,
|
||||
isNavDropdownOpen,
|
||||
showLoading: !isSignInAttempted,
|
||||
const mapStateToProps = state => {
|
||||
const { username } = userSelector(state);
|
||||
return {
|
||||
toast: state.app.toast,
|
||||
isSignedIn: !!username
|
||||
})
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
appMounted: PropTypes.func.isRequired,
|
||||
children: PropTypes.node,
|
||||
closeDropdown: PropTypes.func.isRequired,
|
||||
fetchUser: PropTypes.func,
|
||||
isNavDropdownOpen: PropTypes.bool,
|
||||
isSignedIn: PropTypes.bool,
|
||||
loadCurrentChallenge: PropTypes.func.isRequired,
|
||||
openDropdown: PropTypes.func.isRequired,
|
||||
params: PropTypes.object,
|
||||
picture: PropTypes.string,
|
||||
points: PropTypes.number,
|
||||
showLoading: PropTypes.bool,
|
||||
submitChallenge: PropTypes.func,
|
||||
toast: PropTypes.object,
|
||||
trackEvent: PropTypes.func.isRequired,
|
||||
updateAppLang: PropTypes.func.isRequired,
|
||||
username: PropTypes.string
|
||||
updateAppLang: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
// export plain class for testing
|
||||
@ -79,54 +46,21 @@ export class FreeCodeCamp extends React.Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.appMounted();
|
||||
if (!this.props.isSignedIn) {
|
||||
this.props.fetchUser();
|
||||
}
|
||||
}
|
||||
|
||||
renderChallengeComplete() {
|
||||
const { submitChallenge } = this.props;
|
||||
return (
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='small'
|
||||
bsStyle='primary'
|
||||
className='animated fadeIn'
|
||||
onClick={ submitChallenge }
|
||||
>
|
||||
Submit and go to my next challenge
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
username,
|
||||
points,
|
||||
picture,
|
||||
trackEvent,
|
||||
loadCurrentChallenge,
|
||||
openDropdown,
|
||||
closeDropdown,
|
||||
isNavDropdownOpen
|
||||
} = this.props;
|
||||
const navProps = {
|
||||
closeDropdown,
|
||||
isNavDropdownOpen,
|
||||
loadCurrentChallenge,
|
||||
openDropdown,
|
||||
picture,
|
||||
points,
|
||||
trackEvent,
|
||||
username
|
||||
};
|
||||
|
||||
// we render nav after the content
|
||||
// to allow the panes to update
|
||||
// redux store, which will update the bin
|
||||
// buttons in the nav
|
||||
return (
|
||||
<div className={ `${ns}-container` }>
|
||||
<Nav { ...navProps }/>
|
||||
<div className={ `${ns}-content` }>
|
||||
{ this.props.children }
|
||||
</div>
|
||||
{ this.props.children }
|
||||
<Nav />
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
|
24
common/app/Child-Container.jsx
Normal file
24
common/app/Child-Container.jsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import ns from './ns.json';
|
||||
|
||||
const propTypes = {
|
||||
children: PropTypes.node,
|
||||
isFullWidth: PropTypes.bool
|
||||
};
|
||||
|
||||
export default function ChildContainer({ children, isFullWidth }) {
|
||||
const contentClassname = classnames({
|
||||
[`${ns}-content`]: true,
|
||||
[`${ns}-centered`]: !isFullWidth
|
||||
});
|
||||
return (
|
||||
<div className={ contentClassname }>
|
||||
{ children }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ChildContainer.displayName = 'ChildContainer';
|
||||
ChildContainer.propTypes = propTypes;
|
@ -5,30 +5,35 @@ import FA from 'react-fontawesome';
|
||||
import PureComponent from 'react-pure-render/component';
|
||||
import { Panel } from 'react-bootstrap';
|
||||
|
||||
import ns from './ns.json';
|
||||
import Challenge from './Challenge.jsx';
|
||||
import { toggleThisPanel } from '../../redux/actions';
|
||||
import {
|
||||
toggleThisPanel,
|
||||
|
||||
makePanelOpenSelector,
|
||||
makePanelHiddenSelector
|
||||
} from '../../redux/selectors';
|
||||
} from './redux';
|
||||
|
||||
import { makeBlockSelector } from '../entities';
|
||||
|
||||
const dispatchActions = { toggleThisPanel };
|
||||
const makeMapStateToProps = () => createSelector(
|
||||
(_, props) => props.dashedName,
|
||||
(state, props) => state.entities.block[props.dashedName],
|
||||
makePanelOpenSelector(),
|
||||
makePanelHiddenSelector(),
|
||||
(dashedName, block, isOpen, isHidden) => {
|
||||
return {
|
||||
isOpen,
|
||||
isHidden,
|
||||
dashedName,
|
||||
title: block.title,
|
||||
time: block.time,
|
||||
challenges: block.challenges
|
||||
};
|
||||
}
|
||||
);
|
||||
function makeMapStateToProps(_, { dashedName }) {
|
||||
return createSelector(
|
||||
makeBlockSelector(dashedName),
|
||||
makePanelOpenSelector(dashedName),
|
||||
makePanelHiddenSelector(dashedName),
|
||||
(block, isOpen, isHidden) => {
|
||||
return {
|
||||
isOpen,
|
||||
isHidden,
|
||||
dashedName,
|
||||
title: block.title,
|
||||
time: block.time,
|
||||
challenges: block.challenges
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
const propTypes = {
|
||||
challenges: PropTypes.array,
|
||||
dashedName: PropTypes.string,
|
||||
@ -51,17 +56,16 @@ export class Block extends PureComponent {
|
||||
|
||||
renderHeader(isOpen, title, time, isCompleted) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className={ isCompleted ? 'faded clear-fix' : 'clear-fix' }>
|
||||
<FA
|
||||
className='no-link-underline'
|
||||
name={ isOpen ? 'caret-down' : 'caret-right' }
|
||||
/>
|
||||
<span>
|
||||
{ title }
|
||||
</span>
|
||||
<span className='challenge-block-time'>({ time })</span>
|
||||
</h3>
|
||||
<div className={ isCompleted ? 'faded' : '' }>
|
||||
<FA
|
||||
className='map-caret'
|
||||
name={ isOpen ? 'caret-down' : 'caret-right' }
|
||||
size='lg'
|
||||
/>
|
||||
<span>
|
||||
{ title }
|
||||
</span>
|
||||
<span className={ `${ns}-block-time` }>({ time })</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -92,7 +96,7 @@ export class Block extends PureComponent {
|
||||
}
|
||||
return (
|
||||
<Panel
|
||||
bsClass='map-accordion-panel-nested'
|
||||
bsClass={ `${ns}-accordion-panel` }
|
||||
collapsible={ true }
|
||||
eventKey={ dashedName || title }
|
||||
expanded={ isOpen }
|
@ -6,13 +6,18 @@ import PureComponent from 'react-pure-render/component';
|
||||
import classnames from 'classnames';
|
||||
import debug from 'debug';
|
||||
|
||||
import { updateCurrentChallenge } from '../../redux/actions';
|
||||
import { makePanelHiddenSelector } from '../../redux/selectors';
|
||||
import { userSelector } from '../../../../redux/selectors';
|
||||
import {
|
||||
clickOnChallenge,
|
||||
|
||||
makePanelHiddenSelector
|
||||
} from './redux';
|
||||
import { userSelector } from '../redux';
|
||||
import { challengeMapSelector } from '../entities';
|
||||
|
||||
const propTypes = {
|
||||
block: PropTypes.string,
|
||||
challenge: PropTypes.object,
|
||||
clickOnChallenge: PropTypes.func.isRequired,
|
||||
dashedName: PropTypes.string,
|
||||
isComingSoon: PropTypes.bool,
|
||||
isCompleted: PropTypes.bool,
|
||||
@ -20,51 +25,53 @@ const propTypes = {
|
||||
isHidden: PropTypes.bool,
|
||||
isLocked: PropTypes.bool,
|
||||
isRequired: PropTypes.bool,
|
||||
title: PropTypes.string,
|
||||
updateCurrentChallenge: PropTypes.func.isRequired
|
||||
title: PropTypes.string
|
||||
};
|
||||
const mapDispatchToProps = { updateCurrentChallenge };
|
||||
const makeMapStateToProps = () => createSelector(
|
||||
userSelector,
|
||||
(_, props) => props.dashedName,
|
||||
state => state.entities.challenge,
|
||||
makePanelHiddenSelector(),
|
||||
(
|
||||
{ user: { challengeMap: userChallengeMap } },
|
||||
dashedName,
|
||||
challengeMap,
|
||||
isHidden
|
||||
) => {
|
||||
const challenge = challengeMap[dashedName] || {};
|
||||
let isCompleted = false;
|
||||
if (userChallengeMap) {
|
||||
isCompleted = !!userChallengeMap[challenge.id];
|
||||
function mapDispatchToProps(dispatch, { dashedName }) {
|
||||
const dispatchers = {
|
||||
clickOnChallenge: e => {
|
||||
e.preventDefault();
|
||||
return dispatch(clickOnChallenge(dashedName));
|
||||
}
|
||||
return {
|
||||
dashedName,
|
||||
challenge,
|
||||
isHidden,
|
||||
isCompleted,
|
||||
title: challenge.title,
|
||||
block: challenge.block,
|
||||
isLocked: challenge.isLocked,
|
||||
isRequired: challenge.isRequired,
|
||||
isComingSoon: challenge.isComingSoon,
|
||||
isDev: debug.enabled('fcc:*')
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
return () => dispatchers;
|
||||
}
|
||||
|
||||
function makeMapStateToProps(_, { dashedName }) {
|
||||
return createSelector(
|
||||
userSelector,
|
||||
challengeMapSelector,
|
||||
makePanelHiddenSelector(dashedName),
|
||||
(
|
||||
{ challengeMap: userChallengeMap },
|
||||
challengeMap,
|
||||
isHidden
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
block,
|
||||
isLocked,
|
||||
isRequired,
|
||||
isComingSoon
|
||||
} = challengeMap[dashedName] || {};
|
||||
const isCompleted = userChallengeMap ? !!userChallengeMap[id] : false;
|
||||
return {
|
||||
dashedName,
|
||||
isHidden,
|
||||
isCompleted,
|
||||
title,
|
||||
block,
|
||||
isLocked,
|
||||
isRequired,
|
||||
isComingSoon,
|
||||
isDev: debug.enabled('fcc:*')
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export class Challenge extends PureComponent {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.handleChallengeClick = this.handleChallengeClick.bind(this);
|
||||
}
|
||||
|
||||
handleChallengeClick() {
|
||||
this.props.updateCurrentChallenge(this.props.challenge);
|
||||
}
|
||||
|
||||
renderCompleted(isCompleted, isLocked) {
|
||||
if (isLocked || !isCompleted) {
|
||||
return null;
|
||||
@ -109,15 +116,16 @@ export class Challenge extends PureComponent {
|
||||
|
||||
render() {
|
||||
const {
|
||||
title,
|
||||
dashedName,
|
||||
block,
|
||||
clickOnChallenge,
|
||||
dashedName,
|
||||
isComingSoon,
|
||||
isCompleted,
|
||||
isDev,
|
||||
isHidden,
|
||||
isLocked,
|
||||
isRequired,
|
||||
isCompleted,
|
||||
isComingSoon,
|
||||
isDev,
|
||||
isHidden
|
||||
title
|
||||
} = this.props;
|
||||
if (isHidden) {
|
||||
return null;
|
||||
@ -125,8 +133,7 @@ export class Challenge extends PureComponent {
|
||||
const challengeClassName = classnames({
|
||||
'text-primary': true,
|
||||
'padded-ionic-icon': true,
|
||||
'negative-15': true,
|
||||
'challenge-title': true,
|
||||
'map-challenge-title': true,
|
||||
'ion-checkmark-circled faded': !(isLocked || isComingSoon) && isCompleted,
|
||||
'ion-ios-circle-outline': !(isLocked || isComingSoon) && !isCompleted,
|
||||
'ion-locked': isLocked || isComingSoon,
|
||||
@ -141,18 +148,18 @@ export class Challenge extends PureComponent {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p
|
||||
<div
|
||||
className={ challengeClassName }
|
||||
key={ title }
|
||||
>
|
||||
<Link to={ `/challenges/${block}/${dashedName}` }>
|
||||
<span onClick={ this.handleChallengeClick }>
|
||||
<span onClick={ clickOnChallenge }>
|
||||
{ title }
|
||||
{ this.renderCompleted(isCompleted, isLocked) }
|
||||
{ this.renderRequired(isRequired) }
|
||||
</span>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
54
common/app/Map/Map.jsx
Normal file
54
common/app/Map/Map.jsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PureComponent from 'react-pure-render/component';
|
||||
import { Col, Row } from 'react-bootstrap';
|
||||
|
||||
import ns from './ns.json';
|
||||
import SuperBlock from './Super-Block.jsx';
|
||||
import { superBlocksSelector } from '../redux';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
superBlocks: superBlocksSelector(state)
|
||||
});
|
||||
|
||||
const mapDispatchToProps = {};
|
||||
const propTypes = {
|
||||
params: PropTypes.object,
|
||||
superBlocks: PropTypes.array
|
||||
};
|
||||
|
||||
export class ShowMap extends PureComponent {
|
||||
renderSuperBlocks(superBlocks) {
|
||||
if (!Array.isArray(superBlocks) || !superBlocks.length) {
|
||||
return <div>No Super Blocks</div>;
|
||||
}
|
||||
return superBlocks.map(dashedName => (
|
||||
<SuperBlock
|
||||
dashedName={ dashedName }
|
||||
key={ dashedName }
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { superBlocks } = this.props;
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={ 12 }>
|
||||
<div className={ `${ns}-accordion center-block` }>
|
||||
{ this.renderSuperBlocks(superBlocks) }
|
||||
<div className='spacer' />
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ShowMap.displayName = 'Map';
|
||||
ShowMap.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(ShowMap);
|
@ -5,36 +5,36 @@ import PureComponent from 'react-pure-render/component';
|
||||
import FA from 'react-fontawesome';
|
||||
import { Panel } from 'react-bootstrap';
|
||||
|
||||
import ns from './ns.json';
|
||||
import Block from './Block.jsx';
|
||||
import { toggleThisPanel } from '../../redux/actions';
|
||||
import {
|
||||
toggleThisPanel,
|
||||
|
||||
makePanelOpenSelector,
|
||||
makePanelHiddenSelector
|
||||
} from '../../redux/selectors';
|
||||
} from './redux';
|
||||
import { makeSuperBlockSelector } from '../entities';
|
||||
|
||||
const dispatchActions = { toggleThisPanel };
|
||||
// make selectors unique to each component
|
||||
// see
|
||||
// reactjs/reselect
|
||||
// sharing-selectors-with-props-across-multiple-components
|
||||
const makeMapStateToProps = () => {
|
||||
const panelOpenSelector = makePanelOpenSelector();
|
||||
const panelHiddenSelector = makePanelHiddenSelector();
|
||||
function makeMapStateToProps(_, { dashedName }) {
|
||||
return createSelector(
|
||||
(_, props) => props.dashedName,
|
||||
(state, props) => state.entities.superBlock[props.dashedName],
|
||||
panelOpenSelector,
|
||||
panelHiddenSelector,
|
||||
(dashedName, superBlock, isOpen, isHidden) => ({
|
||||
makeSuperBlockSelector(dashedName),
|
||||
makePanelOpenSelector(dashedName),
|
||||
makePanelHiddenSelector(dashedName),
|
||||
(superBlock, isOpen, isHidden) => ({
|
||||
isOpen,
|
||||
isHidden,
|
||||
dashedName,
|
||||
title: superBlock.title,
|
||||
blocks: superBlock.blocks,
|
||||
title: superBlock.title || dashedName,
|
||||
blocks: superBlock.blocks || [],
|
||||
message: superBlock.message
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const propTypes = {
|
||||
blocks: PropTypes.array,
|
||||
@ -58,7 +58,7 @@ export class SuperBlock extends PureComponent {
|
||||
|
||||
renderBlocks(blocks) {
|
||||
if (!Array.isArray(blocks) || !blocks.length) {
|
||||
return <div>No Blocks Found</div>;
|
||||
return null;
|
||||
}
|
||||
return blocks.map(dashedName => (
|
||||
<Block
|
||||
@ -73,7 +73,7 @@ export class SuperBlock extends PureComponent {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className='challenge-block-description'>
|
||||
<div className={ `${ns}-block-description` }>
|
||||
{ message }
|
||||
</div>
|
||||
);
|
||||
@ -81,13 +81,14 @@ export class SuperBlock extends PureComponent {
|
||||
|
||||
renderHeader(isOpen, title, isCompleted) {
|
||||
return (
|
||||
<h2 className={ isCompleted ? 'faded' : '' }>
|
||||
<div className={ isCompleted ? 'faded' : '' }>
|
||||
<FA
|
||||
className='no-link-underline'
|
||||
className={ `${ns}-caret` }
|
||||
name={ isOpen ? 'caret-down' : 'caret-right' }
|
||||
size='lg'
|
||||
/>
|
||||
{ title }
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -105,7 +106,7 @@ export class SuperBlock extends PureComponent {
|
||||
}
|
||||
return (
|
||||
<Panel
|
||||
bsClass='map-accordion-panel'
|
||||
bsClass={ `${ns}-accordion-panel` }
|
||||
collapsible={ true }
|
||||
eventKey={ dashedName || title }
|
||||
expanded={ isOpen }
|
||||
@ -115,9 +116,7 @@ export class SuperBlock extends PureComponent {
|
||||
onSelect={ this.handleSelect }
|
||||
>
|
||||
{ this.renderMessage(message) }
|
||||
<div
|
||||
className='map-accordion-block'
|
||||
>
|
||||
<div className={ `${ns}-accordion-block` }>
|
||||
{ this.renderBlocks(blocks) }
|
||||
</div>
|
||||
</Panel>
|
118
common/app/Map/map.less
Normal file
118
common/app/Map/map.less
Normal file
@ -0,0 +1,118 @@
|
||||
// should be the same as the filename and ./ns.json
|
||||
@ns: map;
|
||||
|
||||
.@{ns}-accordion {
|
||||
max-width: 700px;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
|
||||
a:focus {
|
||||
text-decoration: none;
|
||||
color:darkgreen;
|
||||
}
|
||||
|
||||
a:focus:hover {
|
||||
text-decoration: underline;
|
||||
color:#001800;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
top: 195px;
|
||||
bottom: 0;
|
||||
// position:absolute;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
h2 {
|
||||
margin:15px 0;
|
||||
padding:0;
|
||||
&:first-of-type {
|
||||
margin-top:0;
|
||||
}
|
||||
> a {
|
||||
padding: 10px 0;
|
||||
padding-left: 50px;
|
||||
padding-right: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
a {
|
||||
margin: 10px 0;
|
||||
padding: 0;
|
||||
> h3 {
|
||||
clear:both;
|
||||
font-size:20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.@{ns}-caret {
|
||||
color: @gray-dark;
|
||||
text-decoration: none;
|
||||
// make sure all carats are fixed width
|
||||
width: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.@{ns}-accordion-block .@{ns}-accordion-panel-heading {
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.@{ns}-challenge-title, .@{ns}-accordion-panel-heading {
|
||||
background: @body-bg;
|
||||
line-height: 26px;
|
||||
padding: 2px 12px 2px 10px;
|
||||
width:100%;
|
||||
}
|
||||
|
||||
.@{ns}-challenge-title {
|
||||
&::before {
|
||||
padding-right: 6px;
|
||||
}
|
||||
padding-left: 40px;
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.@{ns}-accordion-panel-heading a {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.@{ns}-accordion-panel-collapse {
|
||||
transition: height 0.001s;
|
||||
}
|
||||
|
||||
.@{ns}-block-time {
|
||||
color: #BBBBBB;
|
||||
@media (min-width: 721px) {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
.@{ns}-block-description {
|
||||
margin:0;
|
||||
margin-top:-10px;
|
||||
padding:0 15px 23px 30px;
|
||||
}
|
||||
|
||||
|
||||
.night {
|
||||
.@{ns}-accordion .no-link-underline {
|
||||
color: @brand-primary;
|
||||
}
|
||||
.@{ns}-accordion h2 > a {
|
||||
background: #666;
|
||||
}
|
||||
.@{ns}-accordion a:focus, #noneFound {
|
||||
color: #ABABAB;
|
||||
}
|
||||
.challenge-title {
|
||||
color: @night-text-color;
|
||||
}
|
||||
}
|
1
common/app/Map/ns.json
Normal file
1
common/app/Map/ns.json
Normal file
@ -0,0 +1 @@
|
||||
"map"
|
129
common/app/Map/redux/index.js
Normal file
129
common/app/Map/redux/index.js
Normal file
@ -0,0 +1,129 @@
|
||||
import { createTypes } from 'redux-create-types';
|
||||
import { createAction, handleActions } from 'redux-actions';
|
||||
import { createSelector } from 'reselect';
|
||||
import identity from 'lodash/identity';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
|
||||
import selectChallengeEpic from './select-challenge-epic.js';
|
||||
|
||||
import * as utils from './utils.js';
|
||||
import ns from '../ns.json';
|
||||
import {
|
||||
types as app,
|
||||
createEventMetaCreator
|
||||
} from '../../redux';
|
||||
|
||||
export const epics = [
|
||||
selectChallengeEpic
|
||||
];
|
||||
|
||||
export const types = createTypes([
|
||||
'initMap',
|
||||
|
||||
'toggleThisPanel',
|
||||
|
||||
'isAllCollapsed',
|
||||
'collapseAll',
|
||||
'expandAll',
|
||||
|
||||
'clickOnChallenge'
|
||||
], ns);
|
||||
|
||||
export const initMap = createAction(types.initMap);
|
||||
|
||||
export const toggleThisPanel = createAction(types.toggleThisPanel);
|
||||
export const collapseAll = createAction(types.collapseAll);
|
||||
|
||||
export const expandAll = createAction(types.expandAll);
|
||||
export const clickOnChallenge = createAction(
|
||||
types.clickOnChallenge,
|
||||
identity,
|
||||
createEventMetaCreator({
|
||||
category: capitalize(ns),
|
||||
action: 'click',
|
||||
label: types.clickOnChallenge
|
||||
})
|
||||
);
|
||||
|
||||
const initialState = {
|
||||
mapUi: { isAllCollapsed: false },
|
||||
superBlocks: []
|
||||
};
|
||||
|
||||
export const getNS = state => state[ns];
|
||||
export const allColapsedSelector = state => state[ns].isAllCollapsed;
|
||||
export const mapSelector = state => getNS(state).mapUi;
|
||||
export function makePanelOpenSelector(name) {
|
||||
return createSelector(
|
||||
mapSelector,
|
||||
mapUi => {
|
||||
const node = utils.getNode(mapUi, name);
|
||||
return node ? node.isOpen : false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function makePanelHiddenSelector(name) {
|
||||
return createSelector(
|
||||
mapSelector,
|
||||
mapUi => {
|
||||
const node = utils.getNode(mapUi, name);
|
||||
return node ? node.isHidden : false;
|
||||
}
|
||||
);
|
||||
}
|
||||
// interface Map{
|
||||
// children: [...{
|
||||
// name: (superBlock: String),
|
||||
// isOpen: Boolean,
|
||||
// isHidden: Boolean,
|
||||
// children: [...{
|
||||
// name: (blockName: String),
|
||||
// isOpen: Boolean,
|
||||
// isHidden: Boolean,
|
||||
// children: [...{
|
||||
// name: (challengeName: String),
|
||||
// isHidden: Boolean
|
||||
// }]
|
||||
// }]
|
||||
// }
|
||||
// }
|
||||
export default function createReducer() {
|
||||
const reducer = handleActions(
|
||||
{
|
||||
[types.toggleThisPanel]: (state, { payload: name }) => {
|
||||
return {
|
||||
...state,
|
||||
mapUi: utils.toggleThisPanel(state.mapUi, name)
|
||||
};
|
||||
},
|
||||
[types.collapseAll]: state => {
|
||||
const mapUi = utils.collapseAllPanels(state.mapUi);
|
||||
mapUi.isAllCollapsed = true;
|
||||
return {
|
||||
...state,
|
||||
mapUi
|
||||
};
|
||||
},
|
||||
[types.expandAll]: state => {
|
||||
const mapUi = utils.expandAllPanels(state.mapUi);
|
||||
mapUi.isAllCollapsed = false;
|
||||
return {
|
||||
...state,
|
||||
mapUi
|
||||
};
|
||||
},
|
||||
[app.fetchChallenges.complete]: (state, { payload }) => {
|
||||
const { entities, result } = payload;
|
||||
return {
|
||||
...state,
|
||||
mapUi: utils.createMapUi(entities, result)
|
||||
};
|
||||
}
|
||||
},
|
||||
initialState
|
||||
);
|
||||
|
||||
reducer.toString = () => ns;
|
||||
return reducer;
|
||||
}
|
10
common/app/Map/redux/select-challenge-epic.js
Normal file
10
common/app/Map/redux/select-challenge-epic.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { ofType } from 'redux-epic';
|
||||
|
||||
import { types } from './';
|
||||
import { updateCurrentChallenge } from '../../redux';
|
||||
|
||||
export default function selectChallengeEpic(actions) {
|
||||
return actions::ofType(types.clickOnChallenge)
|
||||
.pluck('payload')
|
||||
.map(updateCurrentChallenge);
|
||||
}
|
159
common/app/Map/redux/utils.js
Normal file
159
common/app/Map/redux/utils.js
Normal file
@ -0,0 +1,159 @@
|
||||
import protect from '../../utils/empty-protector';
|
||||
|
||||
const throwIfUndefined = () => {
|
||||
throw new Error('Challenge does not have a title');
|
||||
};
|
||||
|
||||
export function createSearchTitle(
|
||||
name = throwIfUndefined(),
|
||||
challengeMap = {}
|
||||
) {
|
||||
return challengeMap[name] || name;
|
||||
}
|
||||
// interface Node {
|
||||
// isHidden: Boolean,
|
||||
// children: Void|[ ...Node ],
|
||||
// isOpen?: Boolean
|
||||
// }
|
||||
//
|
||||
// interface MapUi
|
||||
// {
|
||||
// children: [...{
|
||||
// name: (superBlock: String),
|
||||
// isOpen: Boolean,
|
||||
// isHidden: Boolean,
|
||||
// children: [...{
|
||||
// name: (blockName: String),
|
||||
// isOpen: Boolean,
|
||||
// isHidden: Boolean,
|
||||
// children: [...{
|
||||
// name: (challengeName: String),
|
||||
// isHidden: Boolean
|
||||
// }]
|
||||
// }]
|
||||
// }]
|
||||
// }
|
||||
export function createMapUi(
|
||||
{
|
||||
block: blockMap,
|
||||
challenge: challengeMap,
|
||||
superBlock: superBlockMap
|
||||
} = {},
|
||||
{ superBlocks } = {}
|
||||
) {
|
||||
if (!superBlocks || !superBlockMap || !blockMap) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
children: superBlocks.map(superBlock => {
|
||||
return {
|
||||
name: superBlock,
|
||||
isOpen: false,
|
||||
isHidden: false,
|
||||
children: protect(superBlockMap[superBlock]).blocks.map(block => {
|
||||
return {
|
||||
name: block,
|
||||
isOpen: false,
|
||||
isHidden: false,
|
||||
children: protect(blockMap[block]).challenges.map(challenge => {
|
||||
return {
|
||||
name: challenge,
|
||||
title: createSearchTitle(challenge, challengeMap),
|
||||
isHidden: false,
|
||||
children: null
|
||||
};
|
||||
})
|
||||
};
|
||||
})
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// synchronise
|
||||
// traverseMapUi(
|
||||
// tree: MapUi|Node,
|
||||
// update: ((MapUi|Node) => MapUi|Node)
|
||||
// ) => MapUi|Node
|
||||
export function traverseMapUi(tree, update) {
|
||||
let childrenChanged;
|
||||
if (!Array.isArray(tree.children)) {
|
||||
return update(tree);
|
||||
}
|
||||
const newChildren = tree.children.map(node => {
|
||||
const newNode = traverseMapUi(node, update);
|
||||
if (!childrenChanged && newNode !== node) {
|
||||
childrenChanged = true;
|
||||
}
|
||||
return newNode;
|
||||
});
|
||||
if (childrenChanged) {
|
||||
tree = {
|
||||
...tree,
|
||||
children: newChildren
|
||||
};
|
||||
}
|
||||
return update(tree);
|
||||
}
|
||||
|
||||
// synchronise
|
||||
// getNode(tree: MapUi, name: String) => MapUi
|
||||
export function getNode(tree, name) {
|
||||
let node;
|
||||
traverseMapUi(tree, thisNode => {
|
||||
if (thisNode.name === name) {
|
||||
node = thisNode;
|
||||
}
|
||||
return thisNode;
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
// synchronise
|
||||
// updateSingelNode(
|
||||
// tree: MapUi,
|
||||
// name: String,
|
||||
// update(MapUi|Node) => MapUi|Node
|
||||
// ) => MapUi
|
||||
export function updateSingleNode(tree, name, update) {
|
||||
return traverseMapUi(tree, node => {
|
||||
if (name !== node.name) {
|
||||
return node;
|
||||
}
|
||||
return update(node);
|
||||
});
|
||||
}
|
||||
|
||||
// synchronise
|
||||
// toggleThisPanel(tree: MapUi, name: String) => MapUi
|
||||
export function toggleThisPanel(tree, name) {
|
||||
return updateSingleNode(tree, name, node => {
|
||||
return {
|
||||
...node,
|
||||
isOpen: !node.isOpen
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// toggleAllPanels(tree: MapUi, isOpen: Boolean = false ) => MapUi
|
||||
export function toggleAllPanels(tree, isOpen = false) {
|
||||
return traverseMapUi(tree, node => {
|
||||
if (!Array.isArray(node.children) || node.isOpen === isOpen) {
|
||||
return node;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
isOpen
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// collapseAllPanels(tree: MapUi) => MapUi
|
||||
export function collapseAllPanels(tree) {
|
||||
return toggleAllPanels(tree);
|
||||
}
|
||||
|
||||
// expandAllPanels(tree: MapUi) => MapUi
|
||||
export function expandAllPanels(tree) {
|
||||
return toggleAllPanels(tree, true);
|
||||
}
|
215
common/app/Map/redux/utils.test.js
Normal file
215
common/app/Map/redux/utils.test.js
Normal file
@ -0,0 +1,215 @@
|
||||
import test from 'tape';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import {
|
||||
getNode,
|
||||
createMapUi,
|
||||
traverseMapUi,
|
||||
updateSingleNode,
|
||||
toggleThisPanel,
|
||||
expandAllPanels,
|
||||
collapseAllPanels
|
||||
} from './utils.js';
|
||||
|
||||
test('createMapUi', t => {
|
||||
t.plan(3);
|
||||
t.test('should return an `{}` when proper args not supplied', t => {
|
||||
t.plan(3);
|
||||
t.equal(
|
||||
Object.keys(createMapUi()).length,
|
||||
0
|
||||
);
|
||||
t.equal(
|
||||
Object.keys(createMapUi({}, [])).length,
|
||||
0
|
||||
);
|
||||
t.equal(
|
||||
Object.keys(createMapUi({ superBlock: {} }, [])).length,
|
||||
0
|
||||
);
|
||||
});
|
||||
t.test('should return a map tree', t => {
|
||||
const expected = {
|
||||
children: [{
|
||||
name: 'superBlockA',
|
||||
children: [{
|
||||
name: 'blockA',
|
||||
children: [{
|
||||
name: 'challengeA'
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const actual = createMapUi({
|
||||
superBlock: {
|
||||
superBlockA: {
|
||||
blocks: [
|
||||
'blockA'
|
||||
]
|
||||
}
|
||||
},
|
||||
block: {
|
||||
blockA: {
|
||||
challenges: [
|
||||
'challengeA'
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{ superBlocks: ['superBlockA'] },
|
||||
{ challengeA: 'ChallengeA title'}
|
||||
);
|
||||
t.plan(3);
|
||||
t.equal(actual.children[0].name, expected.children[0].name);
|
||||
t.equal(
|
||||
actual.children[0].children[0].name,
|
||||
expected.children[0].children[0].name
|
||||
);
|
||||
t.equal(
|
||||
actual.children[0].children[0].children[0].name,
|
||||
expected.children[0].children[0].children[0].name
|
||||
);
|
||||
});
|
||||
t.test('should protect against malformed data', t => {
|
||||
t.plan(2);
|
||||
t.equal(
|
||||
createMapUi({
|
||||
superBlock: {},
|
||||
block: {
|
||||
blockA: {
|
||||
challenges: [
|
||||
'challengeA'
|
||||
]
|
||||
}
|
||||
}
|
||||
}, { superBlocks: ['superBlockA'] }).children[0].children.length,
|
||||
0
|
||||
);
|
||||
t.equal(
|
||||
createMapUi({
|
||||
superBlock: {
|
||||
superBlockA: {
|
||||
blocks: [
|
||||
'blockA'
|
||||
]
|
||||
}
|
||||
},
|
||||
block: {}
|
||||
},
|
||||
{ superBlocks: ['superBlockA'] }).children[0].children[0].children.length,
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
test('traverseMapUi', t => {
|
||||
t.test('should return tree', t => {
|
||||
t.plan(2);
|
||||
const expectedTree = {};
|
||||
const actaulTree = traverseMapUi(expectedTree, tree => {
|
||||
t.equal(tree, expectedTree);
|
||||
return tree;
|
||||
});
|
||||
t.equal(actaulTree, expectedTree);
|
||||
});
|
||||
t.test('should hit every node', t => {
|
||||
t.plan(4);
|
||||
const expected = { children: [{ children: [{}] }] };
|
||||
const spy = sinon.spy(t => t);
|
||||
spy.withArgs(expected);
|
||||
spy.withArgs(expected.children[0]);
|
||||
spy.withArgs(expected.children[0].children[0]);
|
||||
traverseMapUi(expected, spy);
|
||||
t.equal(spy.callCount, 3);
|
||||
t.ok(spy.withArgs(expected).calledOnce, 'foo');
|
||||
t.ok(spy.withArgs(expected.children[0]).calledOnce, 'bar');
|
||||
t.ok(spy.withArgs(expected.children[0].children[0]).calledOnce, 'baz');
|
||||
});
|
||||
t.test('should create new object when children change', t => {
|
||||
t.plan(9);
|
||||
const expected = { children: [{ bar: true }, {}] };
|
||||
const actual = traverseMapUi(expected, node => ({ ...node, foo: true }));
|
||||
t.notEqual(actual, expected);
|
||||
t.notEqual(actual.children, expected.children);
|
||||
t.notEqual(actual.children[0], expected.children[0]);
|
||||
t.notEqual(actual.children[1], expected.children[1]);
|
||||
t.equal(actual.children[0].bar, expected.children[0].bar);
|
||||
t.notOk(expected.children[0].foo);
|
||||
t.notOk(expected.children[1].foo);
|
||||
t.true(actual.children[0].foo);
|
||||
t.true(actual.children[1].foo);
|
||||
});
|
||||
});
|
||||
test('getNode', t => {
|
||||
t.test('should return node', t => {
|
||||
t.plan(1);
|
||||
const expected = { name: 'foo' };
|
||||
const tree = { children: [{ name: 'notfoo' }, expected ] };
|
||||
const actual = getNode(tree, 'foo');
|
||||
t.equal(expected, actual);
|
||||
});
|
||||
t.test('should returned undefined if not found', t => {
|
||||
t.plan(1);
|
||||
const tree = {
|
||||
children: [ { name: 'foo' }, { children: [ { name: 'bar' } ] } ]
|
||||
};
|
||||
const actual = getNode(tree, 'baz');
|
||||
t.notOk(actual);
|
||||
});
|
||||
});
|
||||
test('updateSingleNode', t => {
|
||||
t.test('should update single node', t => {
|
||||
const expected = { name: 'foo' };
|
||||
const untouched = { name: 'notFoo' };
|
||||
const actual = updateSingleNode(
|
||||
{ children: [ untouched, expected ] },
|
||||
'foo',
|
||||
node => ({ ...node, tag: true })
|
||||
);
|
||||
t.plan(4);
|
||||
t.ok(actual.children[1].tag);
|
||||
t.equal(actual.children[1].name, expected.name);
|
||||
t.notEqual(actual.children[1], expected);
|
||||
t.equal(actual.children[0], untouched);
|
||||
});
|
||||
});
|
||||
test('toggleThisPanel', t => {
|
||||
t.test('should update single node', t => {
|
||||
const expected = { name: 'foo', isOpen: true };
|
||||
const actual = toggleThisPanel(
|
||||
{ children: [ { name: 'foo', isOpen: false }] },
|
||||
'foo'
|
||||
);
|
||||
t.plan(1);
|
||||
t.deepLooseEqual(actual.children[0], expected);
|
||||
});
|
||||
});
|
||||
test('toggleAllPanels', t => {
|
||||
t.test('should add `isOpen: true` to every node without children', t => {
|
||||
const expected = {
|
||||
isOpen: true,
|
||||
children: [{
|
||||
isOpen: true,
|
||||
children: [{}, {}]
|
||||
}]
|
||||
};
|
||||
const actual = expandAllPanels({ children: [{ children: [{}, {}] }] });
|
||||
t.plan(1);
|
||||
t.deepLooseEqual(actual, expected);
|
||||
});
|
||||
t.test('should add `isOpen: false` to every node without children', t => {
|
||||
const leaf = {};
|
||||
const expected = {
|
||||
isOpen: false,
|
||||
children: [{
|
||||
isOpen: false,
|
||||
children: [{}, leaf]
|
||||
}]
|
||||
};
|
||||
const actual = collapseAllPanels(
|
||||
{ isOpen: true, children: [{ children: [{}, leaf]}]},
|
||||
);
|
||||
t.plan(2);
|
||||
t.deepLooseEqual(actual, expected);
|
||||
t.equal(actual.children[0].children[1], leaf);
|
||||
});
|
||||
});
|
19
common/app/Nav/Bin-Button.jsx
Normal file
19
common/app/Nav/Bin-Button.jsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { NavItem } from 'react-bootstrap';
|
||||
|
||||
const propTypes = {
|
||||
content: PropTypes.string,
|
||||
handleClick: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default function BinButton({ content, handleClick }) {
|
||||
return (
|
||||
<NavItem
|
||||
onClick={ handleClick }
|
||||
>
|
||||
{ content }
|
||||
</NavItem>
|
||||
);
|
||||
}
|
||||
BinButton.displayName = 'BinButton';
|
||||
BinButton.propTypes = propTypes;
|
249
common/app/Nav/Nav.jsx
Normal file
249
common/app/Nav/Nav.jsx
Normal file
@ -0,0 +1,249 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import noop from 'lodash/noop';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { LinkContainer } from 'react-router-bootstrap';
|
||||
import {
|
||||
MenuItem,
|
||||
Nav,
|
||||
NavDropdown,
|
||||
NavItem,
|
||||
Navbar,
|
||||
NavbarBrand
|
||||
} from 'react-bootstrap';
|
||||
|
||||
import navLinks from './links.json';
|
||||
import SignUp from './Sign-Up.jsx';
|
||||
import BinButton from './Bin-Button.jsx';
|
||||
import {
|
||||
clickOnLogo,
|
||||
openDropdown,
|
||||
closeDropdown,
|
||||
createNavLinkActionCreator,
|
||||
|
||||
dropdownSelector
|
||||
} from './redux';
|
||||
import {
|
||||
userSelector,
|
||||
signInLoadingSelector
|
||||
} from '../redux';
|
||||
import { nameToTypeSelector, panesSelector } from '../Panes/redux';
|
||||
|
||||
|
||||
const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg';
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
userSelector,
|
||||
dropdownSelector,
|
||||
signInLoadingSelector,
|
||||
panesSelector,
|
||||
nameToTypeSelector,
|
||||
(
|
||||
{ username, picture, points },
|
||||
isDropdownOpen,
|
||||
showLoading,
|
||||
panes,
|
||||
nameToType
|
||||
) => {
|
||||
return {
|
||||
panes: panes.map(name => {
|
||||
return {
|
||||
content: name,
|
||||
action: nameToType[name]
|
||||
};
|
||||
}, {}),
|
||||
isDropdownOpen,
|
||||
isSignedIn: !!username,
|
||||
picture,
|
||||
points,
|
||||
showLoading,
|
||||
username
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
const dispatchers = bindActionCreators(navLinks.reduce(
|
||||
(mdtp, { content }) => {
|
||||
const handler = `handle${capitalize(content)}Click`;
|
||||
mdtp[handler] = createNavLinkActionCreator(content);
|
||||
return mdtp;
|
||||
},
|
||||
{
|
||||
clickOnLogo: e => {
|
||||
e.preventDefault();
|
||||
return clickOnLogo();
|
||||
},
|
||||
closeDropdown: () => closeDropdown(),
|
||||
openDropdown: () => openDropdown()
|
||||
}
|
||||
), dispatch);
|
||||
dispatchers.dispatch = dispatch;
|
||||
return () => dispatchers;
|
||||
}
|
||||
|
||||
function mergeProps(stateProps, dispatchProps, ownProps) {
|
||||
const panes = stateProps.panes.map(pane => {
|
||||
return {
|
||||
...pane,
|
||||
actionCreator: () => dispatchProps.dispatch({ type: pane.action })
|
||||
};
|
||||
});
|
||||
return {
|
||||
...ownProps,
|
||||
...stateProps,
|
||||
...dispatchProps,
|
||||
panes
|
||||
};
|
||||
}
|
||||
|
||||
const propTypes = navLinks.reduce(
|
||||
(pt, { content }) => {
|
||||
const handler = `handle${capitalize(content)}Click`;
|
||||
pt[handler] = PropTypes.func.isRequired;
|
||||
return pt;
|
||||
},
|
||||
{
|
||||
panes: PropTypes.array,
|
||||
clickOnLogo: PropTypes.func.isRequired,
|
||||
closeDropdown: PropTypes.func.isRequired,
|
||||
isDropdownOpen: PropTypes.bool,
|
||||
openDropdown: PropTypes.func.isRequired,
|
||||
picture: PropTypes.string,
|
||||
points: PropTypes.number,
|
||||
showLoading: PropTypes.bool,
|
||||
signedIn: PropTypes.bool,
|
||||
username: PropTypes.string
|
||||
}
|
||||
);
|
||||
|
||||
export class FCCNav extends React.Component {
|
||||
renderLink(isNavItem, { isReact, isDropdown, content, link, links, target }) {
|
||||
const Component = isNavItem ? NavItem : MenuItem;
|
||||
const {
|
||||
isDropdownOpen,
|
||||
openDropdown,
|
||||
closeDropdown
|
||||
} = this.props;
|
||||
|
||||
if (isDropdown) {
|
||||
// adding a noop to NavDropdown to disable false warning
|
||||
// about controlled component
|
||||
return (
|
||||
<NavDropdown
|
||||
id={ `nav-${content}-dropdown` }
|
||||
key={ content }
|
||||
noCaret={ true }
|
||||
onClick={ openDropdown }
|
||||
onClose={ closeDropdown }
|
||||
onMouseEnter={ openDropdown }
|
||||
onMouseLeave={ closeDropdown }
|
||||
onToggle={ noop }
|
||||
open={ isDropdownOpen }
|
||||
title={ content }
|
||||
>
|
||||
{ links.map(this.renderLink.bind(this, false)) }
|
||||
</NavDropdown>
|
||||
);
|
||||
}
|
||||
if (isReact) {
|
||||
return (
|
||||
<LinkContainer
|
||||
key={ content }
|
||||
onClick={ this.props[`handle${content}Click`] }
|
||||
to={ link }
|
||||
>
|
||||
<Component
|
||||
target={ target || null }
|
||||
>
|
||||
{ content }
|
||||
</Component>
|
||||
</LinkContainer>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Component
|
||||
href={ link }
|
||||
key={ content }
|
||||
onClick={ this.props[`handle${content}Click`] }
|
||||
target={ target || null }
|
||||
>
|
||||
{ content }
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
panes,
|
||||
clickOnLogo,
|
||||
username,
|
||||
points,
|
||||
picture,
|
||||
showLoading
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Navbar
|
||||
className='nav-height'
|
||||
id='navbar'
|
||||
staticTop={ true }
|
||||
>
|
||||
<Navbar.Header>
|
||||
<Navbar.Toggle children={ 'Menu' } />
|
||||
<NavbarBrand>
|
||||
<a
|
||||
href='/challenges/current-challenge'
|
||||
onClick={ clickOnLogo }
|
||||
>
|
||||
<img
|
||||
alt='learn to code javascript at freeCodeCamp logo'
|
||||
className='img-responsive nav-logo'
|
||||
src={ fCClogo }
|
||||
/>
|
||||
</a>
|
||||
</NavbarBrand>
|
||||
</Navbar.Header>
|
||||
<Navbar.Collapse>
|
||||
<Nav
|
||||
navbar={ true }
|
||||
pullRight={ true }
|
||||
>
|
||||
{
|
||||
panes.map(({ content, actionCreator }) => (
|
||||
<BinButton
|
||||
content={ content }
|
||||
handleClick={ actionCreator }
|
||||
key={ content }
|
||||
/>
|
||||
))
|
||||
}
|
||||
{
|
||||
navLinks.map(
|
||||
this.renderLink.bind(this, true)
|
||||
)
|
||||
}
|
||||
<SignUp
|
||||
picture={ picture }
|
||||
points={ points }
|
||||
showLoading={ showLoading }
|
||||
username={ username }
|
||||
/>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FCCNav.displayName = 'FCCNav';
|
||||
FCCNav.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
mergeProps
|
||||
)(FCCNav);
|
54
common/app/Nav/Sign-Up.jsx
Normal file
54
common/app/Nav/Sign-Up.jsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { NavItem } from 'react-bootstrap';
|
||||
|
||||
// this is separated out to prevent react bootstrap's
|
||||
// NavBar from injecting unknown props to the li component
|
||||
|
||||
const propTypes = {
|
||||
picture: PropTypes.string,
|
||||
points: PropTypes.number,
|
||||
showLoading: PropTypes.bool,
|
||||
username: PropTypes.string
|
||||
};
|
||||
|
||||
export default function SignUpButton({
|
||||
picture,
|
||||
points,
|
||||
showLoading,
|
||||
username
|
||||
}) {
|
||||
if (showLoading) {
|
||||
return null;
|
||||
}
|
||||
if (!username) {
|
||||
return (
|
||||
<NavItem
|
||||
href='/signup'
|
||||
key='signup'
|
||||
>
|
||||
Sign Up
|
||||
</NavItem>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li
|
||||
className='nav-avatar'
|
||||
key='user'
|
||||
>
|
||||
<Link to='/settings'>
|
||||
<span className='nav-username hidden-md hidden-lg'> { username } </span>
|
||||
<span className='nav-points'> [ { points || 1 } ] </span>
|
||||
<span className='nav-picture-container hidden-xs hidden-sm'>
|
||||
<img
|
||||
className='nav-picture float-right'
|
||||
src={ picture }
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
SignUpButton.displayName = 'SignUpButton';
|
||||
SignUpButton.propTypes = propTypes;
|
@ -34,11 +34,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "Map",
|
||||
"link": "/map",
|
||||
"isReact": true
|
||||
},
|
||||
{
|
||||
"content": "Donate",
|
||||
"link": "https://www.freecodecamp.com/donate"
|
196
common/app/Nav/nav.less
Normal file
196
common/app/Nav/nav.less
Normal file
@ -0,0 +1,196 @@
|
||||
.navbar {
|
||||
background-color: @brand-primary;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.navbar-nav > li > a {
|
||||
color: @body-bg;
|
||||
&:hover {
|
||||
color: @brand-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar > .container {
|
||||
padding-right: 0px;
|
||||
width: auto;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
// container default padding size
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-height {
|
||||
border: none;
|
||||
height: @navbar-height;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@navbar-logo-height: 25px;
|
||||
@navbar-logo-padding: (@navbar-height - @navbar-logo-height) / 2;
|
||||
.navbar-brand {
|
||||
padding-top: @navbar-logo-padding;
|
||||
padding-bottom: @navbar-logo-padding;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
height: @navbar-logo-height;
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
background-color: @brand-primary;
|
||||
text-align: center;
|
||||
|
||||
@media (min-width: @screen-md-min) {
|
||||
margin-right:0;
|
||||
}
|
||||
@media (min-width: @screen-md-max) and (max-width: 991px) {
|
||||
left: 0;
|
||||
margin-right: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
line-height: 1;
|
||||
@media (min-width: 767px) {
|
||||
padding-left: 15px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
// li is used here to get more specific
|
||||
// and win against navbar.less#273
|
||||
li.nav-avatar {
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
@media (min-width: @screen-sm-min) {
|
||||
height: @navbar-height;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
> a {
|
||||
margin: 0;
|
||||
padding: 0 @navbar-padding-horizontal 0 @navbar-padding-horizontal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-username {
|
||||
padding-right: @navbar-padding-horizontal;
|
||||
}
|
||||
|
||||
.nav-points {
|
||||
@media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {
|
||||
padding: @navbar-padding-vertical 0 @navbar-padding-vertical 0;
|
||||
}
|
||||
@media (min-width: @screen-md-min) {
|
||||
padding: @navbar-padding-vertical @navbar-padding-horizontal @navbar-padding-vertical 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-picture {
|
||||
margin-top: @navbar-logo-padding;
|
||||
margin-bottom: @navbar-logo-padding;
|
||||
height: @navbar-logo-height;
|
||||
width: @navbar-logo-height;
|
||||
}
|
||||
|
||||
.navbar-nav a {
|
||||
color: @body-bg;
|
||||
margin-top: -5px;
|
||||
margin-bottom: -5px;
|
||||
}
|
||||
|
||||
.navbar-toggle {
|
||||
color: @body-bg;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: #4a2b0f;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-collapse {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: @screen-xs-max) {
|
||||
.navbar-header {
|
||||
float: none;
|
||||
}
|
||||
|
||||
.navbar-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar-collapse.collapse {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.navbar-nav > li {
|
||||
float: none;
|
||||
}
|
||||
|
||||
.navbar-nav > li > a {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.navbar-text {
|
||||
float: none;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
/* since 3.1.0 */
|
||||
.navbar-collapse.collapse.in {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.collapsing {
|
||||
overflow: hidden !important;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.night {
|
||||
.navbar-default {
|
||||
.navbar-nav {
|
||||
& > li > a {
|
||||
color: #CCC;
|
||||
}
|
||||
.dropdown-menu {
|
||||
background-color: @gray;
|
||||
a {
|
||||
color: @night-text-color !important;
|
||||
}
|
||||
}
|
||||
a:focus,
|
||||
a:hover,
|
||||
.open #nav-Community-dropdown {
|
||||
background-color: #666 !important;
|
||||
color: @link-hover-color !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.navbar-toggle {
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: #666;
|
||||
color: @link-hover-color;
|
||||
border-color: #666;
|
||||
}
|
||||
}
|
||||
}
|
1
common/app/Nav/ns.json
Normal file
1
common/app/Nav/ns.json
Normal file
@ -0,0 +1 @@
|
||||
"nav"
|
10
common/app/Nav/redux/bin-epic.js
Normal file
10
common/app/Nav/redux/bin-epic.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { ofType } from 'redux-epic';
|
||||
|
||||
import { types } from './';
|
||||
|
||||
import { hidePane } from '../../Panes/redux';
|
||||
|
||||
export default function binEpic(actions) {
|
||||
return actions::ofType(types.clickOnMap)
|
||||
.map(() => hidePane('Map'));
|
||||
}
|
79
common/app/Nav/redux/index.js
Normal file
79
common/app/Nav/redux/index.js
Normal file
@ -0,0 +1,79 @@
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { createTypes } from 'redux-create-types';
|
||||
import { createAction, handleActions } from 'redux-actions';
|
||||
import noop from 'lodash/noop';
|
||||
|
||||
import loadCurrentChallengeEpic from './load-current-challenge-epic.js';
|
||||
import binEpic from './bin-epic.js';
|
||||
import ns from '../ns.json';
|
||||
import { createEventMetaCreator } from '../../redux';
|
||||
|
||||
export const epics = [
|
||||
loadCurrentChallengeEpic,
|
||||
binEpic
|
||||
];
|
||||
|
||||
export const types = createTypes([
|
||||
'clickOnLogo',
|
||||
'clickOnMap',
|
||||
'navLinkClicked',
|
||||
|
||||
'closeDropdown',
|
||||
'openDropdown'
|
||||
], ns);
|
||||
|
||||
export const clickOnLogo = createAction(
|
||||
types.clickOnLogo,
|
||||
noop,
|
||||
createEventMetaCreator({
|
||||
category: 'Nav',
|
||||
action: 'clicked',
|
||||
label: 'fcc logo clicked'
|
||||
})
|
||||
);
|
||||
|
||||
export const clickOnMap = createAction(
|
||||
types.clickOnMap,
|
||||
noop,
|
||||
createEventMetaCreator({
|
||||
category: 'Nav',
|
||||
action: 'clicked',
|
||||
label: 'map button clicked'
|
||||
})
|
||||
);
|
||||
|
||||
export const closeDropdown = createAction(types.closeDropdown);
|
||||
export const openDropdown = createAction(types.openDropdown);
|
||||
export function createNavLinkActionCreator(link) {
|
||||
return createAction(
|
||||
types.navLinkClicked,
|
||||
noop,
|
||||
createEventMetaCreator({
|
||||
category: capitalize(ns),
|
||||
action: 'click',
|
||||
label: `${link} link`
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
isDropdownOpen: false
|
||||
};
|
||||
|
||||
export const dropdownSelector = state => state[ns].isDropdownOpen;
|
||||
|
||||
export default function createReducer() {
|
||||
const reducer = handleActions({
|
||||
[types.closeDropdown]: state => ({
|
||||
...state,
|
||||
isDropdownOpen: false
|
||||
}),
|
||||
[types.openDropdown]: state => ({
|
||||
...state,
|
||||
isDropdownOpen: true
|
||||
})
|
||||
}, initialState);
|
||||
|
||||
reducer.toString = () => ns;
|
||||
return reducer;
|
||||
}
|
61
common/app/Nav/redux/load-current-challenge-epic.js
Normal file
61
common/app/Nav/redux/load-current-challenge-epic.js
Normal file
@ -0,0 +1,61 @@
|
||||
import { ofType } from 'redux-epic';
|
||||
|
||||
import { types } from './';
|
||||
import {
|
||||
updateCurrentChallenge,
|
||||
|
||||
userSelector,
|
||||
firstChallengeSelector,
|
||||
challengeSelector
|
||||
} from '../../redux';
|
||||
import { entitiesSelector } from '../../entities';
|
||||
|
||||
export default function loadCurrentChallengeEpic(actions, { getState }) {
|
||||
return actions::ofType(types.clickOnLogo)
|
||||
.debounce(500)
|
||||
.map(() => {
|
||||
let finalChallenge;
|
||||
const state = getState();
|
||||
const { id: currentlyLoadedChallengeId } = challengeSelector(state);
|
||||
const {
|
||||
challenge: challengeMap,
|
||||
challengeIdToName
|
||||
} = entitiesSelector(state);
|
||||
const {
|
||||
routing: {
|
||||
locationBeforeTransitions: { pathname } = {}
|
||||
}
|
||||
} = state;
|
||||
const firstChallenge = firstChallengeSelector(state);
|
||||
const { currentChallengeId } = userSelector(state);
|
||||
const isOnAChallenge = (/^\/[^\/]{2,6}\/challenges/).test(pathname);
|
||||
|
||||
if (!currentChallengeId) {
|
||||
finalChallenge = firstChallenge;
|
||||
} else {
|
||||
finalChallenge = challengeMap[
|
||||
challengeIdToName[ currentChallengeId ]
|
||||
];
|
||||
}
|
||||
return {
|
||||
finalChallenge,
|
||||
isOnAChallenge,
|
||||
currentlyLoadedChallengeId
|
||||
};
|
||||
})
|
||||
.filter(({
|
||||
finalChallenge,
|
||||
isOnAChallenge,
|
||||
currentlyLoadedChallengeId
|
||||
}) => (
|
||||
// data might not be there yet, filter out for now
|
||||
!!finalChallenge &&
|
||||
// are we already on that challenge? if not load challenge
|
||||
(!isOnAChallenge || finalChallenge.id !== currentlyLoadedChallengeId)
|
||||
// don't reload if the challenge is already loaded.
|
||||
// This may change to toast to avoid user confusion
|
||||
))
|
||||
.map(({ finalChallenge }) => {
|
||||
return updateCurrentChallenge(finalChallenge.dashedName);
|
||||
});
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { hardGoTo } from '../../redux/actions';
|
||||
import { hardGoTo } from '../redux';
|
||||
|
||||
const propTypes = {
|
||||
hardGoTo: PropTypes.func,
|
||||
@ -8,8 +8,6 @@ const propTypes = {
|
||||
};
|
||||
|
||||
export class NotFound extends React.Component {
|
||||
|
||||
|
||||
componentWillMount() {
|
||||
this.props.hardGoTo(this.props.location.pathname);
|
||||
}
|
1
common/app/NotFound/index.js
Normal file
1
common/app/NotFound/index.js
Normal file
@ -0,0 +1 @@
|
||||
export default from './Not-Found.jsx';
|
50
common/app/Panes/Divider.jsx
Normal file
50
common/app/Panes/Divider.jsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { dividerClicked } from './redux';
|
||||
|
||||
const mapStateToProps = null;
|
||||
function mapDispatchToProps(dispatch, { name }) {
|
||||
const dispatchers = {
|
||||
dividerClicked: () => dispatch(dividerClicked(name))
|
||||
};
|
||||
return () => dispatchers;
|
||||
}
|
||||
|
||||
const propTypes = {
|
||||
dividerClicked: PropTypes.func.isRequired,
|
||||
left: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export function Divider({ left, dividerClicked }) {
|
||||
const style = {
|
||||
borderLeft: '1px solid rgb(204, 204, 204)',
|
||||
bottom: 0,
|
||||
cursor: 'col-resize',
|
||||
height: '100%',
|
||||
left: left + '%',
|
||||
marginLeft: '-4px',
|
||||
position: 'absolute',
|
||||
right: 'auto',
|
||||
top: 0,
|
||||
width: '8px',
|
||||
zIndex: 100
|
||||
};
|
||||
// use onMouseDown as onClick does not fire
|
||||
// until onMouseUp
|
||||
// note(berks): do we need touch support?
|
||||
return (
|
||||
<div
|
||||
onMouseDown={ dividerClicked }
|
||||
style={ style }
|
||||
/>
|
||||
);
|
||||
}
|
||||
Divider.displayName = 'Divider';
|
||||
Divider.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Divider);
|
38
common/app/Panes/Pane.jsx
Normal file
38
common/app/Panes/Pane.jsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
const mapStateToProps = null;
|
||||
const mapDispatchToProps = null;
|
||||
const propTypes = {
|
||||
children: PropTypes.element,
|
||||
left: PropTypes.number.isRequired,
|
||||
right: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export function Pane({
|
||||
children,
|
||||
left,
|
||||
right
|
||||
}) {
|
||||
const style = {
|
||||
bottom: 0,
|
||||
left: left + '%',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
position: 'absolute',
|
||||
right: right + '%',
|
||||
top: 0
|
||||
};
|
||||
return (
|
||||
<div style={ style }>
|
||||
{ children }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Pane.displayName = 'Pane';
|
||||
Pane.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Pane);
|
58
common/app/Panes/Panes-Container.jsx
Normal file
58
common/app/Panes/Panes-Container.jsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React, { PureComponent, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import Panes from './Panes.jsx';
|
||||
import {
|
||||
panesMounted,
|
||||
panesUpdated,
|
||||
panesWillMount,
|
||||
panesWillUnmount
|
||||
} from './redux';
|
||||
|
||||
const mapStateToProps = null;
|
||||
const mapDispatchToProps = {
|
||||
panesMounted,
|
||||
panesUpdated,
|
||||
panesWillMount,
|
||||
panesWillUnmount
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
nameToComponent: PropTypes.object.isRequired,
|
||||
panesMounted: PropTypes.func.isRequired,
|
||||
panesUpdated: PropTypes.func.isRequired,
|
||||
panesWillMount: PropTypes.func.isRequired,
|
||||
panesWillUnmount: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export class PanesContainer extends PureComponent {
|
||||
componentWillMount() {
|
||||
this.props.panesWillMount(Object.keys(this.props.nameToComponent));
|
||||
}
|
||||
componentDidMount() {
|
||||
this.props.panesMounted();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.panesWillUnmount();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.nameToComponent !== this.props.nameToComponent) {
|
||||
this.props.panesUpdated(Object.keys(nextProps.nameToComponent));
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Panes { ...this.props } />
|
||||
);
|
||||
}
|
||||
}
|
||||
PanesContainer.displayName = 'PanesContainer';
|
||||
PanesContainer.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(PanesContainer);
|
111
common/app/Panes/Panes.jsx
Normal file
111
common/app/Panes/Panes.jsx
Normal file
@ -0,0 +1,111 @@
|
||||
import React, { PureComponent, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import {
|
||||
panesSelector,
|
||||
panesByNameSelector,
|
||||
heightSelector,
|
||||
widthSelector
|
||||
} from './redux';
|
||||
import Pane from './Pane.jsx';
|
||||
import Divider from './Divider.jsx';
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
panesSelector,
|
||||
panesByNameSelector,
|
||||
heightSelector,
|
||||
widthSelector,
|
||||
(panes, panesByName, height) => {
|
||||
let lastDividerPosition = 0;
|
||||
return {
|
||||
panes: panes
|
||||
.map(name => panesByName[name])
|
||||
.filter(({ isHidden })=> !isHidden)
|
||||
.map((pane, index, { length: numOfPanes }) => {
|
||||
const dividerLeft = pane.dividerLeft || 0;
|
||||
const left = lastDividerPosition;
|
||||
lastDividerPosition = dividerLeft;
|
||||
return {
|
||||
...pane,
|
||||
left: index === 0 ? 0 : left,
|
||||
right: index + 1 === numOfPanes ? 0 : 100 - dividerLeft
|
||||
};
|
||||
}, {}),
|
||||
height
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const mapDispatchToProps = null;
|
||||
|
||||
const propTypes = {
|
||||
height: PropTypes.number.isRequired,
|
||||
nameToComponent: PropTypes.object.isRequired,
|
||||
panes: PropTypes.array
|
||||
};
|
||||
|
||||
export class Panes extends PureComponent {
|
||||
renderPanes() {
|
||||
const {
|
||||
nameToComponent,
|
||||
panes
|
||||
} = this.props;
|
||||
return panes.map(({ name, left, right, dividerLeft }) => {
|
||||
const { Component } = nameToComponent[name] || {};
|
||||
const FinalComponent = Component ? Component : 'span';
|
||||
const divider = dividerLeft ?
|
||||
(
|
||||
<Divider
|
||||
key={ name + 'divider' }
|
||||
left={ dividerLeft }
|
||||
name={ name }
|
||||
/>
|
||||
) :
|
||||
null;
|
||||
|
||||
return [
|
||||
<Pane
|
||||
key={ name }
|
||||
left={ left }
|
||||
right={ right }
|
||||
>
|
||||
<FinalComponent />
|
||||
</Pane>,
|
||||
divider
|
||||
];
|
||||
}).reduce((panes, pane) => panes.concat(pane), [])
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { height } = this.props;
|
||||
const outerStyle = {
|
||||
height,
|
||||
position: 'relative',
|
||||
width: '100%'
|
||||
};
|
||||
const innerStyle = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0
|
||||
};
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<div style={innerStyle}>
|
||||
{ this.renderPanes() }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Panes.displayName = 'Panes';
|
||||
Panes.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Panes);
|
1
common/app/Panes/index.js
Normal file
1
common/app/Panes/index.js
Normal file
@ -0,0 +1 @@
|
||||
export default from './Panes-Container.jsx';
|
1
common/app/Panes/ns.json
Normal file
1
common/app/Panes/ns.json
Normal file
@ -0,0 +1 @@
|
||||
"panes"
|
40
common/app/Panes/redux/divider-epic.js
Normal file
40
common/app/Panes/redux/divider-epic.js
Normal file
@ -0,0 +1,40 @@
|
||||
import 'rx-dom';
|
||||
import { Observable, Scheduler } from 'rx';
|
||||
import { combineEpics, ofType } from 'redux-epic';
|
||||
|
||||
import {
|
||||
types,
|
||||
|
||||
mouseReleased,
|
||||
dividerMoved,
|
||||
|
||||
pressedDividerSelector
|
||||
} from './';
|
||||
|
||||
export function dividerReleasedEpic(actions, _, { document }) {
|
||||
return actions::ofType(types.dividerClicked)
|
||||
.switchMap(() => Observable.fromEvent(document, 'mouseup')
|
||||
.map(() => mouseReleased())
|
||||
// allow mouse up on divider to go first
|
||||
.delay(1)
|
||||
.takeUntil(actions::ofType(types.mouseReleased))
|
||||
);
|
||||
}
|
||||
|
||||
export function dividerMovedEpic(actions, { getState }, { document }) {
|
||||
return actions::ofType(types.dividerClicked)
|
||||
.switchMap(() => Observable.fromEvent(document, 'mousemove')
|
||||
// prevent mouse drags from highlighting text
|
||||
.do(e => e.preventDefault())
|
||||
.map(({ clientX }) => clientX)
|
||||
.throttle(1, Scheduler.requestAnimationFrame)
|
||||
.filter(() => {
|
||||
const divider = pressedDividerSelector(getState());
|
||||
return !!divider || divider === 0;
|
||||
})
|
||||
.map(dividerMoved)
|
||||
.takeUntil(actions::ofType(types.mouseReleased))
|
||||
);
|
||||
}
|
||||
|
||||
export default combineEpics(dividerReleasedEpic, dividerMovedEpic);
|
236
common/app/Panes/redux/index.js
Normal file
236
common/app/Panes/redux/index.js
Normal file
@ -0,0 +1,236 @@
|
||||
import { combineActions, createAction, handleActions } from 'redux-actions';
|
||||
import { createTypes } from 'redux-create-types';
|
||||
import clamp from 'lodash/clamp';
|
||||
|
||||
import ns from '../ns.json';
|
||||
|
||||
import windowEpic from './window-epic.js';
|
||||
import dividerEpic from './divider-epic.js';
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
export const epics = [
|
||||
windowEpic,
|
||||
dividerEpic
|
||||
];
|
||||
|
||||
export const types = createTypes([
|
||||
'panesMounted',
|
||||
'panesUpdated',
|
||||
'panesWillMount',
|
||||
'panesWillUnmount',
|
||||
'updateSize',
|
||||
|
||||
'dividerClicked',
|
||||
'dividerMoved',
|
||||
'mouseReleased',
|
||||
'windowResized',
|
||||
|
||||
// commands
|
||||
'updateNavHeight',
|
||||
'hidePane'
|
||||
], ns);
|
||||
|
||||
export const panesMounted = createAction(types.panesMounted);
|
||||
export const panesUpdated = createAction(types.panesUpdated);
|
||||
export const panesWillMount = createAction(types.panesWillMount);
|
||||
export const panesWillUnmount = createAction(types.panesWillUnmount);
|
||||
|
||||
export const dividerClicked = createAction(types.dividerClicked);
|
||||
export const dividerMoved = createAction(types.dividerMoved);
|
||||
export const mouseReleased = createAction(types.mouseReleased);
|
||||
export const windowResized = createAction(types.windowResized);
|
||||
|
||||
// commands
|
||||
export const updateNavHeight = createAction(types.updateNavHeight);
|
||||
export const hidePane = createAction(types.hidePane);
|
||||
|
||||
const initialState = {
|
||||
height: 600,
|
||||
width: 800,
|
||||
navHeight: 50,
|
||||
panes: [],
|
||||
panesByName: {},
|
||||
pressedDivider: null,
|
||||
nameToType: {}
|
||||
};
|
||||
export const getNS = state => state[ns];
|
||||
export const heightSelector = state => {
|
||||
const { navHeight, height } = getNS(state);
|
||||
return height - navHeight;
|
||||
};
|
||||
|
||||
export const panesSelector = state => getNS(state).panes;
|
||||
export const panesByNameSelector = state => getNS(state).panesByName;
|
||||
export const pressedDividerSelector =
|
||||
state => getNS(state).pressedDivider;
|
||||
export const widthSelector = state => getNS(state).width;
|
||||
export const nameToTypeSelector = state => getNS(state).nameToType;
|
||||
|
||||
function isPanesAction({ type } = {}, typeToName) {
|
||||
return !!typeToName[type];
|
||||
}
|
||||
|
||||
function getDividerLeft(numOfPanes, index) {
|
||||
let dividerLeft = null;
|
||||
if (numOfPanes > 1 && numOfPanes !== index + 1) {
|
||||
dividerLeft = (100 / numOfPanes) * (index + 1);
|
||||
}
|
||||
return dividerLeft;
|
||||
}
|
||||
|
||||
export default function createPanesAspects(typeToName) {
|
||||
if (isDev) {
|
||||
Object.keys(typeToName).forEach(actionType => {
|
||||
if (actionType === 'undefined') {
|
||||
throw new Error(
|
||||
`action type for ${typeToName[actionType]} is undefined`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
const nameToType = Object.keys(typeToName).reduce((map, type) => {
|
||||
map[typeToName[type]] = type;
|
||||
return map;
|
||||
}, {});
|
||||
function getInitialState() {
|
||||
return {
|
||||
...initialState,
|
||||
nameToType
|
||||
};
|
||||
}
|
||||
|
||||
function middleware() {
|
||||
return next => action => {
|
||||
let finalAction = action;
|
||||
if (isPanesAction(action, typeToName)) {
|
||||
finalAction = {
|
||||
...action,
|
||||
meta: {
|
||||
...action.meta,
|
||||
isPaneAction: true
|
||||
}
|
||||
};
|
||||
}
|
||||
return next(finalAction);
|
||||
};
|
||||
}
|
||||
|
||||
const reducer = handleActions({
|
||||
[types.dividerClicked]: (state, { payload: name }) => ({
|
||||
...state,
|
||||
pressedDivider: name
|
||||
}),
|
||||
[types.dividerMoved]: (state, { payload: clientX }) => {
|
||||
const { width, pressedDivider: paneName } = state;
|
||||
const dividerBuffer = (200 / width) * 100;
|
||||
const paneIndex = state.panes.indexOf(paneName);
|
||||
const currentPane = state.panesByName[paneName];
|
||||
const rightPane = state.panesByName[state.panes[paneIndex + 1]] || {};
|
||||
const leftPane = state.panesByName[state.panes[paneIndex - 1]] || {};
|
||||
const rightBound = (rightPane.dividerLeft || 100) - dividerBuffer;
|
||||
const leftBound = (leftPane.dividerLeft || 0) + dividerBuffer;
|
||||
const newPosition = clamp(
|
||||
(clientX / width) * 100,
|
||||
leftBound,
|
||||
rightBound
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
panesByName: {
|
||||
...state.panesByName,
|
||||
[currentPane.name]: {
|
||||
...currentPane,
|
||||
dividerLeft: newPosition
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[types.mouseReleased]: state => ({ ...state, pressedDivider: null }),
|
||||
[types.windowResized]: (state, { payload: { height, width } }) => ({
|
||||
...state,
|
||||
height,
|
||||
width
|
||||
}),
|
||||
// used to clear bin buttons
|
||||
[types.panesWillUnmount]: state => ({
|
||||
...state,
|
||||
panes: [],
|
||||
panesByName: {},
|
||||
pressedDivider: null
|
||||
}),
|
||||
[
|
||||
combineActions(
|
||||
panesWillMount,
|
||||
panesUpdated
|
||||
)
|
||||
]: (state, { payload: panes }) => {
|
||||
const numOfPanes = panes.length;
|
||||
return {
|
||||
...state,
|
||||
panes,
|
||||
panesByName: panes.reduce((panes, name, index) => {
|
||||
const dividerLeft = getDividerLeft(numOfPanes, index);
|
||||
panes[name] = {
|
||||
name,
|
||||
dividerLeft,
|
||||
isHidden: false
|
||||
};
|
||||
return panes;
|
||||
}, {})
|
||||
};
|
||||
},
|
||||
[types.updateNavHeight]: (state, { payload: navHeight }) => ({
|
||||
...state,
|
||||
navHeight
|
||||
})
|
||||
}, getInitialState());
|
||||
function metaReducer(state = getInitialState(), action) {
|
||||
if (action.meta && action.meta.isPaneAction) {
|
||||
const name = typeToName[action.type];
|
||||
const oldPane = state.panesByName[name];
|
||||
const pane = {
|
||||
...oldPane,
|
||||
isHidden: !oldPane.isHidden
|
||||
};
|
||||
const panesByName = {
|
||||
...state.panesByName,
|
||||
[name]: pane
|
||||
};
|
||||
const numOfPanes = state.panes.reduce((sum, name) => {
|
||||
return panesByName[name].isHidden ? sum : sum + 1;
|
||||
}, 0);
|
||||
let numOfHidden = 0;
|
||||
return {
|
||||
...state,
|
||||
panesByName: state.panes.reduce(
|
||||
(panesByName, name, index) => {
|
||||
if (!panesByName[name].isHidden) {
|
||||
const dividerLeft = getDividerLeft(
|
||||
numOfPanes,
|
||||
index - numOfHidden
|
||||
);
|
||||
panesByName[name] = {
|
||||
...panesByName[name],
|
||||
dividerLeft
|
||||
};
|
||||
} else {
|
||||
numOfHidden = numOfHidden + 1;
|
||||
}
|
||||
return panesByName;
|
||||
},
|
||||
panesByName
|
||||
)
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function finalReducer(state, action) {
|
||||
return reducer(metaReducer(state, action), action);
|
||||
}
|
||||
finalReducer.toString = () => ns;
|
||||
return {
|
||||
reducer: finalReducer,
|
||||
middleware
|
||||
};
|
||||
}
|
21
common/app/Panes/redux/window-epic.js
Normal file
21
common/app/Panes/redux/window-epic.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { Observable } from 'rx';
|
||||
import { ofType } from 'redux-epic';
|
||||
|
||||
import {
|
||||
types,
|
||||
windowResized
|
||||
} from './';
|
||||
|
||||
export default function windowEpic(actions, _, { window }) {
|
||||
return actions::ofType(types.panesMounted)
|
||||
.switchMap(() => {
|
||||
return Observable.fromEvent(window, 'resize', () => windowResized({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
}))
|
||||
.startWith(windowResized({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
}));
|
||||
});
|
||||
}
|
@ -3,11 +3,11 @@ import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { NotificationStack } from 'react-notification';
|
||||
|
||||
import { removeToast } from './redux/actions';
|
||||
import { removeToast } from './redux';
|
||||
import {
|
||||
submitChallenge,
|
||||
resetChallenge
|
||||
} from '../routes/challenges/redux/actions';
|
||||
} from '../routes/challenges/redux';
|
||||
|
||||
const registeredActions = {
|
||||
submitChallenge,
|
1
common/app/Toasts/index.js
Normal file
1
common/app/Toasts/index.js
Normal file
@ -0,0 +1 @@
|
||||
export default from './Toasts.jsx';
|
1
common/app/Toasts/ns.json
Normal file
1
common/app/Toasts/ns.json
Normal file
@ -0,0 +1 @@
|
||||
"toasts"
|
45
common/app/Toasts/redux/index.js
Normal file
45
common/app/Toasts/redux/index.js
Normal file
@ -0,0 +1,45 @@
|
||||
import { createTypes } from 'redux-create-types';
|
||||
import { createAction, handleActions } from 'redux-actions';
|
||||
|
||||
import ns from '../ns.json';
|
||||
|
||||
export const types = createTypes([
|
||||
'makeToast',
|
||||
'removeToast'
|
||||
], 'toast');
|
||||
|
||||
let key = 0;
|
||||
export const makeToast = createAction(
|
||||
types.makeToast,
|
||||
({ timeout, ...rest }) => ({
|
||||
...rest,
|
||||
// assign current value of key to new toast
|
||||
// and then increment key value
|
||||
key: key++,
|
||||
dismissAfter: timeout || 6000,
|
||||
position: rest.position === 'left' ? 'left' : 'right'
|
||||
})
|
||||
);
|
||||
|
||||
export const removeToast = createAction(
|
||||
types.removeToast,
|
||||
({ key }) => key
|
||||
);
|
||||
|
||||
|
||||
const initialState = [];
|
||||
|
||||
export default function createReducer() {
|
||||
const reducer = handleActions({
|
||||
[types.makeToast]: (state, { payload: toast }) => [
|
||||
...state,
|
||||
toast
|
||||
],
|
||||
[types.removeToast]: (state, { payload: key }) => state.filter(
|
||||
toast => toast.key !== key
|
||||
)
|
||||
}, initialState);
|
||||
|
||||
reducer.toString = () => ns;
|
||||
return reducer;
|
||||
}
|
@ -2,13 +2,22 @@
|
||||
@ns: app;
|
||||
|
||||
.@{ns}-container {
|
||||
.column();
|
||||
// we invert the nav and content since
|
||||
// the content needs to render first
|
||||
// Here we invert the order in which
|
||||
// they are painted using css so the
|
||||
// nav is on top again
|
||||
.grid(@direction: column-reverse);
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.@{ns}-content {
|
||||
.center(@value: @container-xl, @padding: @grid-gutter-width);
|
||||
// makes the inital content height 0px
|
||||
// then lets it grow to fit the rest of the space
|
||||
flex: 1 0 0px;
|
||||
}
|
||||
|
||||
.@{ns}-centered {
|
||||
.center(@value: @container-xl, @padding: @grid-gutter-width);
|
||||
margin-top: @navbar-margin-bottom;
|
||||
}
|
||||
|
@ -1,36 +0,0 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
// this is separated out to prevent react bootstrap's
|
||||
// NavBar from injecting unknown props to the li component
|
||||
|
||||
const propTypes = {
|
||||
picture: PropTypes.string,
|
||||
points: PropTypes.number,
|
||||
username: PropTypes.string
|
||||
};
|
||||
|
||||
export default function AvatarPointsNavItem({ picture, points, username }) {
|
||||
return (
|
||||
<li
|
||||
className='avatar-points'
|
||||
key='user'
|
||||
>
|
||||
<Link to='/settings'>
|
||||
<span className='brownie-points-nav'>
|
||||
<span className='hidden-md hidden-lg'> { username } </span>
|
||||
<span className='brownie-points'> [ { points || 1 } ] </span>
|
||||
</span>
|
||||
<span className='hidden-xs hidden-sm avatar'>
|
||||
<img
|
||||
className='profile-picture float-right'
|
||||
src={ picture }
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
AvatarPointsNavItem.displayName = 'AvatarPointsNavItem';
|
||||
AvatarPointsNavItem.propTypes = propTypes;
|
@ -1,205 +0,0 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { LinkContainer } from 'react-router-bootstrap';
|
||||
import {
|
||||
Col,
|
||||
MenuItem,
|
||||
Nav,
|
||||
NavDropdown,
|
||||
NavItem,
|
||||
Navbar,
|
||||
NavbarBrand
|
||||
} from 'react-bootstrap';
|
||||
import noop from 'lodash/noop';
|
||||
|
||||
import navLinks from './links.json';
|
||||
import AvatarPointsNavItem from './Avatar-Points-Nav-Item.jsx';
|
||||
|
||||
const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg';
|
||||
|
||||
const toggleButtonChild = (
|
||||
<Col xs={ 12 }>
|
||||
<span className='hamburger-text'>Menu</span>
|
||||
</Col>
|
||||
);
|
||||
|
||||
function handleNavLinkEvent(content) {
|
||||
this.props.trackEvent({
|
||||
category: 'Nav',
|
||||
action: 'clicked',
|
||||
label: `${content} link`
|
||||
});
|
||||
}
|
||||
|
||||
const propTypes = {
|
||||
closeDropdown: PropTypes.func.isRequired,
|
||||
isNavDropdownOpen: PropTypes.bool,
|
||||
loadCurrentChallenge: PropTypes.func.isRequired,
|
||||
openDropdown: PropTypes.func.isRequired,
|
||||
picture: PropTypes.string,
|
||||
points: PropTypes.number,
|
||||
showLoading: PropTypes.bool,
|
||||
signedIn: PropTypes.bool,
|
||||
trackEvent: PropTypes.func.isRequired,
|
||||
username: PropTypes.string
|
||||
};
|
||||
|
||||
export default class FCCNav extends React.Component {
|
||||
constructor(...props) {
|
||||
super(...props);
|
||||
this.handleMapClickOnMap = this.handleMapClickOnMap.bind(this);
|
||||
this.handleLogoClick = this.handleLogoClick.bind(this);
|
||||
navLinks.forEach(({ content }) => {
|
||||
this[`handle${content}Click`] = handleNavLinkEvent.bind(this, content);
|
||||
});
|
||||
}
|
||||
|
||||
handleMapClickOnMap(e) {
|
||||
e.preventDefault();
|
||||
this.props.trackEvent({
|
||||
category: 'Nav',
|
||||
action: 'clicked',
|
||||
label: 'map clicked while on map'
|
||||
});
|
||||
}
|
||||
|
||||
handleNavClick() {
|
||||
this.props.trackEvent({
|
||||
category: 'Nav',
|
||||
action: 'clicked',
|
||||
label: 'map clicked while on map'
|
||||
});
|
||||
}
|
||||
|
||||
handleLogoClick(e) {
|
||||
e.preventDefault();
|
||||
this.props.loadCurrentChallenge();
|
||||
}
|
||||
|
||||
renderLink(isNavItem, { isReact, isDropdown, content, link, links, target }) {
|
||||
const Component = isNavItem ? NavItem : MenuItem;
|
||||
const {
|
||||
isNavDropdownOpen,
|
||||
openDropdown,
|
||||
closeDropdown
|
||||
} = this.props;
|
||||
|
||||
if (isDropdown) {
|
||||
// adding a noop to NavDropdown to disable false warning
|
||||
// about controlled component
|
||||
return (
|
||||
<NavDropdown
|
||||
id={ `nav-${content}-dropdown` }
|
||||
key={ content }
|
||||
noCaret={ true }
|
||||
onClick={ openDropdown }
|
||||
onClose={ closeDropdown }
|
||||
onMouseEnter={ openDropdown }
|
||||
onMouseLeave={ closeDropdown }
|
||||
onToggle={ noop }
|
||||
open={ isNavDropdownOpen }
|
||||
title={ content }
|
||||
>
|
||||
{ links.map(this.renderLink.bind(this, false)) }
|
||||
</NavDropdown>
|
||||
);
|
||||
}
|
||||
if (isReact) {
|
||||
return (
|
||||
<LinkContainer
|
||||
key={ content }
|
||||
onClick={ this[`handle${content}Click`] }
|
||||
to={ link }
|
||||
>
|
||||
<Component
|
||||
target={ target || null }
|
||||
>
|
||||
{ content }
|
||||
</Component>
|
||||
</LinkContainer>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Component
|
||||
href={ link }
|
||||
key={ content }
|
||||
onClick={ this[`handle${content}Click`] }
|
||||
target={ target || null }
|
||||
>
|
||||
{ content }
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
renderSignIn(username, points, picture, showLoading) {
|
||||
if (showLoading) {
|
||||
return null;
|
||||
}
|
||||
if (username) {
|
||||
return (
|
||||
<AvatarPointsNavItem
|
||||
picture={ picture }
|
||||
points={ points }
|
||||
username={ username }
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<NavItem
|
||||
href='/signup'
|
||||
key='signup'
|
||||
>
|
||||
Sign Up
|
||||
</NavItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
username,
|
||||
points,
|
||||
picture,
|
||||
showLoading
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Navbar
|
||||
className='nav-height'
|
||||
staticTop={ true }
|
||||
>
|
||||
<Navbar.Header>
|
||||
<Navbar.Toggle children={ toggleButtonChild } />
|
||||
<NavbarBrand>
|
||||
<a
|
||||
href='/challenges/current-challenge'
|
||||
onClick={ this.handleLogoClick }
|
||||
>
|
||||
<img
|
||||
alt='learn to code javascript at freeCodeCamp logo'
|
||||
className='img-responsive nav-logo'
|
||||
src={ fCClogo }
|
||||
/>
|
||||
</a>
|
||||
</NavbarBrand>
|
||||
</Navbar.Header>
|
||||
<Navbar.Collapse>
|
||||
<Nav
|
||||
className='hamburger-dropdown'
|
||||
navbar={ true }
|
||||
pullRight={ true }
|
||||
>
|
||||
{
|
||||
navLinks.map(
|
||||
this.renderLink.bind(this, true)
|
||||
)
|
||||
}
|
||||
{ this.renderSignIn(username, points, picture, showLoading) }
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FCCNav.displayName = 'FCCNav';
|
||||
FCCNav.propTypes = propTypes;
|
@ -1 +0,0 @@
|
||||
things like NavBar and Footer go here
|
@ -2,17 +2,13 @@ import { Observable } from 'rx';
|
||||
import { match } from 'react-router';
|
||||
import { compose, createStore, applyMiddleware } from 'redux';
|
||||
|
||||
// main app
|
||||
import App from './App.jsx';
|
||||
// app routes
|
||||
import createChildRoute from './routes';
|
||||
|
||||
// redux
|
||||
import { createEpic } from 'redux-epic';
|
||||
import createReducer from './create-reducer';
|
||||
import sagas from './sagas';
|
||||
import createRoutes from './create-routes.js';
|
||||
import createPanesMap from './create-panes-map.js';
|
||||
import createPanesAspects from './Panes/redux';
|
||||
import epics from './epics';
|
||||
|
||||
// general utils
|
||||
import servicesCreator from '../utils/services-creator';
|
||||
|
||||
const createRouteProps = Observable.fromNodeCallback(match);
|
||||
@ -27,7 +23,7 @@ const createRouteProps = Observable.fromNodeCallback(match);
|
||||
// middlewares?: Function[],
|
||||
// sideReducers?: Object
|
||||
// enhancers?: Function[],
|
||||
// sagas?: Function[],
|
||||
// epics?: Function[],
|
||||
// }) => Observable
|
||||
//
|
||||
// Either location or history must be defined
|
||||
@ -41,44 +37,49 @@ export default function createApp({
|
||||
middlewares: sideMiddlewares = [],
|
||||
enhancers: sideEnhancers = [],
|
||||
reducers: sideReducers = {},
|
||||
sagas: sideSagas = [],
|
||||
sagaOptions: sideSagaOptions = {}
|
||||
epics: sideEpics = [],
|
||||
epicOptions: sideEpicOptions = {}
|
||||
}) {
|
||||
const sagaOptions = {
|
||||
...sideSagaOptions,
|
||||
const epicOptions = {
|
||||
...sideEpicOptions,
|
||||
services: servicesCreator(serviceOptions)
|
||||
};
|
||||
|
||||
const sagaMiddleware = createEpic(
|
||||
sagaOptions,
|
||||
...sagas,
|
||||
...sideSagas
|
||||
const epicMiddleware = createEpic(
|
||||
epicOptions,
|
||||
...epics,
|
||||
...sideEpics
|
||||
);
|
||||
const enhancers = [
|
||||
const {
|
||||
reducer: panesReducer,
|
||||
middleware: panesMiddleware
|
||||
} = createPanesAspects(createPanesMap());
|
||||
const enhancer = compose(
|
||||
applyMiddleware(
|
||||
...sideMiddlewares,
|
||||
sagaMiddleware
|
||||
panesMiddleware,
|
||||
epicMiddleware,
|
||||
...sideMiddlewares
|
||||
),
|
||||
// enhancers must come after middlewares
|
||||
// on client side these are things like Redux DevTools
|
||||
...sideEnhancers
|
||||
];
|
||||
const reducer = createReducer(sideReducers);
|
||||
);
|
||||
const reducer = createReducer(
|
||||
{
|
||||
[panesReducer]: panesReducer,
|
||||
...sideReducers
|
||||
},
|
||||
);
|
||||
|
||||
// create composed store enhancer
|
||||
// use store enhancer function to enhance `createStore` function
|
||||
// call enhanced createStore function with reducer and initialState
|
||||
// to create store
|
||||
const store = compose(...enhancers)(createStore)(reducer, initialState);
|
||||
const store = createStore(reducer, initialState, enhancer);
|
||||
// sync history client side with store.
|
||||
// server side this is an identity function and history is undefined
|
||||
history = syncHistoryWithStore(history, store, syncOptions);
|
||||
const routes = {
|
||||
components: App,
|
||||
...createChildRoute({
|
||||
getState() { return store.getState(); }
|
||||
})
|
||||
};
|
||||
const routes = createRoutes(store);
|
||||
// createRouteProps({
|
||||
// redirect: LocationDescriptor,
|
||||
// history: History,
|
||||
@ -90,6 +91,6 @@ export default function createApp({
|
||||
props,
|
||||
reducer,
|
||||
store,
|
||||
epic: sagaMiddleware
|
||||
epic: epicMiddleware
|
||||
}));
|
||||
}
|
||||
|
7
common/app/create-panes-map.js
Normal file
7
common/app/create-panes-map.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { createPanesMap as routesPanes } from './routes/';
|
||||
|
||||
export default function createPanesMap() {
|
||||
return {
|
||||
...routesPanes()
|
||||
};
|
||||
}
|
@ -1,21 +1,42 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import { reducer as formReducer } from 'redux-form';
|
||||
|
||||
import { reducer as app } from './redux';
|
||||
import { reducer as toasts } from './toasts/redux';
|
||||
import entitiesReducer from './redux/entities-reducer';
|
||||
import {
|
||||
reducer as challengesApp,
|
||||
projectNormalizer
|
||||
} from './routes/challenges/redux';
|
||||
import app from './redux';
|
||||
import entities from './entities';
|
||||
import map from './Map/redux';
|
||||
import nav from './Nav/redux';
|
||||
import routes from './routes/redux';
|
||||
import toasts from './Toasts/redux';
|
||||
// not ideal but should go away once we move to react-redux-form
|
||||
import { projectNormalizer } from './routes/challenges/redux';
|
||||
|
||||
export default function createReducer(sideReducers = {}) {
|
||||
return combineReducers({
|
||||
...sideReducers,
|
||||
entities: entitiesReducer,
|
||||
export default function createReducer(sideReducers) {
|
||||
// reducers exported from features need to be factories
|
||||
// this helps avoid cyclic requires messing up reducer creation
|
||||
// We end up with exports from files being undefined as node tries
|
||||
// to resolve cyclic dependencies.
|
||||
// This prevents that by wrapping the `handleActions` call so that the ref
|
||||
// to types imported from parent features are closures and can be resolved
|
||||
// by node before we need them.
|
||||
const reducerMap = [
|
||||
app,
|
||||
toasts,
|
||||
challengesApp,
|
||||
form: formReducer.normalize({ ...projectNormalizer })
|
||||
});
|
||||
entities,
|
||||
map,
|
||||
nav,
|
||||
routes,
|
||||
toasts
|
||||
]
|
||||
.map(createReducer => createReducer())
|
||||
.reduce((arr, cur) => arr.concat(cur), [])
|
||||
.reduce(
|
||||
(reducerMap, reducer) => {
|
||||
reducerMap[reducer] = reducer;
|
||||
return reducerMap;
|
||||
},
|
||||
{
|
||||
form: formReducer.normalize({ ...projectNormalizer }),
|
||||
...sideReducers
|
||||
}
|
||||
);
|
||||
return combineReducers(reducerMap);
|
||||
}
|
||||
|
9
common/app/create-routes.js
Normal file
9
common/app/create-routes.js
Normal file
@ -0,0 +1,9 @@
|
||||
import App from './App.jsx';
|
||||
import createChildRoute from './routes';
|
||||
|
||||
export default function createRoutes(store) {
|
||||
return {
|
||||
components: App,
|
||||
...createChildRoute(store)
|
||||
};
|
||||
}
|
163
common/app/entities/index.js
Normal file
163
common/app/entities/index.js
Normal file
@ -0,0 +1,163 @@
|
||||
import { createTypes } from 'redux-create-types';
|
||||
import { createAction, handleActions } from 'redux-actions';
|
||||
|
||||
export const ns = 'entities';
|
||||
export const getNS = state => state[ns];
|
||||
export const entitiesSelector = getNS;
|
||||
export const types = createTypes([
|
||||
'updateUserPoints',
|
||||
'updateUserFlag',
|
||||
'updateUserEmail',
|
||||
'updateUserLang',
|
||||
'updateUserChallenge',
|
||||
'updateUserCurrentChallenge'
|
||||
], ns);
|
||||
|
||||
// updateUserPoints(username: String, points: Number) => Action
|
||||
export const updateUserPoints = createAction(
|
||||
types.updateUserPoints,
|
||||
(username, points) => ({ username, points })
|
||||
);
|
||||
// updateUserFlag(username: String, flag: String) => Action
|
||||
export const updateUserFlag = createAction(
|
||||
types.updateUserFlag,
|
||||
(username, flag) => ({ username, flag })
|
||||
);
|
||||
// updateUserEmail(username: String, email: String) => Action
|
||||
export const updateUserEmail = createAction(
|
||||
types.updateUserFlag,
|
||||
(username, email) => ({ username, email })
|
||||
);
|
||||
// updateUserLang(username: String, lang: String) => Action
|
||||
export const updateUserLang = createAction(
|
||||
types.updateUserLang,
|
||||
(username, lang) => ({ username, languageTag: lang })
|
||||
);
|
||||
|
||||
// updateUserChallenge(
|
||||
// username: String,
|
||||
// challengeInfo: Object
|
||||
// ) => Action
|
||||
export const updateUserChallenge = createAction(
|
||||
types.updateUserChallenge,
|
||||
(username, challengeInfo) => ({ username, challengeInfo })
|
||||
);
|
||||
|
||||
export const updateUserCurrentChallenge = createAction(
|
||||
types.updateUserCurrentChallenge
|
||||
);
|
||||
|
||||
|
||||
const initialState = {
|
||||
superBlock: {},
|
||||
block: {},
|
||||
challenge: {},
|
||||
user: {}
|
||||
};
|
||||
|
||||
export const challengeMapSelector = state => getNS(state).challenge || {};
|
||||
export function makeBlockSelector(block) {
|
||||
return state => {
|
||||
const blockMap = getNS(state).block || {};
|
||||
return blockMap[block] || {};
|
||||
};
|
||||
}
|
||||
export function makeSuperBlockSelector(name) {
|
||||
return state => {
|
||||
const superBlock = getNS(state).superBlock || {};
|
||||
return superBlock[name] || {};
|
||||
};
|
||||
}
|
||||
|
||||
export default function createReducer() {
|
||||
const userReducer = handleActions(
|
||||
{
|
||||
[types.updateUserPoints]: (state, { payload: { username, points } }) => ({
|
||||
...state,
|
||||
[username]: {
|
||||
...state[username],
|
||||
points
|
||||
}
|
||||
}),
|
||||
[types.updateUserFlag]: (state, { payload: { username, flag } }) => ({
|
||||
...state,
|
||||
[username]: {
|
||||
...state[username],
|
||||
[flag]: !state[username][flag]
|
||||
}
|
||||
}),
|
||||
[types.updateUserEmail]: (state, { payload: { username, email } }) => ({
|
||||
...state,
|
||||
[username]: {
|
||||
...state[username],
|
||||
email
|
||||
}
|
||||
}),
|
||||
[types.updateUserLang]:
|
||||
(
|
||||
state,
|
||||
{
|
||||
payload: { username, languageTag }
|
||||
}
|
||||
) => ({
|
||||
...state,
|
||||
[username]: {
|
||||
...state[username],
|
||||
languageTag
|
||||
}
|
||||
}),
|
||||
[types.updateUserCurrentChallenge]:
|
||||
(
|
||||
state,
|
||||
{
|
||||
payload: { username, currentChallengeId }
|
||||
}
|
||||
) => ({
|
||||
...state,
|
||||
[username]: {
|
||||
...state[username],
|
||||
currentChallengeId
|
||||
}
|
||||
}),
|
||||
[types.updateUserChallenge]:
|
||||
(
|
||||
state,
|
||||
{
|
||||
payload: { username, challengeInfo }
|
||||
}
|
||||
) => ({
|
||||
...state,
|
||||
[username]: {
|
||||
...state[username],
|
||||
challengeMap: {
|
||||
...state[username].challengeMap,
|
||||
[challengeInfo.id]: challengeInfo
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
initialState.user
|
||||
);
|
||||
|
||||
function metaReducer(state = initialState, action) {
|
||||
if (action.meta && action.meta.entities) {
|
||||
return {
|
||||
...state,
|
||||
...action.meta.entities
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function entitiesReducer(state, action) {
|
||||
const newState = metaReducer(state, action);
|
||||
const user = userReducer(newState.user, action);
|
||||
if (newState.user !== user) {
|
||||
return { ...newState, user };
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
|
||||
entitiesReducer.toString = () => ns;
|
||||
return entitiesReducer;
|
||||
}
|
15
common/app/epics.js
Normal file
15
common/app/epics.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { epics as app } from './redux';
|
||||
import { epics as challenge } from './routes/challenges/redux';
|
||||
import { epics as settings } from './routes/settings/redux';
|
||||
import { epics as nav } from './Nav/redux';
|
||||
import { epics as map } from './Map/redux';
|
||||
import { epics as panes } from './Panes/redux';
|
||||
|
||||
export default [
|
||||
...app,
|
||||
...challenge,
|
||||
...settings,
|
||||
...nav,
|
||||
...map,
|
||||
...panes
|
||||
];
|
@ -1,2 +1,4 @@
|
||||
&{ @import "./app.less"; }
|
||||
&{ @import "./Map/map.less"; }
|
||||
&{ @import "./Nav/nav.less"; }
|
||||
&{ @import "./routes/index.less"; }
|
||||
|
@ -1,147 +0,0 @@
|
||||
import { Observable } from 'rx';
|
||||
import { createAction } from 'redux-actions';
|
||||
import types from './types';
|
||||
import noop from 'lodash/noop';
|
||||
|
||||
const throwIfUndefined = () => {
|
||||
throw new TypeError('Argument must not be of type `undefined`');
|
||||
};
|
||||
|
||||
export const createEventMeta = ({
|
||||
category = throwIfUndefined,
|
||||
action = throwIfUndefined,
|
||||
label,
|
||||
value
|
||||
} = throwIfUndefined) => ({
|
||||
analytics: {
|
||||
type: 'event',
|
||||
category,
|
||||
action,
|
||||
label,
|
||||
value
|
||||
}
|
||||
});
|
||||
|
||||
export const trackEvent = createAction(
|
||||
types.analytics,
|
||||
null,
|
||||
createEventMeta
|
||||
);
|
||||
|
||||
export const trackSocial = createAction(
|
||||
types.analytics,
|
||||
null,
|
||||
(
|
||||
network = throwIfUndefined,
|
||||
action = throwIfUndefined,
|
||||
target = throwIfUndefined
|
||||
) => ({
|
||||
analytics: {
|
||||
type: 'event',
|
||||
network,
|
||||
action,
|
||||
target
|
||||
}
|
||||
})
|
||||
);
|
||||
// updateTitle(title: String) => Action
|
||||
export const updateTitle = createAction(types.updateTitle);
|
||||
|
||||
// fetchUser() => Action
|
||||
// used in combination with fetch-user-saga
|
||||
export const fetchUser = createAction(types.fetchUser);
|
||||
|
||||
// addUser(
|
||||
// entities: { [userId]: User }
|
||||
// ) => Action
|
||||
export const addUser = createAction(
|
||||
types.addUser,
|
||||
() => {},
|
||||
entities => ({ entities })
|
||||
);
|
||||
export const updateThisUser = createAction(types.updateThisUser);
|
||||
export const showSignIn = createAction(types.showSignIn);
|
||||
export const loadCurrentChallenge = createAction(
|
||||
types.loadCurrentChallenge,
|
||||
null,
|
||||
() => createEventMeta({
|
||||
category: 'Nav',
|
||||
action: 'clicked',
|
||||
label: 'fcc logo clicked'
|
||||
})
|
||||
);
|
||||
export const updateMyCurrentChallenge = createAction(
|
||||
types.updateMyCurrentChallenge,
|
||||
(username, currentChallengeId) => ({ username, currentChallengeId })
|
||||
);
|
||||
|
||||
// updateUserPoints(username: String, points: Number) => Action
|
||||
export const updateUserPoints = createAction(
|
||||
types.updateUserPoints,
|
||||
(username, points) => ({ username, points })
|
||||
);
|
||||
// updateUserFlag(username: String, flag: String) => Action
|
||||
export const updateUserFlag = createAction(
|
||||
types.updateUserFlag,
|
||||
(username, flag) => ({ username, flag })
|
||||
);
|
||||
// updateUserEmail(username: String, email: String) => Action
|
||||
export const updateUserEmail = createAction(
|
||||
types.updateUserFlag,
|
||||
(username, email) => ({ username, email })
|
||||
);
|
||||
// updateUserLang(username: String, lang: String) => Action
|
||||
export const updateUserLang = createAction(
|
||||
types.updateUserLang,
|
||||
(username, lang) => ({ username, languageTag: lang })
|
||||
);
|
||||
|
||||
// updateUserChallenge(
|
||||
// username: String,
|
||||
// challengeInfo: Object
|
||||
// ) => Action
|
||||
export const updateUserChallenge = createAction(
|
||||
types.updateUserChallenge,
|
||||
(username, challengeInfo) => ({ username, challengeInfo })
|
||||
);
|
||||
|
||||
export const updateAppLang = createAction(types.updateAppLang);
|
||||
|
||||
// used when server needs client to redirect
|
||||
export const delayedRedirect = createAction(types.delayedRedirect);
|
||||
|
||||
// hardGoTo(path: String) => Action
|
||||
export const hardGoTo = createAction(types.hardGoTo);
|
||||
|
||||
// data
|
||||
export const updateChallengesData = createAction(types.updateChallengesData);
|
||||
export const updateHikesData = createAction(types.updateHikesData);
|
||||
|
||||
export const createErrorObservable = error => Observable.just({
|
||||
type: types.handleError,
|
||||
error
|
||||
});
|
||||
// doActionOnError(
|
||||
// actionCreator: (() => Action|Null)
|
||||
// ) => (error: Error) => Observable[Action]
|
||||
export const doActionOnError = actionCreator => error => Observable.of(
|
||||
{
|
||||
type: types.handleError,
|
||||
error
|
||||
},
|
||||
actionCreator()
|
||||
);
|
||||
|
||||
export const toggleNightMode = createAction(
|
||||
types.toggleNightMode,
|
||||
// we use this function to avoid hanging onto the eventObject
|
||||
// so that react can recycle it
|
||||
() => null
|
||||
);
|
||||
// updateTheme(theme: /night|default/) => Action
|
||||
export const updateTheme = createAction(types.updateTheme);
|
||||
// addThemeToBody(theme: /night|default/) => Action
|
||||
export const addThemeToBody = createAction(types.addThemeToBody);
|
||||
|
||||
export const openDropdown = createAction(types.openDropdown, noop);
|
||||
export const closeDropdown = createAction(types.closeDropdown, noop);
|
@ -1,98 +0,0 @@
|
||||
import { handleActions } from 'redux-actions';
|
||||
|
||||
import types from './types';
|
||||
|
||||
const initialState = {
|
||||
superBlock: {},
|
||||
block: {},
|
||||
challenge: {},
|
||||
user: {}
|
||||
};
|
||||
|
||||
const userReducer = handleActions(
|
||||
{
|
||||
[types.updateUserPoints]: (state, { payload: { username, points } }) => ({
|
||||
...state,
|
||||
[username]: {
|
||||
...state[username],
|
||||
points
|
||||
}
|
||||
}),
|
||||
[types.updateUserFlag]: (state, { payload: { username, flag } }) => ({
|
||||
...state,
|
||||
[username]: {
|
||||
...state[username],
|
||||
[flag]: !state[username][flag]
|
||||
}
|
||||
}),
|
||||
[types.updateUserEmail]: (state, { payload: { username, email } }) => ({
|
||||
...state,
|
||||
[username]: {
|
||||
...state[username],
|
||||
email
|
||||
}
|
||||
}),
|
||||
[types.updateUserLang]:
|
||||
(
|
||||
state,
|
||||
{
|
||||
payload: { username, languageTag }
|
||||
}
|
||||
) => ({
|
||||
...state,
|
||||
[username]: {
|
||||
...state[username],
|
||||
languageTag
|
||||
}
|
||||
}),
|
||||
[types.updateMyCurrentChallenge]:
|
||||
(
|
||||
state,
|
||||
{
|
||||
payload: { username, currentChallengeId }
|
||||
}
|
||||
) => ({
|
||||
...state,
|
||||
[username]: {
|
||||
...state[username],
|
||||
currentChallengeId
|
||||
}
|
||||
}),
|
||||
[types.updateUserChallenge]:
|
||||
(
|
||||
state,
|
||||
{
|
||||
payload: { username, challengeInfo }
|
||||
}
|
||||
) => ({
|
||||
...state,
|
||||
[username]: {
|
||||
...state[username],
|
||||
challengeMap: {
|
||||
...state[username].challengeMap,
|
||||
[challengeInfo.id]: challengeInfo
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
initialState.user
|
||||
);
|
||||
|
||||
function metaReducer(state = initialState, action) {
|
||||
if (action.meta && action.meta.entities) {
|
||||
return {
|
||||
...state,
|
||||
...action.meta.entities
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export default function entitiesReducer(state, action) {
|
||||
const newState = metaReducer(state, action);
|
||||
const user = userReducer(newState.user, action);
|
||||
if (newState.user !== user) {
|
||||
return { ...newState, user };
|
||||
}
|
||||
return newState;
|
||||
}
|
86
common/app/redux/fetch-challenges-epic.js
Normal file
86
common/app/redux/fetch-challenges-epic.js
Normal file
@ -0,0 +1,86 @@
|
||||
import { Observable } from 'rx';
|
||||
import { combineEpics, ofType } from 'redux-epic';
|
||||
import debug from 'debug';
|
||||
|
||||
import {
|
||||
types,
|
||||
|
||||
createErrorObservable,
|
||||
delayedRedirect,
|
||||
|
||||
fetchChallengeCompleted,
|
||||
fetchChallengesCompleted,
|
||||
|
||||
langSelector
|
||||
} from './';
|
||||
import { shapeChallenges } from './utils';
|
||||
|
||||
const isDev = debug.enabled('fcc:*');
|
||||
|
||||
export function fetchChallengeEpic(actions, { getState }, { services }) {
|
||||
return actions::ofType('' + types.fetchChallenge)
|
||||
.flatMap(({ payload: { dashedName, block } }) => {
|
||||
const lang = langSelector(getState());
|
||||
const options = {
|
||||
service: 'map',
|
||||
params: { block, dashedName, lang }
|
||||
};
|
||||
return services.readService$(options)
|
||||
.retry(3)
|
||||
.map(({ entities, ...rest }) => ({
|
||||
entities: shapeChallenges(entities, isDev),
|
||||
...rest
|
||||
}))
|
||||
.flatMap(({ entities, result, redirect } = {}) => {
|
||||
const actions = [
|
||||
fetchChallengeCompleted({
|
||||
entities,
|
||||
currentChallenge: result.challenge,
|
||||
challenge: entities.challenge[result.challenge],
|
||||
result
|
||||
}),
|
||||
redirect ? delayedRedirect(redirect) : null
|
||||
];
|
||||
return Observable.from(actions).filter(Boolean);
|
||||
})
|
||||
.catch(createErrorObservable);
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchChallengesEpic(
|
||||
actions,
|
||||
{ getState },
|
||||
{ services }
|
||||
) {
|
||||
return actions::ofType(
|
||||
// async type
|
||||
'' + types.fetchChallenges,
|
||||
types.appMounted
|
||||
)
|
||||
.flatMapLatest(() => {
|
||||
const lang = langSelector(getState());
|
||||
const options = {
|
||||
lang,
|
||||
service: 'map'
|
||||
};
|
||||
return services.readService$(options)
|
||||
.retry(3)
|
||||
.map(({ entities, ...res }) => ({
|
||||
entities: shapeChallenges(
|
||||
entities,
|
||||
isDev
|
||||
),
|
||||
...res
|
||||
}))
|
||||
.map(({ entities, result } = {}) => {
|
||||
return fetchChallengesCompleted(
|
||||
entities,
|
||||
result
|
||||
);
|
||||
})
|
||||
.startWith({ type: types.fetchChallenges.start })
|
||||
.catch(createErrorObservable);
|
||||
});
|
||||
}
|
||||
|
||||
export default combineEpics(fetchChallengeEpic, fetchChallengesEpic);
|
@ -1,33 +1,33 @@
|
||||
import { Observable } from 'rx';
|
||||
import types from './types';
|
||||
import { ofType } from 'redux-epic';
|
||||
import {
|
||||
types,
|
||||
|
||||
addUser,
|
||||
updateThisUser,
|
||||
createErrorObservable,
|
||||
showSignIn,
|
||||
updateTheme,
|
||||
addThemeToBody
|
||||
} from './actions';
|
||||
} from './';
|
||||
|
||||
const { fetchUser } = types;
|
||||
export default function getUserSaga(action$, getState, { services }) {
|
||||
return action$
|
||||
.filter(action => action.type === fetchUser)
|
||||
export default function getUserEpic(actions, { getState }, { services }) {
|
||||
return actions::ofType(types.fetchUser)
|
||||
.flatMap(() => {
|
||||
return services.readService$({ service: 'user' })
|
||||
.filter(({ entities, result }) => entities && !!result)
|
||||
.flatMap(({ entities, result })=> {
|
||||
if (!entities || !result) {
|
||||
return Observable.just(showSignIn());
|
||||
}
|
||||
const user = entities.user[result];
|
||||
const isNightMode = user.theme === 'night';
|
||||
return Observable.of(
|
||||
const actions = [
|
||||
addUser(entities),
|
||||
updateThisUser(result),
|
||||
isNightMode ? updateTheme(user.theme) : null,
|
||||
isNightMode ? addThemeToBody(user.theme) : null
|
||||
);
|
||||
];
|
||||
return Observable.from(actions).filter(Boolean);
|
||||
})
|
||||
.defaultIfEmpty(showSignIn())
|
||||
.catch(createErrorObservable);
|
||||
});
|
||||
}
|
@ -1,10 +1,279 @@
|
||||
import fetchUserSaga from './fetch-user-saga';
|
||||
import loadCurrentChallengeSaga from './load-current-challenge-saga';
|
||||
import { Observable } from 'rx';
|
||||
import { createTypes, createAsyncTypes } from 'redux-create-types';
|
||||
import { combineActions, createAction, handleActions } from 'redux-actions';
|
||||
import { createSelector } from 'reselect';
|
||||
import noop from 'lodash/noop';
|
||||
import identity from 'lodash/identity';
|
||||
|
||||
export { default as reducer } from './reducer';
|
||||
export * as actions from './actions';
|
||||
export { default as types } from './types';
|
||||
export const sagas = [
|
||||
fetchUserSaga,
|
||||
loadCurrentChallengeSaga
|
||||
import { entitiesSelector } from '../entities';
|
||||
import fetchUserEpic from './fetch-user-epic.js';
|
||||
import updateMyCurrentChallengeEpic from './update-my-challenge-epic.js';
|
||||
import fetchChallengesEpic from './fetch-challenges-epic.js';
|
||||
import navSizeEpic from './nav-size-epic.js';
|
||||
|
||||
import ns from '../ns.json';
|
||||
|
||||
export const epics = [
|
||||
fetchUserEpic,
|
||||
fetchChallengesEpic,
|
||||
updateMyCurrentChallengeEpic,
|
||||
navSizeEpic
|
||||
];
|
||||
|
||||
export const types = createTypes([
|
||||
'appMounted',
|
||||
'analytics',
|
||||
'updateTitle',
|
||||
'updateAppLang',
|
||||
|
||||
createAsyncTypes('fetchChallenge'),
|
||||
createAsyncTypes('fetchChallenges'),
|
||||
'updateCurrentChallenge',
|
||||
|
||||
'fetchUser',
|
||||
'addUser',
|
||||
'updateThisUser',
|
||||
'showSignIn',
|
||||
|
||||
'handleError',
|
||||
// used to hit the server
|
||||
'hardGoTo',
|
||||
'delayedRedirect',
|
||||
|
||||
// night mode
|
||||
'toggleNightMode',
|
||||
'updateTheme',
|
||||
'addThemeToBody'
|
||||
], ns);
|
||||
|
||||
const throwIfUndefined = () => {
|
||||
throw new TypeError('Argument must not be of type `undefined`');
|
||||
};
|
||||
|
||||
// createEventMetaCreator({
|
||||
// category: String,
|
||||
// action: String,
|
||||
// label?: String,
|
||||
// value?: Number
|
||||
// }) => () => Object
|
||||
export const createEventMetaCreator = ({
|
||||
// categories are features or namespaces of the app (capitalized):
|
||||
// Map, Nav, Challenges, and so on
|
||||
category = throwIfUndefined,
|
||||
// can be a one word the event
|
||||
// click, play, toggle.
|
||||
// This is not a hard and fast rule
|
||||
action = throwIfUndefined,
|
||||
// any additional information
|
||||
// when in doubt use redux action type
|
||||
// or a short sentence describing the action
|
||||
label,
|
||||
// used to tack some specific value for a GA event
|
||||
value
|
||||
} = throwIfUndefined) => () => ({
|
||||
analytics: {
|
||||
type: 'event',
|
||||
category,
|
||||
action,
|
||||
label,
|
||||
value
|
||||
}
|
||||
});
|
||||
|
||||
export const appMounted = createAction(types.appMounted);
|
||||
export const fetchChallenge = createAction(
|
||||
'' + types.fetchChallenge,
|
||||
(dashedName, block) => ({ dashedName, block })
|
||||
);
|
||||
export const fetchChallengeCompleted = createAction(
|
||||
types.fetchChallenge.complete,
|
||||
null,
|
||||
identity
|
||||
);
|
||||
export const fetchChallenges = createAction('' + types.fetchChallenges);
|
||||
export const fetchChallengesCompleted = createAction(
|
||||
types.fetchChallenges.complete,
|
||||
(entities, result) => ({ entities, result }),
|
||||
entities => ({ entities })
|
||||
);
|
||||
export const updateCurrentChallenge = createAction(
|
||||
types.updateCurrentChallenge
|
||||
);
|
||||
|
||||
// updateTitle(title: String) => Action
|
||||
export const updateTitle = createAction(types.updateTitle);
|
||||
|
||||
// fetchUser() => Action
|
||||
// used in combination with fetch-user-epic
|
||||
export const fetchUser = createAction(types.fetchUser);
|
||||
|
||||
// addUser(
|
||||
// entities: { [userId]: User }
|
||||
// ) => Action
|
||||
export const addUser = createAction(
|
||||
types.addUser,
|
||||
noop,
|
||||
entities => ({ entities })
|
||||
);
|
||||
export const updateThisUser = createAction(types.updateThisUser);
|
||||
export const showSignIn = createAction(types.showSignIn);
|
||||
|
||||
export const updateAppLang = createAction(types.updateAppLang);
|
||||
|
||||
// used when server needs client to redirect
|
||||
export const delayedRedirect = createAction(types.delayedRedirect);
|
||||
|
||||
// hardGoTo(path: String) => Action
|
||||
export const hardGoTo = createAction(types.hardGoTo);
|
||||
|
||||
export const createErrorObservable = error => Observable.just({
|
||||
type: types.handleError,
|
||||
error
|
||||
});
|
||||
// doActionOnError(
|
||||
// actionCreator: (() => Action|Null)
|
||||
// ) => (error: Error) => Observable[Action]
|
||||
export const doActionOnError = actionCreator => error => Observable.of(
|
||||
{
|
||||
type: types.handleError,
|
||||
error
|
||||
},
|
||||
actionCreator()
|
||||
);
|
||||
|
||||
export const toggleNightMode = createAction(
|
||||
types.toggleNightMode,
|
||||
// we use this function to avoid hanging onto the eventObject
|
||||
// so that react can recycle it
|
||||
() => null
|
||||
);
|
||||
// updateTheme(theme: /night|default/) => Action
|
||||
export const updateTheme = createAction(types.updateTheme);
|
||||
// addThemeToBody(theme: /night|default/) => Action
|
||||
export const addThemeToBody = createAction(types.addThemeToBody);
|
||||
|
||||
const initialState = {
|
||||
title: 'Learn To Code | freeCodeCamp',
|
||||
isSignInAttempted: false,
|
||||
user: '',
|
||||
lang: '',
|
||||
csrfToken: '',
|
||||
theme: 'default',
|
||||
// eventually this should be only in the user object
|
||||
currentChallenge: '',
|
||||
superBlocks: []
|
||||
};
|
||||
|
||||
export const getNS = state => state[ns];
|
||||
export const langSelector = state => getNS(state).lang;
|
||||
export const csrfSelector = state => getNS(state).csrfToken;
|
||||
export const themeSelector = state => getNS(state).theme;
|
||||
export const titleSelector = state => getNS(state).title;
|
||||
|
||||
export const currentChallengeSelector = state => getNS(state).currentChallenge;
|
||||
export const superBlocksSelector = state => getNS(state).superBlocks;
|
||||
export const signInLoadingSelector = state => !getNS(state).isSignInAttempted;
|
||||
|
||||
export const userSelector = createSelector(
|
||||
state => getNS(state).user,
|
||||
state => entitiesSelector(state).user,
|
||||
(username, userMap) => userMap[username] || {}
|
||||
);
|
||||
|
||||
export const challengeSelector = createSelector(
|
||||
currentChallengeSelector,
|
||||
state => entitiesSelector(state).challenge,
|
||||
(challengeName, challengeMap = {}) => {
|
||||
return challengeMap[challengeName] || {};
|
||||
}
|
||||
);
|
||||
|
||||
export const firstChallengeSelector = createSelector(
|
||||
entitiesSelector,
|
||||
superBlocksSelector,
|
||||
(
|
||||
{
|
||||
challengeMap,
|
||||
blockMap,
|
||||
superBlockMap
|
||||
},
|
||||
superBlocks
|
||||
) => {
|
||||
if (
|
||||
!challengeMap ||
|
||||
!blockMap ||
|
||||
!superBlockMap ||
|
||||
!superBlocks
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return challengeMap[
|
||||
blockMap[
|
||||
superBlockMap[
|
||||
superBlocks[0]
|
||||
].blocks[0]
|
||||
].challenges[0]
|
||||
];
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default function createReducer() {
|
||||
const reducer = handleActions(
|
||||
{
|
||||
[types.updateTitle]: (state, { payload = 'Learn To Code' }) => ({
|
||||
...state,
|
||||
title: payload + ' | freeCodeCamp'
|
||||
}),
|
||||
|
||||
[types.updateThisUser]: (state, { payload: user }) => ({
|
||||
...state,
|
||||
user
|
||||
}),
|
||||
[types.fetchChallenge.complete]: (state, { payload }) => ({
|
||||
...state,
|
||||
currentChallenge: payload.currentChallenge
|
||||
}),
|
||||
[combineActions(
|
||||
types.fetchChallenge.complete,
|
||||
types.fetchChallenges.complete
|
||||
)]: (state, { payload }) => ({
|
||||
...state,
|
||||
superBlocks: payload.result.superBlocks
|
||||
}),
|
||||
[types.updateCurrentChallenge]: (state, { payload = '' }) => ({
|
||||
...state,
|
||||
currentChallenge: payload
|
||||
}),
|
||||
[types.updateAppLang]: (state, { payload = 'en' }) =>({
|
||||
...state,
|
||||
lang: payload
|
||||
}),
|
||||
[types.updateTheme]: (state, { payload = 'default' }) => ({
|
||||
...state,
|
||||
theme: payload
|
||||
}),
|
||||
[combineActions(types.showSignIn, types.updateThisUser)]: state => ({
|
||||
...state,
|
||||
isSignInAttempted: true
|
||||
}),
|
||||
|
||||
[types.challengeSaved]: (state, { payload: { points = 0 } }) => ({
|
||||
...state,
|
||||
points
|
||||
}),
|
||||
[types.delayedRedirect]: (state, { payload }) => ({
|
||||
...state,
|
||||
delayedRedirect: payload
|
||||
})
|
||||
},
|
||||
initialState
|
||||
);
|
||||
|
||||
reducer.toString = () => ns;
|
||||
return reducer;
|
||||
}
|
||||
|
@ -1,92 +0,0 @@
|
||||
import { Observable } from 'rx';
|
||||
import debug from 'debug';
|
||||
import { push } from 'react-router-redux';
|
||||
|
||||
import types from './types';
|
||||
import {
|
||||
updateMyCurrentChallenge,
|
||||
createErrorObservable
|
||||
} from './actions';
|
||||
import {
|
||||
userSelector,
|
||||
firstChallengeSelector
|
||||
} from './selectors';
|
||||
import { updateCurrentChallenge } from '../routes/challenges/redux/actions';
|
||||
import getActionsOfType from '../../utils/get-actions-of-type';
|
||||
import combineSagas from '../../utils/combine-sagas';
|
||||
import { postJSON$ } from '../../utils/ajax-stream';
|
||||
|
||||
const log = debug('fcc:app/redux/load-current-challenge-saga');
|
||||
export function updateMyCurrentChallengeSaga(actions, getState) {
|
||||
const updateChallenge$ = getActionsOfType(
|
||||
actions,
|
||||
updateCurrentChallenge.toString()
|
||||
)
|
||||
.map(({ payload: { id } }) => id)
|
||||
.filter(() => {
|
||||
const { app: { user: username } } = getState();
|
||||
return !!username;
|
||||
})
|
||||
.distinctUntilChanged();
|
||||
const optimistic = updateChallenge$.map(id => {
|
||||
const { app: { user: username } } = getState();
|
||||
return updateMyCurrentChallenge(username, id);
|
||||
});
|
||||
const ajaxUpdate = updateChallenge$
|
||||
.debounce(250)
|
||||
.flatMapLatest(currentChallengeId => {
|
||||
const { app: { csrfToken: _csrf } } = getState();
|
||||
return postJSON$(
|
||||
'/update-my-current-challenge',
|
||||
{ _csrf, currentChallengeId }
|
||||
)
|
||||
.map(({ message }) => log(message))
|
||||
.catch(createErrorObservable);
|
||||
});
|
||||
return Observable.merge(optimistic, ajaxUpdate);
|
||||
}
|
||||
|
||||
export function loadCurrentChallengeSaga(actions, getState) {
|
||||
return getActionsOfType(actions, types.loadCurrentChallenge)
|
||||
.flatMap(() => {
|
||||
let finalChallenge;
|
||||
const state = getState();
|
||||
const {
|
||||
entities: { challenge: challengeMap, challengeIdToName },
|
||||
challengesApp: { id: currentlyLoadedChallengeId },
|
||||
locationBeforeTransition: { pathname } = {}
|
||||
} = state;
|
||||
const firstChallenge = firstChallengeSelector(state);
|
||||
const { user: { currentChallengeId } } = userSelector(state);
|
||||
const isOnAChallenge = (/^\/[^\/]{2,6}\/challenges/).test(pathname);
|
||||
|
||||
if (!currentChallengeId) {
|
||||
finalChallenge = firstChallenge;
|
||||
} else {
|
||||
finalChallenge = challengeMap[
|
||||
challengeIdToName[ currentChallengeId ]
|
||||
];
|
||||
}
|
||||
if (
|
||||
// data might not be there yet, ignore for now
|
||||
!finalChallenge ||
|
||||
// are we already on that challenge?
|
||||
(isOnAChallenge && finalChallenge.id === currentlyLoadedChallengeId)
|
||||
) {
|
||||
// don't reload if the challenge is already loaded.
|
||||
// This may change to toast to avoid user confusion
|
||||
return Observable.empty();
|
||||
}
|
||||
return Observable.of(
|
||||
updateCurrentChallenge(finalChallenge),
|
||||
push(
|
||||
`/challenges/${finalChallenge.block}/${finalChallenge.dashedName}`
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default combineSagas(
|
||||
updateMyCurrentChallengeSaga,
|
||||
loadCurrentChallengeSaga
|
||||
);
|
13
common/app/redux/nav-size-epic.js
Normal file
13
common/app/redux/nav-size-epic.js
Normal file
@ -0,0 +1,13 @@
|
||||
import { ofType } from 'redux-epic';
|
||||
|
||||
import { types } from './';
|
||||
|
||||
import { updateNavHeight } from '../Panes/redux';
|
||||
|
||||
export default function navSizeEpic(actions, _, { document }) {
|
||||
return actions::ofType(types.appMounted)
|
||||
.map(() => {
|
||||
const navbar = document.getElementById('navbar');
|
||||
return updateNavHeight(navbar.clientHeight || 50);
|
||||
});
|
||||
}
|
@ -1,56 +0,0 @@
|
||||
import { handleActions } from 'redux-actions';
|
||||
import types from './types';
|
||||
|
||||
const initialState = {
|
||||
title: 'Learn To Code | freeCodeCamp',
|
||||
isSignInAttempted: false,
|
||||
user: '',
|
||||
lang: '',
|
||||
csrfToken: '',
|
||||
theme: 'default'
|
||||
};
|
||||
|
||||
export default handleActions(
|
||||
{
|
||||
[types.updateTitle]: (state, { payload = 'Learn To Code' }) => ({
|
||||
...state,
|
||||
title: payload + ' | freeCodeCamp'
|
||||
}),
|
||||
|
||||
[types.updateThisUser]: (state, { payload: user }) => ({
|
||||
...state,
|
||||
user,
|
||||
isSignInAttempted: true
|
||||
}),
|
||||
[types.updateAppLang]: (state, { payload = 'en' }) =>({
|
||||
...state,
|
||||
lang: payload
|
||||
}),
|
||||
[types.updateTheme]: (state, { payload = 'default' }) => ({
|
||||
...state,
|
||||
theme: payload
|
||||
}),
|
||||
[types.showSignIn]: state => ({
|
||||
...state,
|
||||
isSignInAttempted: true
|
||||
}),
|
||||
|
||||
[types.challengeSaved]: (state, { payload: { points = 0 } }) => ({
|
||||
...state,
|
||||
points
|
||||
}),
|
||||
[types.delayedRedirect]: (state, { payload }) => ({
|
||||
...state,
|
||||
delayedRedirect: payload
|
||||
}),
|
||||
[types.openDropdown]: state => ({
|
||||
...state,
|
||||
isNavDropdownOpen: true
|
||||
}),
|
||||
[types.closeDropdown]: state => ({
|
||||
...state,
|
||||
isNavDropdownOpen: false
|
||||
})
|
||||
},
|
||||
initialState
|
||||
);
|
@ -1,38 +0,0 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
export const userSelector = createSelector(
|
||||
state => state.app.user,
|
||||
state => state.entities.user,
|
||||
(username, userMap) => ({
|
||||
user: userMap[username] || {}
|
||||
})
|
||||
);
|
||||
|
||||
export const firstChallengeSelector = createSelector(
|
||||
state => state.entities.challenge,
|
||||
state => state.entities.block,
|
||||
state => state.entities.superBlock,
|
||||
state => state.challengesApp.superBlocks,
|
||||
(challengeMap, blockMap, superBlockMap, superBlocks) => {
|
||||
if (
|
||||
!challengeMap ||
|
||||
!blockMap ||
|
||||
!superBlockMap ||
|
||||
!superBlocks
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return challengeMap[
|
||||
blockMap[
|
||||
superBlockMap[
|
||||
superBlocks[0]
|
||||
].blocks[0]
|
||||
].challenges[0]
|
||||
];
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
);
|
@ -1,37 +0,0 @@
|
||||
import { createTypes } from 'redux-create-types';
|
||||
|
||||
export default createTypes([
|
||||
'analytics',
|
||||
'updateTitle',
|
||||
'updateAppLang',
|
||||
|
||||
'fetchUser',
|
||||
'addUser',
|
||||
'updateThisUser',
|
||||
'updateUserPoints',
|
||||
'updateUserFlag',
|
||||
'updateUserEmail',
|
||||
'updateUserLang',
|
||||
'updateUserChallenge',
|
||||
'showSignIn',
|
||||
'loadCurrentChallenge',
|
||||
'updateMyCurrentChallenge',
|
||||
|
||||
'handleError',
|
||||
// used to hit the server
|
||||
'hardGoTo',
|
||||
'delayedRedirect',
|
||||
|
||||
// data handling
|
||||
'updateChallengesData',
|
||||
'updateHikesData',
|
||||
|
||||
// night mode
|
||||
'toggleNightMode',
|
||||
'updateTheme',
|
||||
'addThemeToBody',
|
||||
|
||||
// nav
|
||||
'openDropdown',
|
||||
'closeDropdown'
|
||||
], 'app');
|
48
common/app/redux/update-my-challenge-epic.js
Normal file
48
common/app/redux/update-my-challenge-epic.js
Normal file
@ -0,0 +1,48 @@
|
||||
import { Observable } from 'rx';
|
||||
import { ofType } from 'redux-epic';
|
||||
import debug from 'debug';
|
||||
|
||||
import {
|
||||
types,
|
||||
createErrorObservable,
|
||||
|
||||
challengeSelector,
|
||||
csrfSelector,
|
||||
userSelector
|
||||
} from './';
|
||||
import { updateUserCurrentChallenge } from '../entities';
|
||||
import { postJSON$ } from '../../utils/ajax-stream';
|
||||
|
||||
const log = debug('fcc:app:redux:up-my-challenge-epic');
|
||||
export default function updateMyCurrentChallengeEpic(actions, { getState }) {
|
||||
const updateChallenge = actions::ofType(types.updateCurrentChallenge)
|
||||
.map(() => {
|
||||
const state = getState();
|
||||
const { username } = userSelector(state);
|
||||
const { id } = challengeSelector(state);
|
||||
const csrf = csrfSelector(state);
|
||||
return {
|
||||
username,
|
||||
csrf,
|
||||
currentChallengeId: id
|
||||
};
|
||||
})
|
||||
.filter(({ username }) => !!username)
|
||||
.distinctUntilChanged(x => x.currentChallengeId);
|
||||
const optimistic = updateChallenge.map(updateUserCurrentChallenge);
|
||||
const ajaxUpdate = updateChallenge
|
||||
.debounce(250)
|
||||
.flatMapLatest(({ csrf, currentChallengeId }) => {
|
||||
return postJSON$(
|
||||
'/update-my-current-challenge',
|
||||
{
|
||||
currentChallengeId,
|
||||
_csrf: csrf
|
||||
}
|
||||
)
|
||||
.map(({ message }) => log(message))
|
||||
.ignoreElements()
|
||||
.catch(createErrorObservable);
|
||||
});
|
||||
return Observable.merge(optimistic, ajaxUpdate);
|
||||
}
|
33
common/app/redux/utils.js
Normal file
33
common/app/redux/utils.js
Normal file
@ -0,0 +1,33 @@
|
||||
import flowRight from 'lodash/flowRight';
|
||||
import createNameIdMap from '../../utils/create-name-id-map.js';
|
||||
|
||||
|
||||
export function filterComingSoonBetaChallenge(
|
||||
isDev = false,
|
||||
{ isComingSoon, isBeta }
|
||||
) {
|
||||
return !(isComingSoon || isBeta) ||
|
||||
isDev;
|
||||
}
|
||||
|
||||
export function filterComingSoonBetaFromEntities(
|
||||
{ challenge: challengeMap, ...rest },
|
||||
isDev = false
|
||||
) {
|
||||
const filter = filterComingSoonBetaChallenge.bind(null, isDev);
|
||||
return {
|
||||
...rest,
|
||||
challenge: Object.keys(challengeMap)
|
||||
.map(dashedName => challengeMap[dashedName])
|
||||
.filter(filter)
|
||||
.reduce((challengeMap, challenge) => {
|
||||
challengeMap[challenge.dashedName] = challenge;
|
||||
return challengeMap;
|
||||
}, {})
|
||||
};
|
||||
}
|
||||
|
||||
export const shapeChallenges = flowRight(
|
||||
filterComingSoonBetaFromEntities,
|
||||
createNameIdMap
|
||||
);
|
92
common/app/redux/utils.test.js
Normal file
92
common/app/redux/utils.test.js
Normal file
@ -0,0 +1,92 @@
|
||||
import test from 'tape';
|
||||
import {
|
||||
filterComingSoonBetaChallenge,
|
||||
filterComingSoonBetaFromEntities
|
||||
} from './utils.js';
|
||||
|
||||
|
||||
test.test('filterComingSoonBetaChallenge', t => {
|
||||
t.plan(4);
|
||||
t.test('should return true when not coming-soon/beta', t => {
|
||||
let isDev;
|
||||
t.ok(filterComingSoonBetaChallenge(isDev, {}));
|
||||
t.ok(filterComingSoonBetaChallenge(true, {}));
|
||||
t.end();
|
||||
});
|
||||
t.test('should return false when isComingSoon', t => {
|
||||
let isDev;
|
||||
t.notOk(filterComingSoonBetaChallenge(isDev, { isComingSoon: true }));
|
||||
t.end();
|
||||
});
|
||||
t.test('should return false when isBeta', t => {
|
||||
let isDev;
|
||||
t.notOk(filterComingSoonBetaChallenge(isDev, { isBeta: true }));
|
||||
t.end();
|
||||
});
|
||||
t.test('should always return true when in dev', t => {
|
||||
let isDev = true;
|
||||
t.ok(filterComingSoonBetaChallenge(isDev, { isBeta: true }));
|
||||
t.ok(filterComingSoonBetaChallenge(isDev, { isComingSoon: true }));
|
||||
t.ok(filterComingSoonBetaChallenge(
|
||||
isDev,
|
||||
{ isBeta: true, isCompleted: true }
|
||||
));
|
||||
t.end();
|
||||
});
|
||||
});
|
||||
test.test('filterComingSoonBetaFromEntities', t => {
|
||||
t.plan(2);
|
||||
t.test('should filter isBeta|coming-soon by default', t => {
|
||||
t.plan(2);
|
||||
const normalChallenge = { dashedName: 'normal-challenge' };
|
||||
const entities = {
|
||||
challenge: {
|
||||
'coming-soon': {
|
||||
isComingSoon: true
|
||||
},
|
||||
'is-beta': {
|
||||
isBeta: true
|
||||
},
|
||||
[normalChallenge.dashedName]: normalChallenge
|
||||
}
|
||||
};
|
||||
const actual = filterComingSoonBetaFromEntities(entities);
|
||||
t.isEqual(
|
||||
Object.keys(actual.challenge).length,
|
||||
1,
|
||||
'did not filter the correct amount of challenges'
|
||||
);
|
||||
t.isEqual(
|
||||
actual.challenge[normalChallenge.dashedName],
|
||||
normalChallenge,
|
||||
'did not return the correct challenge'
|
||||
);
|
||||
});
|
||||
t.test('should not filter isBeta|coming-soon when isDev', t => {
|
||||
t.plan(1);
|
||||
const normalChallenge = { dashedName: 'normal-challenge' };
|
||||
const entities = {
|
||||
challenge: {
|
||||
'coming-soon': {
|
||||
dashedName: 'coming-soon',
|
||||
isComingSoon: true
|
||||
},
|
||||
'is-beta': {
|
||||
dashedName: 'is-beta',
|
||||
isBeta: true
|
||||
},
|
||||
'is-both': {
|
||||
dashedName: 'is-both',
|
||||
isBeta: true
|
||||
},
|
||||
[normalChallenge.dashedName]: normalChallenge
|
||||
}
|
||||
};
|
||||
const actual = filterComingSoonBetaFromEntities(entities, true);
|
||||
t.isEqual(
|
||||
Object.keys(actual.challenge).length,
|
||||
4,
|
||||
'filtered challenges'
|
||||
);
|
||||
});
|
||||
});
|
@ -4,10 +4,15 @@ import { Button, Modal } from 'react-bootstrap';
|
||||
import PureComponent from 'react-pure-render/component';
|
||||
|
||||
import ns from './ns.json';
|
||||
import {
|
||||
createIssue,
|
||||
openIssueSearch,
|
||||
closeBugModal,
|
||||
|
||||
import { createIssue, openIssueSearch, closeBugModal } from './redux/actions';
|
||||
bugModalSelector
|
||||
} from './redux';
|
||||
|
||||
const mapStateToProps = state => ({ isOpen: state.challengesApp.isBugOpen });
|
||||
const mapStateToProps = state => ({ isOpen: bugModalSelector(state) });
|
||||
const mapDispatchToProps = { createIssue, openIssueSearch, closeBugModal };
|
||||
const bugLink = 'http://forum.freecodecamp.com/t/how-to-report-a-bug/19543';
|
||||
|
||||
|
@ -2,6 +2,8 @@ import React, { PropTypes } from 'react';
|
||||
import PureComponent from 'react-pure-render/component';
|
||||
import { Grid, Col, Row } from 'react-bootstrap';
|
||||
|
||||
import ns from './ns.json';
|
||||
|
||||
const propTypes = {
|
||||
content: PropTypes.string
|
||||
};
|
||||
@ -10,7 +12,7 @@ export default class CodeMirrorSkeleton extends PureComponent {
|
||||
|
||||
renderLine(line, i) {
|
||||
return (
|
||||
<div className='shimmer' key={ i }>
|
||||
<div className={ `${ns}-shimmer` } key={ i }>
|
||||
<Row>
|
||||
<Col xs={ 12 }>
|
||||
<div className='sprite-wrapper'>
|
||||
|
104
common/app/routes/challenges/Completion-Modal.jsx
Normal file
104
common/app/routes/challenges/Completion-Modal.jsx
Normal file
@ -0,0 +1,104 @@
|
||||
import noop from 'lodash/noop';
|
||||
import React, { PureComponent, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { Button, Modal } from 'react-bootstrap';
|
||||
import FontAwesome from 'react-fontawesome';
|
||||
|
||||
import ns from './ns.json';
|
||||
import {
|
||||
closeChallengeModal,
|
||||
submitChallenge,
|
||||
|
||||
challengeModalSelector,
|
||||
successMessageSelector
|
||||
} from './redux';
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
challengeModalSelector,
|
||||
successMessageSelector,
|
||||
(isOpen, message) => ({
|
||||
isOpen,
|
||||
message
|
||||
})
|
||||
);
|
||||
|
||||
const mapDispatchToProps = function(dispatch) {
|
||||
const dispatchers = {
|
||||
close: () => dispatch(closeChallengeModal()),
|
||||
submitChallenge: (e) => {
|
||||
if (
|
||||
e.keyCode === 13 &&
|
||||
(e.ctrlKey || e.meta)
|
||||
) {
|
||||
dispatch(submitChallenge());
|
||||
}
|
||||
}
|
||||
};
|
||||
return () => dispatchers;
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
close: PropTypes.func.isRequired,
|
||||
isOpen: PropTypes.bool,
|
||||
message: PropTypes.string,
|
||||
submitChallenge: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export class CompletionModal extends PureComponent {
|
||||
render() {
|
||||
const {
|
||||
close,
|
||||
isOpen,
|
||||
submitChallenge,
|
||||
message
|
||||
} = this.props;
|
||||
return (
|
||||
<Modal
|
||||
animation={ false }
|
||||
dialogClassName={ `${ns}-success-modal` }
|
||||
keyboard={ true }
|
||||
onHide={ close }
|
||||
onKeyDown={ isOpen ? submitChallenge : noop }
|
||||
show={ isOpen }
|
||||
>
|
||||
<Modal.Header
|
||||
className={ `${ns}-list-header` }
|
||||
closeButton={ true }
|
||||
>
|
||||
<Modal.Title>{ message }</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div className='text-center'>
|
||||
<div className='row'>
|
||||
<div>
|
||||
<FontAwesome
|
||||
className='completion-icon text-primary'
|
||||
name='check-circle'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
block={ true }
|
||||
bsSize='large'
|
||||
bsStyle='primary'
|
||||
onClick={ submitChallenge }
|
||||
>
|
||||
Submit and go to next challenge (Ctrl + Enter)
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CompletionModal.displayName = 'CompletionModal';
|
||||
CompletionModal.propTypes = propTypes;
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(CompletionModal);
|
@ -5,61 +5,52 @@ import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import PureComponent from 'react-pure-render/component';
|
||||
|
||||
import CompletionModal from './Completion-Modal.jsx';
|
||||
import Classic from './views/classic';
|
||||
import Step from './views/step';
|
||||
import Project from './views/project';
|
||||
import Video from './views/video';
|
||||
import BackEnd from './views/backend';
|
||||
|
||||
import { challengeMetaSelector } from './redux';
|
||||
import {
|
||||
updateTitle,
|
||||
updateCurrentChallenge,
|
||||
fetchChallenge,
|
||||
fetchChallenges,
|
||||
replaceChallenge,
|
||||
resetUi
|
||||
} from './redux/actions';
|
||||
import { challengeSelector } from './redux/selectors';
|
||||
import { updateTitle } from '../../redux/actions';
|
||||
import { makeToast } from '../../toasts/redux/actions';
|
||||
|
||||
challengeSelector,
|
||||
langSelector
|
||||
} from '../../redux';
|
||||
import { makeToast } from '../../Toasts/redux';
|
||||
|
||||
const views = {
|
||||
backend: BackEnd,
|
||||
classic: Classic,
|
||||
project: Project,
|
||||
simple: Project,
|
||||
step: Step,
|
||||
video: Video
|
||||
step: Step
|
||||
};
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchChallenge,
|
||||
fetchChallenges,
|
||||
makeToast,
|
||||
replaceChallenge,
|
||||
resetUi,
|
||||
updateCurrentChallenge,
|
||||
updateTitle
|
||||
};
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
challengeSelector,
|
||||
state => state.challengesApp.challenge,
|
||||
state => state.challengesApp.superBlocks,
|
||||
state => state.app.lang,
|
||||
challengeMetaSelector,
|
||||
langSelector,
|
||||
(
|
||||
{
|
||||
challenge: { isTranslated } = {},
|
||||
viewType,
|
||||
title
|
||||
},
|
||||
challenge,
|
||||
superBlocks = [],
|
||||
{ dashedName, isTranslated },
|
||||
{ viewType, title },
|
||||
lang
|
||||
) => ({
|
||||
lang,
|
||||
isTranslated,
|
||||
title,
|
||||
challenge,
|
||||
viewType,
|
||||
areChallengesLoaded: superBlocks.length > 0
|
||||
challenge: dashedName,
|
||||
viewType
|
||||
})
|
||||
);
|
||||
|
||||
@ -74,26 +65,23 @@ const fetchOptions = {
|
||||
};
|
||||
|
||||
const link = 'http://forum.freecodecamp.com/t/' +
|
||||
'guidelines-for-translating-free-code-camp' +
|
||||
'-to-any-language/19111';
|
||||
'guidelines-for-translating-free-code-camp' +
|
||||
'-to-any-language/19111';
|
||||
|
||||
const propTypes = {
|
||||
areChallengesLoaded: PropTypes.bool,
|
||||
fetchChallenges: PropTypes.func.isRequired,
|
||||
isStep: PropTypes.bool,
|
||||
isTranslated: PropTypes.bool,
|
||||
lang: PropTypes.string.isRequired,
|
||||
makeToast: PropTypes.func.isRequired,
|
||||
params: PropTypes.object.isRequired,
|
||||
replaceChallenge: PropTypes.func.isRequired,
|
||||
resetUi: PropTypes.func.isRequired,
|
||||
title: PropTypes.string,
|
||||
updateCurrentChallenge: PropTypes.func.isRequired,
|
||||
updateTitle: PropTypes.func.isRequired,
|
||||
viewType: PropTypes.string
|
||||
};
|
||||
|
||||
export class Show extends PureComponent {
|
||||
|
||||
componentWillMount() {
|
||||
const { lang, isTranslated, makeToast } = this.props;
|
||||
if (lang !== 'en' && !isTranslated) {
|
||||
@ -106,27 +94,19 @@ export class Show extends PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.areChallengesLoaded) {
|
||||
this.props.fetchChallenges();
|
||||
}
|
||||
if (this.props.title) {
|
||||
this.props.updateTitle(this.props.title);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.resetUi();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const { title } = nextProps;
|
||||
const { block, dashedName } = nextProps.params;
|
||||
const { dashedName } = nextProps.params;
|
||||
const { lang, isTranslated } = nextProps;
|
||||
const { resetUi, updateTitle, replaceChallenge, makeToast } = this.props;
|
||||
const { updateTitle, updateCurrentChallenge, makeToast } = this.props;
|
||||
if (this.props.params.dashedName !== dashedName) {
|
||||
updateCurrentChallenge(dashedName);
|
||||
updateTitle(title);
|
||||
resetUi();
|
||||
replaceChallenge({ dashedName, block });
|
||||
if (lang !== 'en' && !isTranslated) {
|
||||
makeToast({
|
||||
message: 'We haven\'t translated this challenge yet.',
|
||||
@ -140,7 +120,12 @@ export class Show extends PureComponent {
|
||||
render() {
|
||||
const { viewType } = this.props;
|
||||
const View = views[viewType] || Classic;
|
||||
return <View />;
|
||||
return (
|
||||
<div>
|
||||
<View />
|
||||
<CompletionModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -78,4 +78,86 @@
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@keyframes skeletonShimmer{
|
||||
0% {
|
||||
transform: translateX(-48px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(1000px);
|
||||
}
|
||||
}
|
||||
|
||||
.@{ns}-shimmer {
|
||||
position: relative;
|
||||
min-height: 18px;
|
||||
|
||||
.row {
|
||||
height: 18px;
|
||||
|
||||
.col-xs-12 {
|
||||
padding-right: 12px;
|
||||
height: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.sprite-wrapper {
|
||||
background-color: #333;
|
||||
height: 17px;
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.sprite {
|
||||
animation-name: skeletonShimmer;
|
||||
animation-duration: 2.5s;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: normal;
|
||||
background: white;
|
||||
box-shadow: 0 0 3px 2px;
|
||||
height: 17px;
|
||||
width: 2px;
|
||||
z-index: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.@{ns}-success-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 50vh;
|
||||
|
||||
.modal-title {
|
||||
color: @gray-lighter;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background-color: @brand-primary;
|
||||
margin-bottom: 0;
|
||||
|
||||
.close {
|
||||
color: #eee;
|
||||
font-size: 4rem;
|
||||
opacity: 0.6;
|
||||
transition: all 300ms ease-out;
|
||||
margin-top: 0;
|
||||
padding-left: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 35px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.fa {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&{ @import "./views/index.less"; }
|
||||
|
@ -1,8 +1,20 @@
|
||||
import Show from './Show.jsx';
|
||||
import _Map from './views/map';
|
||||
import { panesMap as backendPanesMap } from './views/backend';
|
||||
import { panesMap as classicPanesMap } from './views/classic';
|
||||
import { panesMap as stepPanesMap } from './views/step';
|
||||
import { panesMap as projectPanesMap } from './views/project';
|
||||
|
||||
export function challengesRoute() {
|
||||
export function createPanesMap() {
|
||||
return {
|
||||
...backendPanesMap,
|
||||
...classicPanesMap,
|
||||
...stepPanesMap,
|
||||
...projectPanesMap
|
||||
};
|
||||
}
|
||||
|
||||
export default function challengesRoutes() {
|
||||
return [{
|
||||
path: 'challenges(/:dashedName)',
|
||||
component: Show,
|
||||
onEnter(nextState, replace) {
|
||||
@ -11,19 +23,8 @@ export function challengesRoute() {
|
||||
replace('/map');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function modernChallengesRoute() {
|
||||
return {
|
||||
}, {
|
||||
path: 'challenges/:block/:dashedName',
|
||||
component: Show
|
||||
};
|
||||
}
|
||||
|
||||
export function mapRoute() {
|
||||
return {
|
||||
path: 'map',
|
||||
component: _Map
|
||||
};
|
||||
}];
|
||||
}
|
||||
|
@ -1,146 +0,0 @@
|
||||
import { createAction } from 'redux-actions';
|
||||
import { setContent } from '../../../../utils/polyvinyl';
|
||||
import { getMouse, loggerToStr } from '../utils';
|
||||
|
||||
import types from './types';
|
||||
|
||||
// step
|
||||
export const stepForward = createAction(types.stepForward);
|
||||
export const stepBackward = createAction(types.stepBackward);
|
||||
export const goToStep = createAction(
|
||||
types.goToStep,
|
||||
(step, isUnlocked) => ({ step, isUnlocked })
|
||||
);
|
||||
export const completeAction = createAction(types.completeAction);
|
||||
export const updateUnlockedSteps = createAction(types.updateUnlockedSteps);
|
||||
export const openLightBoxImage = createAction(types.openLightBoxImage);
|
||||
export const closeLightBoxImage = createAction(types.closeLightBoxImage);
|
||||
|
||||
// challenges
|
||||
export const fetchChallenge = createAction(
|
||||
types.fetchChallenge,
|
||||
(dashedName, block) => ({ dashedName, block })
|
||||
);
|
||||
export const fetchChallengeCompleted = createAction(
|
||||
types.fetchChallengeCompleted,
|
||||
(_, challenge) => challenge,
|
||||
entities => ({ entities })
|
||||
);
|
||||
export const closeChallengeModal = createAction(types.closeChallengeModal);
|
||||
export const resetUi = createAction(types.resetUi);
|
||||
export const updateHint = createAction(types.updateHint);
|
||||
export const lockUntrustedCode = createAction(types.lockUntrustedCode);
|
||||
export const unlockUntrustedCode = createAction(
|
||||
types.unlockUntrustedCode,
|
||||
() => null
|
||||
);
|
||||
export const updateSuccessMessage = createAction(types.updateSuccessMessage);
|
||||
export const fetchChallenges = createAction(types.fetchChallenges);
|
||||
export const fetchChallengesCompleted = createAction(
|
||||
types.fetchChallengesCompleted,
|
||||
(_, superBlocks) => superBlocks,
|
||||
entities => ({ entities })
|
||||
);
|
||||
|
||||
export const updateCurrentChallenge = createAction(
|
||||
types.updateCurrentChallenge
|
||||
);
|
||||
export const resetChallenge = createAction(types.resetChallenge);
|
||||
// replaceChallenge(dashedname) => Action
|
||||
export const replaceChallenge = createAction(types.replaceChallenge);
|
||||
|
||||
// map
|
||||
export const updateFilter = createAction(
|
||||
types.updateFilter,
|
||||
e => e.target.value
|
||||
);
|
||||
|
||||
export const initMap = createAction(types.initMap);
|
||||
export const toggleThisPanel = createAction(types.toggleThisPanel);
|
||||
export const collapseAll = createAction(types.collapseAll);
|
||||
export const expandAll = createAction(types.expandAll);
|
||||
|
||||
export const clearFilter = createAction(types.clearFilter);
|
||||
|
||||
// files
|
||||
export const updateFile = createAction(
|
||||
types.updateFile,
|
||||
(content, file) => setContent(content, file)
|
||||
);
|
||||
|
||||
export const updateFiles = createAction(types.updateFiles);
|
||||
|
||||
// rechallenge
|
||||
export const executeChallenge = createAction(
|
||||
types.executeChallenge,
|
||||
() => null
|
||||
);
|
||||
|
||||
export const updateMain = createAction(types.updateMain);
|
||||
export const frameMain = createAction(types.frameMain);
|
||||
export const frameTests = createAction(types.frameTests);
|
||||
|
||||
export const runTests = createAction(types.runTests);
|
||||
export const updateTests = createAction(types.updateTests);
|
||||
|
||||
export const initOutput = createAction(types.initOutput, loggerToStr);
|
||||
export const updateOutput = createAction(types.updateOutput, loggerToStr);
|
||||
|
||||
export const checkChallenge = createAction(types.checkChallenge);
|
||||
|
||||
export const showProjectSubmit = createAction(types.showProjectSubmit);
|
||||
|
||||
export const submitChallenge = createAction(types.submitChallenge);
|
||||
export const moveToNextChallenge = createAction(types.moveToNextChallenge);
|
||||
|
||||
// code storage
|
||||
export const saveCode = createAction(types.saveCode);
|
||||
export const loadCode = createAction(types.loadCode);
|
||||
export const savedCodeFound = createAction(
|
||||
types.savedCodeFound,
|
||||
(files, challenge) => ({ files, challenge })
|
||||
);
|
||||
export const clearSavedCode = createAction(types.clearSavedCode);
|
||||
|
||||
|
||||
// video challenges
|
||||
export const toggleQuestionView = createAction(types.toggleQuestionView);
|
||||
export const grabQuestion = createAction(types.grabQuestion, e => {
|
||||
let { pageX, pageY, touches } = e;
|
||||
if (touches) {
|
||||
e.preventDefault();
|
||||
// these re-assigns the values of pageX, pageY from touches
|
||||
({ pageX, pageY } = touches[0]);
|
||||
}
|
||||
const delta = [pageX, pageY];
|
||||
const mouse = [0, 0];
|
||||
|
||||
return { delta, mouse };
|
||||
});
|
||||
|
||||
export const releaseQuestion = createAction(types.releaseQuestion);
|
||||
export const moveQuestion = createAction(
|
||||
types.moveQuestion,
|
||||
({ e, delta }) => getMouse(e, delta)
|
||||
);
|
||||
|
||||
// answer({
|
||||
// e: Event,
|
||||
// answer: Boolean,
|
||||
// userAnswer: Boolean,
|
||||
// info: String,
|
||||
// threshold: Number
|
||||
// }) => Action
|
||||
export const answerQuestion = createAction(types.answerQuestion);
|
||||
|
||||
export const startShake = createAction(types.startShake);
|
||||
export const endShake = createAction(types.primeNextQuestion);
|
||||
|
||||
export const goToNextQuestion = createAction(types.goToNextQuestion);
|
||||
export const videoCompleted = createAction(types.videoCompleted);
|
||||
|
||||
// bug
|
||||
export const openBugModal = createAction(types.openBugModal);
|
||||
export const closeBugModal = createAction(types.closeBugModal);
|
||||
export const openIssueSearch = createAction(types.openIssueSearch);
|
||||
export const createIssue = createAction(types.createIssue);
|
@ -1,86 +0,0 @@
|
||||
import { Observable } from 'rx';
|
||||
import types from './types';
|
||||
import { getMouse } from '../utils';
|
||||
|
||||
import { submitChallenge, videoCompleted } from './actions';
|
||||
import { createErrorObservable } from '../../../redux/actions';
|
||||
import { makeToast } from '../../../toasts/redux/actions';
|
||||
import { challengeSelector } from './selectors';
|
||||
|
||||
export default function answerSaga(action$, getState) {
|
||||
return action$
|
||||
.filter(action => action.type === types.answerQuestion)
|
||||
.flatMap(({
|
||||
payload: {
|
||||
e,
|
||||
answer,
|
||||
userAnswer,
|
||||
info,
|
||||
threshold
|
||||
}
|
||||
}) => {
|
||||
const state = getState();
|
||||
const {
|
||||
challenge: { tests }
|
||||
} = challengeSelector(state);
|
||||
const {
|
||||
challengesApp: {
|
||||
currentQuestion,
|
||||
delta = [ 0, 0 ]
|
||||
}
|
||||
} = state;
|
||||
|
||||
let finalAnswer;
|
||||
// drag answer, compute response
|
||||
if (typeof userAnswer === 'undefined') {
|
||||
const [positionX] = getMouse(e, delta);
|
||||
|
||||
// question released under threshold
|
||||
if (Math.abs(positionX) < threshold) {
|
||||
return Observable.just(null);
|
||||
}
|
||||
|
||||
if (positionX >= threshold) {
|
||||
finalAnswer = true;
|
||||
}
|
||||
|
||||
if (positionX <= -threshold) {
|
||||
finalAnswer = false;
|
||||
}
|
||||
} else {
|
||||
finalAnswer = userAnswer;
|
||||
}
|
||||
|
||||
// incorrect question
|
||||
if (answer !== finalAnswer) {
|
||||
let infoAction;
|
||||
if (info) {
|
||||
infoAction = makeToast({
|
||||
message: info,
|
||||
timeout: 5000
|
||||
});
|
||||
}
|
||||
|
||||
return Observable
|
||||
.just({ type: types.endShake })
|
||||
.delay(500)
|
||||
.startWith(infoAction, { type: types.startShake });
|
||||
}
|
||||
|
||||
if (tests[currentQuestion]) {
|
||||
return Observable
|
||||
.just({ type: types.goToNextQuestion })
|
||||
.delay(300)
|
||||
.startWith({ type: types.primeNextQuestion });
|
||||
}
|
||||
|
||||
|
||||
return Observable.just(submitChallenge())
|
||||
.delay(300)
|
||||
// moves question to the appropriate side of the screen
|
||||
.startWith(videoCompleted(finalAnswer))
|
||||
// end with action so we know it is ok to transition
|
||||
.concat(Observable.just({ type: types.transitionHike }))
|
||||
.catch(createErrorObservable);
|
||||
});
|
||||
}
|
@ -1,5 +1,12 @@
|
||||
import types from '../redux/types';
|
||||
import { closeBugModal } from '../redux/actions';
|
||||
import { ofType } from 'redux-epic';
|
||||
import {
|
||||
types,
|
||||
closeBugModal,
|
||||
|
||||
filesSelector
|
||||
} from '../redux';
|
||||
|
||||
import { currentChallengeSelector } from '../../../redux';
|
||||
|
||||
function filesToMarkdown(files = {}) {
|
||||
const moreThenOneFile = Object.keys(files).length > 1;
|
||||
@ -22,19 +29,12 @@ function filesToMarkdown(files = {}) {
|
||||
}, '\n');
|
||||
}
|
||||
|
||||
export default function bugSaga(actions$, getState, { window }) {
|
||||
return actions$
|
||||
.filter(({ type }) => (
|
||||
type === types.openIssueSearch ||
|
||||
type === types.createIssue
|
||||
))
|
||||
export default function bugEpic(actions, { getState }, { window }) {
|
||||
return actions::ofType(types.openIssueSearch, types.createIssue)
|
||||
.map(({ type }) => {
|
||||
const {
|
||||
challengesApp: {
|
||||
legacyKey: challengeName,
|
||||
files
|
||||
}
|
||||
} = getState();
|
||||
const state = getState();
|
||||
const files = filesSelector(state);
|
||||
const challengeName = currentChallengeSelector(state);
|
||||
const {
|
||||
navigator: { userAgent },
|
||||
location: { href }
|
@ -1,22 +1,59 @@
|
||||
import debug from 'debug';
|
||||
import { Observable } from 'rx';
|
||||
import { combineEpics, ofType } from 'redux-epic';
|
||||
import { push } from 'react-router-redux';
|
||||
import types from './types';
|
||||
import { resetUi, updateCurrentChallenge } from './actions';
|
||||
import { createErrorObservable } from '../../../redux/actions';
|
||||
import { makeToast } from '../../../toasts/redux/actions';
|
||||
|
||||
import {
|
||||
types,
|
||||
|
||||
updateMain,
|
||||
challengeUpdated
|
||||
} from './';
|
||||
import { getNS as entitiesSelector } from '../../../entities';
|
||||
import {
|
||||
getNextChallenge,
|
||||
getFirstChallengeOfNextBlock,
|
||||
getFirstChallengeOfNextSuperBlock
|
||||
} from '../utils';
|
||||
import debug from 'debug';
|
||||
import {
|
||||
types as app,
|
||||
|
||||
createErrorObservable,
|
||||
updateCurrentChallenge,
|
||||
|
||||
currentChallengeSelector,
|
||||
challengeSelector,
|
||||
superBlocksSelector
|
||||
} from '../../../redux';
|
||||
import { makeToast } from '../../../Toasts/redux';
|
||||
|
||||
const isDev = debug.enabled('fcc:*');
|
||||
const { moveToNextChallenge } = types;
|
||||
|
||||
export default function nextChallengeSaga(actions$, getState) {
|
||||
return actions$
|
||||
.filter(({ type }) => type === moveToNextChallenge)
|
||||
export function challengeUpdatedEpic(actions, { getState }) {
|
||||
return actions::ofType(app.updateCurrentChallenge)
|
||||
.flatMap(() => {
|
||||
const challenge = challengeSelector(getState());
|
||||
return Observable.of(
|
||||
challengeUpdated(challenge),
|
||||
push(`/challenges/${challenge.block}/${challenge.dashedName}`)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// used to reset users code on request
|
||||
export function resetChallengeEpic(actions, { getState }) {
|
||||
return actions::ofType(types.resetChallenge)
|
||||
.flatMap(() => {
|
||||
const currentChallenge = currentChallengeSelector(getState());
|
||||
return Observable.of(
|
||||
updateCurrentChallenge(currentChallenge),
|
||||
updateMain()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function nextChallengeEpic(actions, { getState }) {
|
||||
return actions::ofType(types.moveToNextChallenge)
|
||||
.flatMap(() => {
|
||||
let nextChallenge;
|
||||
// let message = '';
|
||||
@ -24,8 +61,9 @@ export default function nextChallengeSaga(actions$, getState) {
|
||||
// let isNewSuperBlock = false;
|
||||
try {
|
||||
const state = getState();
|
||||
const { challenge, superBlocks } = state.challengesApp;
|
||||
const { entities } = state;
|
||||
const superBlocks = superBlocksSelector(state);
|
||||
const challenge = currentChallengeSelector(state);
|
||||
const entities = entitiesSelector(state);
|
||||
nextChallenge = getNextChallenge(challenge, entities, { isDev });
|
||||
// block completed.
|
||||
if (!nextChallenge) {
|
||||
@ -73,13 +111,17 @@ export default function nextChallengeSaga(actions$, getState) {
|
||||
);
|
||||
}
|
||||
return Observable.of(
|
||||
updateCurrentChallenge(nextChallenge),
|
||||
resetUi(),
|
||||
makeToast({ message: 'Your next challenge has arrived.' }),
|
||||
push(`/challenges/${nextChallenge.block}/${nextChallenge.dashedName}`)
|
||||
updateCurrentChallenge(nextChallenge.dashedName),
|
||||
makeToast({ message: 'Your next challenge has arrived.' })
|
||||
);
|
||||
} catch (err) {
|
||||
return createErrorObservable(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default combineEpics(
|
||||
challengeUpdatedEpic,
|
||||
nextChallengeEpic,
|
||||
resetChallengeEpic
|
||||
);
|
@ -1,25 +1,35 @@
|
||||
import { Observable } from 'rx';
|
||||
import { ofType } from 'redux-epic';
|
||||
|
||||
import types from './types.js';
|
||||
import {
|
||||
moveToNextChallenge,
|
||||
clearSavedCode
|
||||
} from './actions.js';
|
||||
types,
|
||||
|
||||
moveToNextChallenge,
|
||||
clearSavedCode,
|
||||
|
||||
challengeMetaSelector,
|
||||
filesSelector,
|
||||
testsSelector
|
||||
} from './';
|
||||
|
||||
import { challengeSelector } from './selectors.js';
|
||||
import {
|
||||
createErrorObservable,
|
||||
|
||||
challengeSelector,
|
||||
csrfSelector,
|
||||
userSelector
|
||||
} from '../../../redux';
|
||||
import {
|
||||
updateUserPoints,
|
||||
updateUserChallenge
|
||||
} from '../../../redux/actions.js';
|
||||
} from '../../../entities';
|
||||
import { backEndProject } from '../../../utils/challengeTypes.js';
|
||||
import { makeToast } from '../../../toasts/redux/actions.js';
|
||||
import { makeToast } from '../../../Toasts/redux';
|
||||
import { postJSON$ } from '../../../../utils/ajax-stream.js';
|
||||
import { ofType } from '../../../../utils/get-actions-of-type.js';
|
||||
|
||||
function postChallenge(url, username, _csrf, challengeInfo) {
|
||||
const body = { ...challengeInfo, _csrf };
|
||||
const saveChallenge$ = postJSON$(url, body)
|
||||
const saveChallenge = postJSON$(url, body)
|
||||
.retry(3)
|
||||
.flatMap(({ points, lastUpdated, completedDate }) => {
|
||||
return Observable.of(
|
||||
@ -32,29 +42,27 @@ function postChallenge(url, username, _csrf, challengeInfo) {
|
||||
);
|
||||
})
|
||||
.catch(createErrorObservable);
|
||||
const challengeCompleted$ = Observable.of(moveToNextChallenge());
|
||||
return Observable.merge(saveChallenge$, challengeCompleted$);
|
||||
const challengeCompleted = Observable.of(moveToNextChallenge());
|
||||
return Observable.merge(saveChallenge, challengeCompleted);
|
||||
}
|
||||
|
||||
function submitModern(type, state) {
|
||||
const { tests } = state.challengesApp;
|
||||
const tests = testsSelector(state);
|
||||
if (tests.length > 0 && tests.every(test => test.pass && !test.err)) {
|
||||
if (type === types.checkChallenge) {
|
||||
return Observable.just(null);
|
||||
return Observable.empty();
|
||||
}
|
||||
|
||||
if (type === types.submitChallenge) {
|
||||
const { challenge: { id } } = challengeSelector(state);
|
||||
const {
|
||||
app: { user, csrfToken },
|
||||
challengesApp: { files }
|
||||
} = state;
|
||||
const challengeInfo = { id, files };
|
||||
const { id } = challengeSelector(state);
|
||||
const files = filesSelector(state);
|
||||
const { username } = userSelector(state);
|
||||
const csrfToken = csrfSelector(state);
|
||||
return postChallenge(
|
||||
'/modern-challenge-completed',
|
||||
user,
|
||||
username,
|
||||
csrfToken,
|
||||
challengeInfo
|
||||
{ id, files }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -64,42 +72,36 @@ function submitModern(type, state) {
|
||||
}
|
||||
|
||||
function submitProject(type, state, { solution, githubLink }) {
|
||||
const {
|
||||
challenge: { id, challengeType }
|
||||
} = challengeSelector(state);
|
||||
const {
|
||||
app: { user, csrfToken }
|
||||
} = state;
|
||||
const { id, challengeType } = challengeSelector(state);
|
||||
const { username } = userSelector(state);
|
||||
const csrfToken = csrfSelector(state);
|
||||
const challengeInfo = { id, challengeType, solution };
|
||||
if (challengeType === backEndProject) {
|
||||
challengeInfo.githubLink = githubLink;
|
||||
}
|
||||
return postChallenge(
|
||||
'/project-completed',
|
||||
user,
|
||||
username,
|
||||
csrfToken,
|
||||
challengeInfo
|
||||
);
|
||||
}
|
||||
|
||||
function submitSimpleChallenge(type, state) {
|
||||
const {
|
||||
challenge: { id }
|
||||
} = challengeSelector(state);
|
||||
const {
|
||||
app: { user, csrfToken }
|
||||
} = state;
|
||||
const { id } = challengeSelector(state);
|
||||
const { username } = userSelector(state);
|
||||
const csrfToken = csrfSelector(state);
|
||||
const challengeInfo = { id };
|
||||
return postChallenge(
|
||||
'/challenge-completed',
|
||||
user,
|
||||
username,
|
||||
csrfToken,
|
||||
challengeInfo
|
||||
);
|
||||
}
|
||||
|
||||
function submitBackendChallenge(type, state, { solution }) {
|
||||
const { tests } = state.challengesApp;
|
||||
const tests = testsSelector(state);
|
||||
if (
|
||||
type === types.checkChallenge &&
|
||||
tests.length > 0 &&
|
||||
@ -115,13 +117,13 @@ function submitBackendChallenge(type, state, { solution }) {
|
||||
})
|
||||
);
|
||||
*/
|
||||
|
||||
const { challenge: { id } } = challengeSelector(state);
|
||||
const { app: { user, csrfToken } } = state;
|
||||
const { id } = challengeSelector(state);
|
||||
const { username } = userSelector(state);
|
||||
const csrfToken = csrfSelector(state);
|
||||
const challengeInfo = { id, solution };
|
||||
return postChallenge(
|
||||
'/backend-challenge-completed',
|
||||
user,
|
||||
username,
|
||||
csrfToken,
|
||||
challengeInfo
|
||||
);
|
||||
@ -141,14 +143,12 @@ const submitters = {
|
||||
'project.simple': submitSimpleChallenge
|
||||
};
|
||||
|
||||
export default function completionSaga(actions$, getState) {
|
||||
return actions$
|
||||
::ofType(types.checkChallenge, types.submitChallenge)
|
||||
export default function completionEpic(actions, { getState }) {
|
||||
return actions::ofType(types.checkChallenge, types.submitChallenge)
|
||||
.flatMap(({ type, payload }) => {
|
||||
const state = getState();
|
||||
const { submitType } = challengeSelector(state);
|
||||
const submitter = submitters[submitType] ||
|
||||
(() => Observable.just(null));
|
||||
const { submitType } = challengeMetaSelector(state);
|
||||
const submitter = submitters[submitType] || (() => Observable.empty());
|
||||
return submitter(type, state, payload);
|
||||
});
|
||||
}
|
17
common/app/routes/challenges/redux/editor-epic.js
Normal file
17
common/app/routes/challenges/redux/editor-epic.js
Normal file
@ -0,0 +1,17 @@
|
||||
import { ofType } from 'redux-epic';
|
||||
|
||||
import {
|
||||
types,
|
||||
updateFile,
|
||||
|
||||
keySelector
|
||||
} from './';
|
||||
|
||||
export default function editorEpic(actions, { getState }) {
|
||||
return actions::ofType(types.classicEditorUpdated)
|
||||
.pluck('payload')
|
||||
.map(content => updateFile({
|
||||
content,
|
||||
key: keySelector(getState())
|
||||
}));
|
||||
}
|
@ -1,89 +0,0 @@
|
||||
import { Observable } from 'rx';
|
||||
import debug from 'debug';
|
||||
|
||||
import { challengeSelector } from './selectors';
|
||||
import types from './types';
|
||||
import {
|
||||
fetchChallengeCompleted,
|
||||
fetchChallengesCompleted,
|
||||
updateCurrentChallenge,
|
||||
initMap
|
||||
} from './actions';
|
||||
import {
|
||||
createMapUi,
|
||||
filterComingSoonBetaFromEntities,
|
||||
searchableChallengeTitles
|
||||
} from '../utils';
|
||||
import {
|
||||
delayedRedirect,
|
||||
createErrorObservable
|
||||
} from '../../../redux/actions';
|
||||
import createNameIdMap from '../../../../utils/create-name-id-map';
|
||||
|
||||
const isDev = debug.enabled('fcc:*');
|
||||
|
||||
const { fetchChallenge, fetchChallenges, replaceChallenge } = types;
|
||||
|
||||
export default function fetchChallengesSaga(action$, getState, { services }) {
|
||||
return action$
|
||||
.filter(({ type }) => (
|
||||
type === fetchChallenges ||
|
||||
type === fetchChallenge ||
|
||||
type === replaceChallenge
|
||||
))
|
||||
.flatMap(({ type, payload: { dashedName, block } = {} }) => {
|
||||
const state = getState();
|
||||
const lang = state.app.lang;
|
||||
if (type === replaceChallenge) {
|
||||
const { challenge: newChallenge } = challengeSelector({
|
||||
...state,
|
||||
challengesApp: {
|
||||
...state.challengesApp,
|
||||
challenge: dashedName
|
||||
}
|
||||
});
|
||||
if (state.challengesApp.challenge !== newChallenge.dashedName) {
|
||||
return Observable.just(updateCurrentChallenge(newChallenge));
|
||||
}
|
||||
return Observable.just(null);
|
||||
}
|
||||
const options = { service: 'map' };
|
||||
options.params = { lang };
|
||||
if (type === fetchChallenge) {
|
||||
options.params.dashedName = dashedName;
|
||||
options.params.block = block;
|
||||
}
|
||||
return services.readService$(options)
|
||||
.retry(3)
|
||||
.flatMap(({ entities, result, redirect } = {}) => {
|
||||
if (type === fetchChallenge) {
|
||||
return Observable.of(
|
||||
fetchChallengeCompleted(
|
||||
createNameIdMap(entities),
|
||||
result
|
||||
),
|
||||
updateCurrentChallenge(entities.challenge[result.challenge]),
|
||||
redirect ? delayedRedirect(redirect) : null
|
||||
);
|
||||
}
|
||||
const filteredEntities = filterComingSoonBetaFromEntities(
|
||||
entities,
|
||||
isDev
|
||||
);
|
||||
const searchNames = searchableChallengeTitles(filteredEntities);
|
||||
return Observable.of(
|
||||
fetchChallengesCompleted(
|
||||
createNameIdMap(filteredEntities),
|
||||
result
|
||||
),
|
||||
initMap(
|
||||
createMapUi(
|
||||
filteredEntities,
|
||||
result,
|
||||
searchNames
|
||||
)),
|
||||
);
|
||||
})
|
||||
.catch(createErrorObservable);
|
||||
});
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user