feat(Profile): Reactify profile page (#16743)
* feat(Profile): Reactify profile page * chore(tidyup): Remove console.log * fix(timeline): Remove legacy challenges from Timeline render * fix(style): Remove underline on a:hover
This commit is contained in:
committed by
Quincy Larson
parent
24ef69cf7a
commit
3131c55782
@ -16,6 +16,7 @@ import Toasts from './Toasts';
|
|||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
import { mainRouteSelector } from './routes/redux';
|
import { mainRouteSelector } from './routes/redux';
|
||||||
import Challenges from './routes/Challenges';
|
import Challenges from './routes/Challenges';
|
||||||
|
import Profile from './routes/Profile';
|
||||||
import Settings from './routes/Settings';
|
import Settings from './routes/Settings';
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
@ -44,6 +45,7 @@ const propTypes = {
|
|||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
challenges: Challenges,
|
challenges: Challenges,
|
||||||
|
profile: Profile,
|
||||||
settings: Settings
|
settings: Settings
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -5,7 +5,6 @@ import { connect } from 'react-redux';
|
|||||||
import capitalize from 'lodash/capitalize';
|
import capitalize from 'lodash/capitalize';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import FCCSearchBar from 'react-freecodecamp-search';
|
import FCCSearchBar from 'react-freecodecamp-search';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Nav,
|
Nav,
|
||||||
@ -15,6 +14,7 @@ import {
|
|||||||
NavbarBrand
|
NavbarBrand
|
||||||
} from 'react-bootstrap';
|
} from 'react-bootstrap';
|
||||||
|
|
||||||
|
import NoPropsPassThrough from '../utils/No-Props-Passthrough.jsx';
|
||||||
import { Link } from '../Router';
|
import { Link } from '../Router';
|
||||||
import navLinks from './links.json';
|
import navLinks from './links.json';
|
||||||
import SignUp from './Sign-Up.jsx';
|
import SignUp from './Sign-Up.jsx';
|
||||||
@ -30,6 +30,7 @@ import {
|
|||||||
} from './redux';
|
} from './redux';
|
||||||
import { isSignedInSelector, signInLoadingSelector } from '../redux';
|
import { isSignedInSelector, signInLoadingSelector } from '../redux';
|
||||||
import { panesSelector } from '../Panes/redux';
|
import { panesSelector } from '../Panes/redux';
|
||||||
|
import { onRouteCurrentChallenge } from '../routes/Challenges/redux';
|
||||||
|
|
||||||
|
|
||||||
const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg';
|
const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg';
|
||||||
@ -224,11 +225,16 @@ export class FCCNav extends React.Component {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
{ shouldShowMapButton ?
|
{ shouldShowMapButton ?
|
||||||
<BinButton
|
<NoPropsPassThrough>
|
||||||
content='Map'
|
<li>
|
||||||
handleClick={ clickOnMap }
|
<Link
|
||||||
key='Map'
|
onClick={ clickOnMap }
|
||||||
/> :
|
to={ onRouteCurrentChallenge() }
|
||||||
|
>
|
||||||
|
Map
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</NoPropsPassThrough> :
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
@ -151,7 +151,17 @@ export const isChallengeLoaded = (state, { dashedName }) =>
|
|||||||
export default composeReducers(
|
export default composeReducers(
|
||||||
ns,
|
ns,
|
||||||
function metaReducer(state = defaultState, action) {
|
function metaReducer(state = defaultState, action) {
|
||||||
if (action.meta && action.meta.entities) {
|
const { meta } = action;
|
||||||
|
if (meta && meta.entities) {
|
||||||
|
if (meta.entities.user) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
user: {
|
||||||
|
...state.user,
|
||||||
|
...meta.entities.user
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
...action.meta.entities
|
...action.meta.entities
|
||||||
@ -159,7 +169,7 @@ export default composeReducers(
|
|||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
},
|
},
|
||||||
function(state = defaultState, action) {
|
function entitiesReducer(state = defaultState, action) {
|
||||||
if (getEntityAction(action)) {
|
if (getEntityAction(action)) {
|
||||||
const { payload: { username, theme } } = getEntityAction(action);
|
const { payload: { username, theme } } = getEntityAction(action);
|
||||||
return {
|
return {
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
import { ofType } from 'redux-epic';
|
import { Observable } from 'rx';
|
||||||
|
import { ofType, combineEpics } from 'redux-epic';
|
||||||
|
|
||||||
|
import { getJSON$ } from '../../utils/ajax-stream';
|
||||||
import {
|
import {
|
||||||
types,
|
types,
|
||||||
|
|
||||||
fetchUserComplete,
|
fetchUserComplete,
|
||||||
|
fetchOtherUserComplete,
|
||||||
createErrorObservable,
|
createErrorObservable,
|
||||||
showSignIn
|
showSignIn
|
||||||
} from './';
|
} from './';
|
||||||
|
import { userFound } from '../routes/Profile/redux';
|
||||||
|
|
||||||
export default function getUserEpic(actions, _, { services }) {
|
function getUserEpic(actions, _, { services }) {
|
||||||
return actions::ofType('' + types.fetchUser)
|
return actions::ofType('' + types.fetchUser)
|
||||||
.flatMap(() => {
|
.flatMap(() => {
|
||||||
return services.readService$({ service: 'user' })
|
return services.readService$({ service: 'user' })
|
||||||
@ -17,3 +22,21 @@ export default function getUserEpic(actions, _, { services }) {
|
|||||||
.catch(createErrorObservable);
|
.catch(createErrorObservable);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOtherUserEpic(actions$) {
|
||||||
|
return actions$::ofType(types.fetchOtherUser.start)
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.flatMap(({ payload: otherUser }) => {
|
||||||
|
return getJSON$(`/api/users/get-public-profile?username=${otherUser}`)
|
||||||
|
.flatMap(response => Observable.of(
|
||||||
|
fetchOtherUserComplete(response),
|
||||||
|
userFound(!!response.result)
|
||||||
|
))
|
||||||
|
.catch(createErrorObservable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default combineEpics(
|
||||||
|
getUserEpic,
|
||||||
|
getOtherUserEpic
|
||||||
|
);
|
||||||
|
@ -17,6 +17,7 @@ import nightModeEpic from './night-mode-epic.js';
|
|||||||
import { createFilesMetaCreator } from '../files';
|
import { createFilesMetaCreator } from '../files';
|
||||||
import { updateThemeMetacreator, entitiesSelector } from '../entities';
|
import { updateThemeMetacreator, entitiesSelector } from '../entities';
|
||||||
import { utils } from '../Flash/redux';
|
import { utils } from '../Flash/redux';
|
||||||
|
import { paramsSelector } from '../Router/redux';
|
||||||
import { types as challenges } from '../routes/Challenges/redux';
|
import { types as challenges } from '../routes/Challenges/redux';
|
||||||
import { challengeToFiles } from '../routes/Challenges/utils';
|
import { challengeToFiles } from '../routes/Challenges/utils';
|
||||||
|
|
||||||
@ -41,6 +42,7 @@ export const types = createTypes([
|
|||||||
createAsyncTypes('fetchChallenge'),
|
createAsyncTypes('fetchChallenge'),
|
||||||
createAsyncTypes('fetchChallenges'),
|
createAsyncTypes('fetchChallenges'),
|
||||||
'updateChallenges',
|
'updateChallenges',
|
||||||
|
createAsyncTypes('fetchOtherUser'),
|
||||||
createAsyncTypes('fetchUser'),
|
createAsyncTypes('fetchUser'),
|
||||||
'showSignIn',
|
'showSignIn',
|
||||||
|
|
||||||
@ -109,9 +111,20 @@ export const fetchChallengesCompleted = createAction(
|
|||||||
entities => ({ entities })
|
entities => ({ entities })
|
||||||
);
|
);
|
||||||
export const updateChallenges = createAction(types.updateChallenges);
|
export const updateChallenges = createAction(types.updateChallenges);
|
||||||
|
|
||||||
// updateTitle(title: String) => Action
|
// updateTitle(title: String) => Action
|
||||||
export const updateTitle = createAction(types.updateTitle);
|
export const updateTitle = createAction(types.updateTitle);
|
||||||
|
|
||||||
|
// fetchOtherUser() => Action
|
||||||
|
// used in combination with fetch-user-epic
|
||||||
|
// to fetch another users profile
|
||||||
|
export const fetchOtherUser = createAction(types.fetchOtherUser.start);
|
||||||
|
export const fetchOtherUserComplete = createAction(
|
||||||
|
types.fetchOtherUser.complete,
|
||||||
|
({ result }) => result,
|
||||||
|
_.identity
|
||||||
|
);
|
||||||
|
|
||||||
// fetchUser() => Action
|
// fetchUser() => Action
|
||||||
// used in combination with fetch-user-epic
|
// used in combination with fetch-user-epic
|
||||||
export const fetchUser = createAction(types.fetchUser);
|
export const fetchUser = createAction(types.fetchUser);
|
||||||
@ -190,6 +203,12 @@ export const userSelector = createSelector(
|
|||||||
(username, userMap) => userMap[username] || {}
|
(username, userMap) => userMap[username] || {}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const userByNameSelector = state => {
|
||||||
|
const username = paramsSelector(state).username;
|
||||||
|
const userMap = entitiesSelector(state).user;
|
||||||
|
return userMap[username] || {};
|
||||||
|
};
|
||||||
|
|
||||||
export const themeSelector = _.flow(
|
export const themeSelector = _.flow(
|
||||||
userSelector,
|
userSelector,
|
||||||
user => user.theme || themes.default
|
user => user.theme || themes.default
|
||||||
|
227
common/app/routes/Profile/Profile.jsx
Normal file
227
common/app/routes/Profile/Profile.jsx
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Grid
|
||||||
|
} from 'react-bootstrap';
|
||||||
|
|
||||||
|
|
||||||
|
import {
|
||||||
|
updateTitle,
|
||||||
|
isSignedInSelector,
|
||||||
|
signInLoadingSelector,
|
||||||
|
usernameSelector,
|
||||||
|
userByNameSelector,
|
||||||
|
fetchOtherUser
|
||||||
|
} from '../../redux';
|
||||||
|
import { userFoundSelector } from './redux';
|
||||||
|
import { paramsSelector } from '../../Router/redux';
|
||||||
|
import ns from './ns.json';
|
||||||
|
import ChildContainer from '../../Child-Container.jsx';
|
||||||
|
import { Link } from '../../Router';
|
||||||
|
import CamperHOC from './components/CamperHOC.jsx';
|
||||||
|
import Portfolio from './components/Portfolio.jsx';
|
||||||
|
import Certificates from './components/Certificates.jsx';
|
||||||
|
import Timeline from './components/Timeline.jsx';
|
||||||
|
import HeatMap from './components/HeatMap.jsx';
|
||||||
|
import { FullWidthRow, Loader } from '../../helperComponents';
|
||||||
|
|
||||||
|
const mapStateToProps = createSelector(
|
||||||
|
isSignedInSelector,
|
||||||
|
userByNameSelector,
|
||||||
|
paramsSelector,
|
||||||
|
usernameSelector,
|
||||||
|
signInLoadingSelector,
|
||||||
|
userFoundSelector,
|
||||||
|
(
|
||||||
|
isSignedIn,
|
||||||
|
{ isLocked, username: requestedUsername },
|
||||||
|
{ username: paramsUsername, lang },
|
||||||
|
currentUsername,
|
||||||
|
showLoading,
|
||||||
|
isUserFound
|
||||||
|
) => ({
|
||||||
|
isSignedIn,
|
||||||
|
currentUsername,
|
||||||
|
isCurrentUserProfile: paramsUsername === currentUsername,
|
||||||
|
isLocked,
|
||||||
|
isUserFound,
|
||||||
|
fetchOtherUserCompleted: typeof isUserFound === 'boolean',
|
||||||
|
paramsUsername,
|
||||||
|
lang,
|
||||||
|
requestedUsername,
|
||||||
|
showLoading
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
fetchOtherUser,
|
||||||
|
updateTitle
|
||||||
|
};
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
currentUsername: PropTypes.string,
|
||||||
|
fetchOtherUser: PropTypes.func.isRequired,
|
||||||
|
fetchOtherUserCompleted: PropTypes.bool,
|
||||||
|
isCurrentUserProfile: PropTypes.bool,
|
||||||
|
isLocked: PropTypes.bool,
|
||||||
|
isSignedIn: PropTypes.bool,
|
||||||
|
isUserFound: PropTypes.bool,
|
||||||
|
lang: PropTypes.string,
|
||||||
|
paramsUsername: PropTypes.string,
|
||||||
|
requestedUsername: PropTypes.string,
|
||||||
|
showLoading: PropTypes.bool,
|
||||||
|
updateTitle: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
class Profile extends Component {
|
||||||
|
|
||||||
|
componentWillMount() {
|
||||||
|
this.props.updateTitle('Profile');
|
||||||
|
}
|
||||||
|
componentDidUpdate() {
|
||||||
|
const { requestedUsername, currentUsername, paramsUsername } = this.props;
|
||||||
|
if (!requestedUsername && paramsUsername !== currentUsername) {
|
||||||
|
this.props.fetchOtherUser(paramsUsername);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRequestedProfile() {
|
||||||
|
const {
|
||||||
|
fetchOtherUserCompleted,
|
||||||
|
isLocked,
|
||||||
|
isUserFound,
|
||||||
|
isCurrentUserProfile,
|
||||||
|
lang = 'en',
|
||||||
|
paramsUsername
|
||||||
|
} = this.props;
|
||||||
|
const takeMeToChallenges = (
|
||||||
|
<a href={`/${lang}/challenges/current-challenge`}>
|
||||||
|
<Button bsSize='lg' bsStyle='primary'>
|
||||||
|
Take me to the Challenges
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
if (isLocked) {
|
||||||
|
return (
|
||||||
|
<div className='full-size'>
|
||||||
|
<h3>
|
||||||
|
{
|
||||||
|
`${paramsUsername} has not made their profile public. `
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
<Alert bsStyle='info'>
|
||||||
|
<p>
|
||||||
|
{
|
||||||
|
'In order to view their progress through the freeCodeCamp ' +
|
||||||
|
'curriculum, they need to make all of thie solutions public'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</Alert>
|
||||||
|
{ takeMeToChallenges }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isCurrentUserProfile && (fetchOtherUserCompleted && !isUserFound)) {
|
||||||
|
return (
|
||||||
|
<div className='full-size'>
|
||||||
|
<Alert bsStyle='info'>
|
||||||
|
<p>
|
||||||
|
{ `We could not find a user by the name of "${paramsUsername}"` }
|
||||||
|
</p>
|
||||||
|
</Alert>
|
||||||
|
{ takeMeToChallenges }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CamperHOC />
|
||||||
|
<HeatMap />
|
||||||
|
<Certificates />
|
||||||
|
<Portfolio />
|
||||||
|
<Timeline className='timelime-container' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderReportUserButton() {
|
||||||
|
const {
|
||||||
|
isSignedIn,
|
||||||
|
fetchOtherUserCompleted,
|
||||||
|
isCurrentUserProfile,
|
||||||
|
isUserFound,
|
||||||
|
paramsUsername
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
!isSignedIn ||
|
||||||
|
isCurrentUserProfile ||
|
||||||
|
(fetchOtherUserCompleted && !isUserFound)
|
||||||
|
) ?
|
||||||
|
null :
|
||||||
|
(
|
||||||
|
<FullWidthRow>
|
||||||
|
<Button
|
||||||
|
block={ true }
|
||||||
|
bsSize='lg'
|
||||||
|
bsStyle='warning'
|
||||||
|
href={`/user/${paramsUsername}/report-user`}
|
||||||
|
>
|
||||||
|
Report Profile
|
||||||
|
</Button>
|
||||||
|
</FullWidthRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSettingsLink() {
|
||||||
|
const { isCurrentUserProfile } = this.props;
|
||||||
|
return isCurrentUserProfile ?
|
||||||
|
<FullWidthRow>
|
||||||
|
<Link to='/settings'>
|
||||||
|
<Button
|
||||||
|
block={ true }
|
||||||
|
bsSize='lg'
|
||||||
|
bsStyle='primary'
|
||||||
|
>
|
||||||
|
Update my settings
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</FullWidthRow> :
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
isCurrentUserProfile,
|
||||||
|
showLoading,
|
||||||
|
fetchOtherUserCompleted
|
||||||
|
} = this.props;
|
||||||
|
if (isCurrentUserProfile && showLoading) {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
if (!isCurrentUserProfile && !fetchOtherUserCompleted) {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ChildContainer>
|
||||||
|
<Grid className={`${ns}-container`}>
|
||||||
|
{ this.renderSettingsLink() }
|
||||||
|
{ this.renderRequestedProfile() }
|
||||||
|
{ this.renderReportUserButton() }
|
||||||
|
</Grid>
|
||||||
|
</ChildContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Profile.displayName = 'Profile';
|
||||||
|
Profile.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(Profile);
|
64
common/app/routes/Profile/components/CamperHOC.jsx
Normal file
64
common/app/routes/Profile/components/CamperHOC.jsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { userByNameSelector } from '../../../redux';
|
||||||
|
import Camper from '../../Settings/components/Camper.jsx';
|
||||||
|
|
||||||
|
const mapStateToProps = createSelector(
|
||||||
|
userByNameSelector,
|
||||||
|
({
|
||||||
|
name,
|
||||||
|
username,
|
||||||
|
location,
|
||||||
|
points,
|
||||||
|
picture,
|
||||||
|
about
|
||||||
|
}) => ({
|
||||||
|
name,
|
||||||
|
username,
|
||||||
|
location,
|
||||||
|
points,
|
||||||
|
picture,
|
||||||
|
about
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
about: PropTypes.string,
|
||||||
|
location: PropTypes.string,
|
||||||
|
name: PropTypes.string,
|
||||||
|
picture: PropTypes.string,
|
||||||
|
points: PropTypes.number,
|
||||||
|
username: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
function CamperHOC({
|
||||||
|
name,
|
||||||
|
username,
|
||||||
|
location,
|
||||||
|
points,
|
||||||
|
picture,
|
||||||
|
about
|
||||||
|
}) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Camper
|
||||||
|
about={ about }
|
||||||
|
location={ location }
|
||||||
|
name={ name }
|
||||||
|
picture={ picture }
|
||||||
|
points={ points }
|
||||||
|
username={ username }
|
||||||
|
/>
|
||||||
|
<hr />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CamperHOC.displayName = 'CamperHOC';
|
||||||
|
CamperHOC.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(CamperHOC);
|
184
common/app/routes/Profile/components/Certificates.jsx
Normal file
184
common/app/routes/Profile/components/Certificates.jsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Row,
|
||||||
|
Col
|
||||||
|
} from 'react-bootstrap';
|
||||||
|
|
||||||
|
import { userByNameSelector } from '../../../redux';
|
||||||
|
|
||||||
|
const mapStateToProps = createSelector(
|
||||||
|
userByNameSelector,
|
||||||
|
({
|
||||||
|
isRespWebDesignCert,
|
||||||
|
is2018DataVisCert,
|
||||||
|
isFrontEndLibsCert,
|
||||||
|
isJsAlgoDataStructCert,
|
||||||
|
isApisMicroservicesCert,
|
||||||
|
isInfosecQaCert,
|
||||||
|
isFrontEndCert,
|
||||||
|
isBackEndCert,
|
||||||
|
isDataVisCert,
|
||||||
|
isFullStackCert,
|
||||||
|
is2018FullStackCert,
|
||||||
|
username
|
||||||
|
}) => ({
|
||||||
|
username,
|
||||||
|
hasModernCert: (
|
||||||
|
isRespWebDesignCert ||
|
||||||
|
is2018DataVisCert ||
|
||||||
|
isFrontEndLibsCert ||
|
||||||
|
isJsAlgoDataStructCert ||
|
||||||
|
isApisMicroservicesCert ||
|
||||||
|
isInfosecQaCert
|
||||||
|
),
|
||||||
|
hasLegacyCert: (isFrontEndCert || isBackEndCert || isDataVisCert),
|
||||||
|
currentCerts: [
|
||||||
|
{
|
||||||
|
show: is2018FullStackCert,
|
||||||
|
title: 'Full Stack Certificate:',
|
||||||
|
showURL: '2018-full-stack'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
show: isRespWebDesignCert,
|
||||||
|
title: 'Responsive Web Design Certificate:',
|
||||||
|
showURL: 'responsive-web-design'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
show: isJsAlgoDataStructCert,
|
||||||
|
title: 'JavaScript Algorithms and Data Structures Certificate:',
|
||||||
|
showURL: 'javascript-algorithms-and-data-structures'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
show: isFrontEndLibsCert,
|
||||||
|
title: 'Front End Libraries Certificate:',
|
||||||
|
showURL: 'front-end-libraries'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
show: is2018DataVisCert,
|
||||||
|
title: 'Data Visualization Certificate:',
|
||||||
|
showURL: 'data-visualization-2018'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
show: isApisMicroservicesCert,
|
||||||
|
title: 'APIs and Microservices Certificate:',
|
||||||
|
showURL: 'apis-and-microservices'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
show: isInfosecQaCert,
|
||||||
|
title: 'Information Security and Quality Assurance Certificate:',
|
||||||
|
showURL: 'information-security-and-quality-assurance'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
legacyCerts: [
|
||||||
|
{
|
||||||
|
show: isFullStackCert,
|
||||||
|
title: 'Full Stack Certificate:',
|
||||||
|
showURL: 'full-stack'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
show: isFrontEndCert,
|
||||||
|
title: 'Front End Certificate:',
|
||||||
|
showURL: 'front-end'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
show: isBackEndCert,
|
||||||
|
title: 'Back End Certificate:',
|
||||||
|
showURL: 'back-end'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
show: isDataVisCert,
|
||||||
|
title: 'Data Visualization Certificate:',
|
||||||
|
showURL: 'data-visualization'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function mapDispatchToProps() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const certArrayTypes = PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
show: PropTypes.bool,
|
||||||
|
title: PropTypes.string,
|
||||||
|
showURL: PropTypes.string
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
currentCerts: certArrayTypes,
|
||||||
|
hasLegacyCert: PropTypes.bool,
|
||||||
|
hasModernCert: PropTypes.bool,
|
||||||
|
legacyCerts: certArrayTypes,
|
||||||
|
username: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderCertShow(username, cert) {
|
||||||
|
return cert.show ? (
|
||||||
|
<Row key={ cert.showURL }>
|
||||||
|
<Col sm={ 8 }>
|
||||||
|
<p>
|
||||||
|
<strong>
|
||||||
|
{ cert.title }
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
</Col>
|
||||||
|
<Col sm={ 2 } smPush={ 2 }>
|
||||||
|
<Button
|
||||||
|
block={ true }
|
||||||
|
bsSize='lg'
|
||||||
|
bsStyle='primary'
|
||||||
|
href={ `/c/${username}/${cert.showURL}`}
|
||||||
|
>
|
||||||
|
Show
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
) :
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Certificates({
|
||||||
|
currentCerts,
|
||||||
|
legacyCerts,
|
||||||
|
hasLegacyCert,
|
||||||
|
hasModernCert,
|
||||||
|
username
|
||||||
|
}) {
|
||||||
|
const renderCertShowWithUsername = _.curry(renderCertShow)(username);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className='text-center'>freeCodeCamp Certificates</h2>
|
||||||
|
<br />
|
||||||
|
{
|
||||||
|
hasModernCert ?
|
||||||
|
currentCerts.map(renderCertShowWithUsername) :
|
||||||
|
<p className='text-center' >
|
||||||
|
No certificates have been earned under the current curriculum
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
hasLegacyCert ?
|
||||||
|
<div>
|
||||||
|
<h3>Legacy Certifications</h3>
|
||||||
|
{
|
||||||
|
legacyCerts.map(renderCertShowWithUsername)
|
||||||
|
}
|
||||||
|
</div> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
<hr />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Certificates.propTypes = propTypes;
|
||||||
|
Certificates.displayName = 'Certificates';
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(Certificates);
|
121
common/app/routes/Profile/components/HeatMap.jsx
Normal file
121
common/app/routes/Profile/components/HeatMap.jsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import d3 from 'react-d3';
|
||||||
|
import CalHeatMap from 'cal-heatmap';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
import differenceInCalendarMonths from 'date-fns/difference_in_calendar_months';
|
||||||
|
|
||||||
|
import { FullWidthRow } from '../../../helperComponents';
|
||||||
|
import { userByNameSelector } from '../../../redux';
|
||||||
|
|
||||||
|
function ensureD3() {
|
||||||
|
// CalHeatMap requires d3 to be available on window
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
if ('d3' in window) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
window.d3 = d3;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapStateToProps = createSelector(
|
||||||
|
userByNameSelector,
|
||||||
|
({ calendar, streak }) => ({ calendar, streak })
|
||||||
|
);
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
calendar: PropTypes.object,
|
||||||
|
streak: PropTypes.shape({
|
||||||
|
current: PropTypes.number,
|
||||||
|
longest: PropTypes.number
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
class HeatMap extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.renderMap = this.renderMap.bind(this);
|
||||||
|
}
|
||||||
|
componentDidMount() {
|
||||||
|
ensureD3();
|
||||||
|
this.renderMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMap() {
|
||||||
|
const { calendar = {} } = this.props;
|
||||||
|
if (Object.keys(calendar).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const today = new Date();
|
||||||
|
const cal = new CalHeatMap();
|
||||||
|
const rectSelector = '#cal-heatmap > svg > svg.graph-legend > g > rect.r';
|
||||||
|
const calLegendTitles = ['less', '', '', 'more'];
|
||||||
|
const firstTS = Object.keys(calendar)[0];
|
||||||
|
let start = new Date(firstTS * 1000);
|
||||||
|
const monthsSinceFirstActive = differenceInCalendarMonths(
|
||||||
|
today,
|
||||||
|
start
|
||||||
|
);
|
||||||
|
cal.init({
|
||||||
|
itemSelector: '#cal-heatmap',
|
||||||
|
domain: 'month',
|
||||||
|
subDomain: 'day',
|
||||||
|
domainDynamicDimension: true,
|
||||||
|
domainGutter: 5,
|
||||||
|
data: calendar,
|
||||||
|
cellSize: 15,
|
||||||
|
cellRadius: 3,
|
||||||
|
cellPadding: 2,
|
||||||
|
tooltip: true,
|
||||||
|
range: monthsSinceFirstActive < 12 ? monthsSinceFirstActive + 1 : 12,
|
||||||
|
start,
|
||||||
|
legendColors: ['#cccccc', '#006400'],
|
||||||
|
legend: [1, 2, 3],
|
||||||
|
label: {
|
||||||
|
position: 'top'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
calLegendTitles.forEach(function(title, i) {
|
||||||
|
document
|
||||||
|
.querySelector(rectSelector + (i + 1).toString() + '> title')
|
||||||
|
.innerHTML = title;
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { streak = {} } = this.props;
|
||||||
|
return (
|
||||||
|
<div id='cal-heatmap-container'>
|
||||||
|
<Helmet>
|
||||||
|
<link href='/css/cal-heatmap.css' rel='stylesheet' />
|
||||||
|
</Helmet>
|
||||||
|
<FullWidthRow>
|
||||||
|
<div id='cal-heatmap' />
|
||||||
|
</FullWidthRow>
|
||||||
|
<FullWidthRow>
|
||||||
|
<div className='streak-container'>
|
||||||
|
<span className='streak'>
|
||||||
|
<strong>Longest Streak:</strong> { streak.longest || 1 }
|
||||||
|
</span>
|
||||||
|
<span className='streak'>
|
||||||
|
<strong>Current Streak:</strong> { streak.current || 1 }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</FullWidthRow>
|
||||||
|
<hr />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HeatMap.displayName = 'HeatMap';
|
||||||
|
HeatMap.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(HeatMap);
|
74
common/app/routes/Profile/components/Portfolio.jsx
Normal file
74
common/app/routes/Profile/components/Portfolio.jsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { Thumbnail, Media } from 'react-bootstrap';
|
||||||
|
|
||||||
|
import { FullWidthRow } from '../../../helperComponents';
|
||||||
|
import { userByNameSelector } from '../../../redux';
|
||||||
|
|
||||||
|
const mapStateToProps = createSelector(
|
||||||
|
userByNameSelector,
|
||||||
|
({ portfolio }) => ({ portfolio })
|
||||||
|
);
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
portfolio: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
description: PropTypes.string,
|
||||||
|
id: PropTypes.string,
|
||||||
|
image: PropTypes.string,
|
||||||
|
title: PropTypes.string,
|
||||||
|
url: PropTypes.string
|
||||||
|
})
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
function Portfolio({ portfolio = [] }) {
|
||||||
|
if (!portfolio.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FullWidthRow>
|
||||||
|
<h2 className='text-center'>Portfolio</h2>
|
||||||
|
{
|
||||||
|
portfolio.map(({ title, url, image, description, id}) => (
|
||||||
|
<Media key={ id }>
|
||||||
|
<Media.Left align='middle'>
|
||||||
|
{
|
||||||
|
image && (
|
||||||
|
<a href={ url } rel='nofollow'>
|
||||||
|
<Thumbnail
|
||||||
|
alt={ `A screen shot of ${title}` }
|
||||||
|
src={ image }
|
||||||
|
style={{ width: '150px' }}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
</Media.Left>
|
||||||
|
<Media.Body>
|
||||||
|
<Media.Heading>
|
||||||
|
<a href={ url } rel='nofollow'>
|
||||||
|
{ title }
|
||||||
|
</a>
|
||||||
|
</Media.Heading>
|
||||||
|
<p>
|
||||||
|
{ description }
|
||||||
|
</p>
|
||||||
|
</Media.Body>
|
||||||
|
</Media>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</FullWidthRow>
|
||||||
|
<hr />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Portfolio.displayName = 'Portfolio';
|
||||||
|
Portfolio.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(Portfolio);
|
@ -8,7 +8,7 @@ import {
|
|||||||
} from 'react-bootstrap';
|
} from 'react-bootstrap';
|
||||||
import FontAwesome from 'react-fontawesome';
|
import FontAwesome from 'react-fontawesome';
|
||||||
|
|
||||||
import { userSelector } from '../../../redux';
|
import { userByNameSelector } from '../../../redux';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
email: PropTypes.string,
|
email: PropTypes.string,
|
||||||
@ -18,12 +18,13 @@ const propTypes = {
|
|||||||
isTwitter: PropTypes.bool,
|
isTwitter: PropTypes.bool,
|
||||||
isWebsite: PropTypes.bool,
|
isWebsite: PropTypes.bool,
|
||||||
linkedIn: PropTypes.string,
|
linkedIn: PropTypes.string,
|
||||||
|
show: PropTypes.bool,
|
||||||
twitter: PropTypes.string,
|
twitter: PropTypes.string,
|
||||||
website: PropTypes.string
|
website: PropTypes.string
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = createSelector(
|
const mapStateToProps = createSelector(
|
||||||
userSelector,
|
userByNameSelector,
|
||||||
({
|
({
|
||||||
githubURL,
|
githubURL,
|
||||||
isLinkedIn,
|
isLinkedIn,
|
||||||
@ -40,6 +41,7 @@ const mapStateToProps = createSelector(
|
|||||||
isTwitter,
|
isTwitter,
|
||||||
isWebsite,
|
isWebsite,
|
||||||
linkedIn,
|
linkedIn,
|
||||||
|
show: (isLinkedIn || isGithub || isTwitter || isWebsite),
|
||||||
twitter,
|
twitter,
|
||||||
website
|
website
|
||||||
})
|
})
|
||||||
@ -101,9 +103,13 @@ function SocialIcons(props) {
|
|||||||
isTwitter,
|
isTwitter,
|
||||||
isWebsite,
|
isWebsite,
|
||||||
linkedIn,
|
linkedIn,
|
||||||
|
show,
|
||||||
twitter,
|
twitter,
|
||||||
website
|
website
|
||||||
} = props;
|
} = props;
|
||||||
|
if (!show) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Row>
|
<Row>
|
||||||
<Col
|
<Col
|
194
common/app/routes/Profile/components/Timeline.jsx
Normal file
194
common/app/routes/Profile/components/Timeline.jsx
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import format from 'date-fns/format';
|
||||||
|
import { reverse, sortBy } from 'lodash';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
Table
|
||||||
|
} from 'react-bootstrap';
|
||||||
|
|
||||||
|
import { challengeIdToNameMapSelector } from '../../../entities';
|
||||||
|
import { userByNameSelector, fetchChallenges } from '../../../redux';
|
||||||
|
import { homeURL } from '../../../../utils/constantStrings.json';
|
||||||
|
import blockNameify from '../../../utils/blockNameify';
|
||||||
|
import { FullWidthRow } from '../../../helperComponents';
|
||||||
|
import { Link } from '../../../Router';
|
||||||
|
import SolutionViewer from '../../Settings/components/SolutionViewer.jsx';
|
||||||
|
|
||||||
|
const mapStateToProps = createSelector(
|
||||||
|
challengeIdToNameMapSelector,
|
||||||
|
userByNameSelector,
|
||||||
|
(
|
||||||
|
idToNameMap,
|
||||||
|
{ challengeMap: completedMap = {}, username }
|
||||||
|
) => ({
|
||||||
|
completedMap,
|
||||||
|
idToNameMap,
|
||||||
|
username
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapDispatchToProps = { fetchChallenges };
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
completedMap: PropTypes.shape({
|
||||||
|
id: PropTypes.string,
|
||||||
|
completedDate: PropTypes.number,
|
||||||
|
lastUpdated: PropTypes.number
|
||||||
|
}),
|
||||||
|
fetchChallenges: PropTypes.func.isRequired,
|
||||||
|
idToNameMap: PropTypes.objectOf(PropTypes.string),
|
||||||
|
username: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
class Timeline extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
solutionToView: null,
|
||||||
|
solutionOpen: false
|
||||||
|
};
|
||||||
|
|
||||||
|
this.closeSolution = this.closeSolution.bind(this);
|
||||||
|
this.renderCompletion = this.renderCompletion.bind(this);
|
||||||
|
this.viewSolution = this.viewSolution.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (!Object.keys(this.props.idToNameMap).length) {
|
||||||
|
this.props.fetchChallenges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCompletion(completed) {
|
||||||
|
const { idToNameMap } = this.props;
|
||||||
|
const { id, completedDate, lastUpdated, files } = completed;
|
||||||
|
return (
|
||||||
|
<tr key={ id }>
|
||||||
|
<td>{ blockNameify(idToNameMap[id]) }</td>
|
||||||
|
<td>
|
||||||
|
<time dateTime={ format(completedDate, 'YYYY-MM-DDTHH:MM:SSZ') }>
|
||||||
|
{
|
||||||
|
format(completedDate, 'MMMM DD YYYY')
|
||||||
|
}
|
||||||
|
</time>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{
|
||||||
|
lastUpdated ?
|
||||||
|
<time dateTime={ format(lastUpdated, 'YYYY-MM-DDTHH:MM:SSZ') }>
|
||||||
|
{
|
||||||
|
format(lastUpdated, 'MMMM DD YYYY')
|
||||||
|
}
|
||||||
|
</time> :
|
||||||
|
''
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{
|
||||||
|
files ?
|
||||||
|
<Button
|
||||||
|
block={ true }
|
||||||
|
bsStyle='primary'
|
||||||
|
onClick={ () => this.viewSolution(id) }
|
||||||
|
>
|
||||||
|
View Solution
|
||||||
|
</Button> :
|
||||||
|
''
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
viewSolution(id) {
|
||||||
|
this.setState(state => ({
|
||||||
|
...state,
|
||||||
|
solutionToView: id,
|
||||||
|
solutionOpen: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSolution() {
|
||||||
|
this.setState(state => ({
|
||||||
|
...state,
|
||||||
|
solutionToView: null,
|
||||||
|
solutionOpen: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { completedMap, idToNameMap, username } = this.props;
|
||||||
|
const { solutionToView: id, solutionOpen } = this.state;
|
||||||
|
if (!Object.keys(idToNameMap).length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<FullWidthRow>
|
||||||
|
<h2 className='text-center'>Timeline</h2>
|
||||||
|
{
|
||||||
|
Object.keys(completedMap).length === 0 ?
|
||||||
|
<p className='text-center'>
|
||||||
|
No challenges have been completed yet.
|
||||||
|
<Link to={ homeURL }>
|
||||||
|
Get started here.
|
||||||
|
</Link>
|
||||||
|
</p> :
|
||||||
|
<Table condensed={true} striped={true}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Challenge</th>
|
||||||
|
<th>First Completed</th>
|
||||||
|
<th>Last Changed</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{
|
||||||
|
reverse(
|
||||||
|
sortBy(
|
||||||
|
Object.keys(completedMap)
|
||||||
|
.filter(key => key in idToNameMap)
|
||||||
|
.map(key => completedMap[key]),
|
||||||
|
[ 'completedDate' ]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map(this.renderCompletion)
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
id &&
|
||||||
|
<Modal
|
||||||
|
aria-labelledby='contained-modal-title'
|
||||||
|
onHide={this.closeSolution}
|
||||||
|
show={ solutionOpen }
|
||||||
|
>
|
||||||
|
<Modal.Header closeButton={ true }>
|
||||||
|
<Modal.Title id='contained-modal-title'>
|
||||||
|
{ `${username}'s Solution to ${blockNameify(idToNameMap[id])}` }
|
||||||
|
</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<SolutionViewer files={ completedMap[id].files }/>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button onClick={this.closeSolution}>Close</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
}
|
||||||
|
</FullWidthRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Timeline.displayName = 'Timeline';
|
||||||
|
Timeline.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(Timeline);
|
7
common/app/routes/Profile/index.js
Normal file
7
common/app/routes/Profile/index.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { types } from './redux';
|
||||||
|
|
||||||
|
export { default } from './Profile.jsx';
|
||||||
|
|
||||||
|
export const routes = {
|
||||||
|
[types.onRouteProfile]: '/:username'
|
||||||
|
};
|
1
common/app/routes/Profile/ns.json
Normal file
1
common/app/routes/Profile/ns.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
"profile"
|
84
common/app/routes/Profile/profile.less
Normal file
84
common/app/routes/Profile/profile.less
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
// should be the same as the filename and ./ns.json
|
||||||
|
@ns: profile;
|
||||||
|
|
||||||
|
.avatar-container {
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
.avatar {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.solution-viewer {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.@{ns}-container {
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
text-decoration-line: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-size {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-media-icons {
|
||||||
|
a {
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
margin-left: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2, h3 {
|
||||||
|
&.name, &.username, &.points, &.location {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-description {
|
||||||
|
height: 62px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cal-heatmap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streak-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streak {
|
||||||
|
color: @brand-primary;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
31
common/app/routes/Profile/redux/index.js
Normal file
31
common/app/routes/Profile/redux/index.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
createAction,
|
||||||
|
createTypes
|
||||||
|
} from 'berkeleys-redux-utils';
|
||||||
|
|
||||||
|
import ns from '../ns.json';
|
||||||
|
import handleActions from 'berkeleys-redux-utils/lib/handle-actions';
|
||||||
|
|
||||||
|
export const types = createTypes([
|
||||||
|
'onRouteProfile',
|
||||||
|
'userFound'
|
||||||
|
], 'profile');
|
||||||
|
|
||||||
|
export const onRouteProfile = createAction(types.onRouteProfile);
|
||||||
|
export const userFound = createAction(types.userFound);
|
||||||
|
const initialState = {
|
||||||
|
isUserFound: null
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userFoundSelector = state => state[ns].isUserFound;
|
||||||
|
|
||||||
|
export default handleActions(() => (
|
||||||
|
{
|
||||||
|
[types.userFound]: (state, { payload }) => ({
|
||||||
|
...state,
|
||||||
|
isUserFound: payload
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
initialState,
|
||||||
|
ns
|
||||||
|
);
|
@ -8,6 +8,7 @@ import FA from 'react-fontawesome';
|
|||||||
|
|
||||||
import ns from './ns.json';
|
import ns from './ns.json';
|
||||||
import { FullWidthRow, Spacer, Loader } from '../../helperComponents';
|
import { FullWidthRow, Spacer, Loader } from '../../helperComponents';
|
||||||
|
import { Link } from '../../Router';
|
||||||
import AboutSettings from './components/About-Settings.jsx';
|
import AboutSettings from './components/About-Settings.jsx';
|
||||||
import InternetSettings from './components/Internet-Settings.jsx';
|
import InternetSettings from './components/Internet-Settings.jsx';
|
||||||
import EmailSettings from './components/Email-Settings.jsx';
|
import EmailSettings from './components/Email-Settings.jsx';
|
||||||
@ -70,6 +71,7 @@ export class Settings extends React.Component {
|
|||||||
componentWillMount() {
|
componentWillMount() {
|
||||||
this.props.updateTitle('Settings');
|
this.props.updateTitle('Settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillReceiveProps({ username, showLoading, hardGoTo }) {
|
componentWillReceiveProps({ username, showLoading, hardGoTo }) {
|
||||||
if (!username && !showLoading) {
|
if (!username && !showLoading) {
|
||||||
hardGoTo('/signup');
|
hardGoTo('/signup');
|
||||||
@ -87,16 +89,17 @@ export class Settings extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div className={ `${ns}-container` }>
|
<div className={ `${ns}-container` }>
|
||||||
<FullWidthRow>
|
<FullWidthRow>
|
||||||
<Button
|
<Link to={ `/${username}` }>
|
||||||
block={ true }
|
<Button
|
||||||
bsSize='lg'
|
block={ true }
|
||||||
bsStyle='primary'
|
bsSize='lg'
|
||||||
className='btn-link-social'
|
bsStyle='primary'
|
||||||
href={ `/${username}` }
|
className='btn-link-social'
|
||||||
>
|
>
|
||||||
<FA name='user' />
|
<FA name='user' />
|
||||||
Show me my public profile
|
Show me my public profile
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
block={ true }
|
block={ true }
|
||||||
bsSize='lg'
|
bsSize='lg'
|
||||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Col, Row } from 'react-bootstrap';
|
import { Col, Row } from 'react-bootstrap';
|
||||||
|
|
||||||
import SocialIcons from './SocialIcons.jsx';
|
import SocialIcons from '../../Profile/components/SocialIcons.jsx';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
about: PropTypes.string,
|
about: PropTypes.string,
|
||||||
@ -37,9 +37,7 @@ function Camper({
|
|||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<br />
|
|
||||||
<SocialIcons />
|
<SocialIcons />
|
||||||
<br/>
|
|
||||||
<h2 className='text-center username'>@{ username }</h2>
|
<h2 className='text-center username'>@{ username }</h2>
|
||||||
{ name && <p className='text-center name'>{ name }</p> }
|
{ name && <p className='text-center name'>{ name }</p> }
|
||||||
{ location && <p className='text-center location'>{ location }</p> }
|
{ location && <p className='text-center location'>{ location }</p> }
|
||||||
|
@ -24,6 +24,11 @@
|
|||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
text-decoration-line: none;
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { routes as challengesRoutes } from './Challenges';
|
import { routes as challengesRoutes } from './Challenges';
|
||||||
import { routes as mapRoutes } from './Map';
|
import { routes as mapRoutes } from './Map';
|
||||||
import { routes as settingsRoutes } from './Settings';
|
import { routes as settingsRoutes } from './Settings';
|
||||||
|
import { routes as profileRoutes } from './Profile';
|
||||||
|
|
||||||
// import { addLang } from '../utils/lang';
|
// import { addLang } from '../utils/lang';
|
||||||
|
|
||||||
@ -9,7 +10,9 @@ export { createPanesMap } from './Challenges';
|
|||||||
export default {
|
export default {
|
||||||
...challengesRoutes,
|
...challengesRoutes,
|
||||||
...mapRoutes,
|
...mapRoutes,
|
||||||
...settingsRoutes
|
...settingsRoutes,
|
||||||
|
// ensure profile routes are last else they hijack other routes
|
||||||
|
...profileRoutes
|
||||||
};
|
};
|
||||||
|
|
||||||
// export default function createChildRoute(deps) {
|
// export default function createChildRoute(deps) {
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
&{ @import "./Challenges/challenges.less"; }
|
&{ @import "./Challenges/challenges.less"; }
|
||||||
|
&{ @import "./Profile/profile.less"; }
|
||||||
&{ @import "./Settings/index.less"; }
|
&{ @import "./Settings/index.less"; }
|
||||||
|
@ -3,8 +3,10 @@ import { combineReducers } from 'berkeleys-redux-utils';
|
|||||||
|
|
||||||
|
|
||||||
import challengeReducer from './Challenges/redux';
|
import challengeReducer from './Challenges/redux';
|
||||||
|
import profileReducer from './Profile/redux';
|
||||||
import settingsReducer from './Settings/redux';
|
import settingsReducer from './Settings/redux';
|
||||||
import { routes as challengeRoutes } from './Challenges';
|
import { routes as challengeRoutes } from './Challenges';
|
||||||
|
import { routes as profileRoutes } from './Profile';
|
||||||
import { routes as settingsRoutes } from './Settings';
|
import { routes as settingsRoutes } from './Settings';
|
||||||
|
|
||||||
const ns = 'mainRouter';
|
const ns = 'mainRouter';
|
||||||
@ -22,6 +24,9 @@ export function mainRouter(state = 'NotFound', action) {
|
|||||||
if (settingsRoutes[type]) {
|
if (settingsRoutes[type]) {
|
||||||
return 'settings';
|
return 'settings';
|
||||||
}
|
}
|
||||||
|
if (profileRoutes[type]) {
|
||||||
|
return 'profile';
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,6 +34,7 @@ mainRouter.toString = () => ns;
|
|||||||
|
|
||||||
export default combineReducers(
|
export default combineReducers(
|
||||||
challengeReducer,
|
challengeReducer,
|
||||||
|
profileReducer,
|
||||||
settingsReducer,
|
settingsReducer,
|
||||||
mainRouter
|
mainRouter
|
||||||
);
|
);
|
||||||
|
@ -17,6 +17,11 @@ import {
|
|||||||
getServerFullURL,
|
getServerFullURL,
|
||||||
getEmailSender
|
getEmailSender
|
||||||
} from '../../server/utils/url-utils.js';
|
} from '../../server/utils/url-utils.js';
|
||||||
|
import {
|
||||||
|
normaliseUserFields,
|
||||||
|
getProgress,
|
||||||
|
publicUserProps
|
||||||
|
} from '../../server/utils/publicUserProps';
|
||||||
|
|
||||||
const debug = debugFactory('fcc:models:user');
|
const debug = debugFactory('fcc:models:user');
|
||||||
const BROWNIEPOINTS_TIMEOUT = [1, 'hour'];
|
const BROWNIEPOINTS_TIMEOUT = [1, 'hour'];
|
||||||
@ -760,6 +765,57 @@ module.exports = function(User) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
User.getPublicProfile = function getPublicProfile(username, cb) {
|
||||||
|
return User.findOne$({ where: { username }})
|
||||||
|
.flatMap(user => {
|
||||||
|
if (!user) {
|
||||||
|
return Observable.of({});
|
||||||
|
}
|
||||||
|
const { challengeMap, progressTimestamps, timezone } = user;
|
||||||
|
return Observable.of({
|
||||||
|
entities: {
|
||||||
|
user: {
|
||||||
|
[user.username]: {
|
||||||
|
..._.pick(user, publicUserProps),
|
||||||
|
isGithub: !!user.githubURL,
|
||||||
|
isLinkedIn: !!user.linkedIn,
|
||||||
|
isTwitter: !!user.twitter,
|
||||||
|
isWebsite: !!user.website,
|
||||||
|
points: progressTimestamps.length,
|
||||||
|
challengeMap,
|
||||||
|
...getProgress(progressTimestamps, timezone),
|
||||||
|
...normaliseUserFields(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
result: user.username
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.subscribe(
|
||||||
|
user => cb(null, user),
|
||||||
|
cb
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
User.remoteMethod('getPublicProfile', {
|
||||||
|
accepts: {
|
||||||
|
arg: 'username',
|
||||||
|
type: 'string',
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
returns: [
|
||||||
|
{
|
||||||
|
arg: 'user',
|
||||||
|
type: 'object',
|
||||||
|
root: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
http: {
|
||||||
|
path: '/get-public-profile',
|
||||||
|
verb: 'GET'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
User.giveBrowniePoints =
|
User.giveBrowniePoints =
|
||||||
function giveBrowniePoints(receiver, giver, data = {}, dev = false, cb) {
|
function giveBrowniePoints(receiver, giver, data = {}, dev = false, cb) {
|
||||||
const findUser = observeMethod(User, 'findOne');
|
const findUser = observeMethod(User, 'findOne');
|
||||||
|
@ -348,6 +348,13 @@
|
|||||||
"permission": "ALLOW",
|
"permission": "ALLOW",
|
||||||
"property": "about"
|
"property": "about"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"accessType": "EXECUTE",
|
||||||
|
"principalType": "ROLE",
|
||||||
|
"principalId": "$everyone",
|
||||||
|
"permission": "ALLOW",
|
||||||
|
"property": "getPublicProfile"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"accessType": "EXECUTE",
|
"accessType": "EXECUTE",
|
||||||
"principalType": "ROLE",
|
"principalType": "ROLE",
|
||||||
|
9
package-lock.json
generated
9
package-lock.json
generated
@ -13990,6 +13990,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"react-d3": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-d3/-/react-d3-0.4.0.tgz",
|
||||||
|
"integrity": "sha1-3s7c7ZZ/SM2JzNeftAjUfw8qy+I=",
|
||||||
|
"requires": {
|
||||||
|
"d3": "3.5.17",
|
||||||
|
"react": "15.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"react-dom": {
|
"react-dom": {
|
||||||
"version": "15.6.2",
|
"version": "15.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-15.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-15.6.2.tgz",
|
||||||
|
@ -113,6 +113,7 @@
|
|||||||
"react-addons-shallow-compare": "~15.4.2",
|
"react-addons-shallow-compare": "~15.4.2",
|
||||||
"react-bootstrap": "~0.31.2",
|
"react-bootstrap": "~0.31.2",
|
||||||
"react-codemirror": "^0.3.0",
|
"react-codemirror": "^0.3.0",
|
||||||
|
"react-d3": "^0.4.0",
|
||||||
"react-dom": "^15.6.2",
|
"react-dom": "^15.6.2",
|
||||||
"react-fontawesome": "^1.2.0",
|
"react-fontawesome": "^1.2.0",
|
||||||
"react-freecodecamp-search": "^1.4.1",
|
"react-freecodecamp-search": "^1.4.1",
|
||||||
|
145
public/css/cal-heatmap.css
Normal file
145
public/css/cal-heatmap.css
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
/* Cal-HeatMap CSS */
|
||||||
|
|
||||||
|
.cal-heatmap-container {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container .graph
|
||||||
|
{
|
||||||
|
font-family: "Lucida Grande", Lucida, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container .graph-label
|
||||||
|
{
|
||||||
|
fill: #999;
|
||||||
|
font-size: 10px
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container .graph, .cal-heatmap-container .graph-legend rect {
|
||||||
|
shape-rendering: crispedges
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container .graph-rect
|
||||||
|
{
|
||||||
|
fill: #ededed
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container .graph-subdomain-group rect:hover
|
||||||
|
{
|
||||||
|
stroke: #000;
|
||||||
|
stroke-width: 1px
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container .subdomain-text {
|
||||||
|
font-size: 8px;
|
||||||
|
fill: #999;
|
||||||
|
pointer-events: none
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container .hover_cursor:hover {
|
||||||
|
cursor: pointer
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container .qi {
|
||||||
|
background-color: #999;
|
||||||
|
fill: #999
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Remove comment to apply this style to date with value equal to 0
|
||||||
|
.q0
|
||||||
|
{
|
||||||
|
background-color: #fff;
|
||||||
|
fill: #fff;
|
||||||
|
stroke: #ededed
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
.cal-heatmap-container .q1
|
||||||
|
{
|
||||||
|
background-color: #dae289;
|
||||||
|
fill: #dae289
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container .q2
|
||||||
|
{
|
||||||
|
background-color: #cedb9c;
|
||||||
|
fill: #9cc069
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container .q3
|
||||||
|
{
|
||||||
|
background-color: #b5cf6b;
|
||||||
|
fill: #669d45
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container .q4
|
||||||
|
{
|
||||||
|
background-color: #637939;
|
||||||
|
fill: #637939
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container .q5
|
||||||
|
{
|
||||||
|
background-color: #3b6427;
|
||||||
|
fill: #3b6427
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container rect.highlight
|
||||||
|
{
|
||||||
|
stroke:#444;
|
||||||
|
stroke-width:1
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container text.highlight
|
||||||
|
{
|
||||||
|
fill: #444
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container rect.now
|
||||||
|
{
|
||||||
|
stroke: red
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container text.now
|
||||||
|
{
|
||||||
|
fill: red;
|
||||||
|
font-weight: 800
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-heatmap-container .domain-background {
|
||||||
|
fill: none;
|
||||||
|
shape-rendering: crispedges
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-tooltip {
|
||||||
|
padding: 10px;
|
||||||
|
background: #222;
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
width: 140px;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 99999;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 2px 2px 2px rgba(0,0,0,0.2);
|
||||||
|
display: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-tooltip::after{
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-color: transparent;
|
||||||
|
border-style: solid;
|
||||||
|
content: "";
|
||||||
|
padding: 0;
|
||||||
|
display: block;
|
||||||
|
bottom: -6px;
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -6px;
|
||||||
|
border-width: 6px 6px 0;
|
||||||
|
border-top-color: #222;
|
||||||
|
}
|
3
server/boot/react.js
vendored
3
server/boot/react.js
vendored
@ -21,7 +21,8 @@ const routes = [
|
|||||||
'/challenges/*',
|
'/challenges/*',
|
||||||
'/map',
|
'/map',
|
||||||
'/settings',
|
'/settings',
|
||||||
'/settings/*'
|
'/settings/*',
|
||||||
|
'/:username'
|
||||||
];
|
];
|
||||||
|
|
||||||
const devRoutes = [];
|
const devRoutes = [];
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import dedent from 'dedent';
|
import dedent from 'dedent';
|
||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
import { Observable } from 'rx';
|
|
||||||
import debugFactory from 'debug';
|
import debugFactory from 'debug';
|
||||||
// import { curry } from 'lodash';
|
import { curry } from 'lodash';
|
||||||
import emoji from 'node-emoji';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
frontEndChallengeId,
|
frontEndChallengeId,
|
||||||
@ -24,18 +22,10 @@ import {
|
|||||||
ifNotVerifiedRedirectToSettings
|
ifNotVerifiedRedirectToSettings
|
||||||
} from '../utils/middleware';
|
} from '../utils/middleware';
|
||||||
import { observeQuery } from '../utils/rx';
|
import { observeQuery } from '../utils/rx';
|
||||||
import {
|
|
||||||
prepUniqueDaysByHours,
|
|
||||||
calcCurrentStreak,
|
|
||||||
calcLongestStreak
|
|
||||||
} from '../utils/user-stats';
|
|
||||||
import supportedLanguages from '../../common/utils/supported-languages';
|
|
||||||
import { encodeFcc } from '../../common/utils/encode-decode.js';
|
|
||||||
import { getChallengeInfo, cachedMap } from '../utils/map';
|
|
||||||
|
|
||||||
const debug = debugFactory('fcc:boot:user');
|
const debug = debugFactory('fcc:boot:user');
|
||||||
const sendNonUserToMap = ifNoUserRedirectTo('/map');
|
const sendNonUserToMap = ifNoUserRedirectTo('/map');
|
||||||
// const sendNonUserToMapWithMessage = curry(ifNoUserRedirectTo, 2)('/map');
|
const sendNonUserToMapWithMessage = curry(ifNoUserRedirectTo, 2)('/map');
|
||||||
const certIds = {
|
const certIds = {
|
||||||
[certTypes.frontEnd]: frontEndChallengeId,
|
[certTypes.frontEnd]: frontEndChallengeId,
|
||||||
[certTypes.backEnd]: backEndChallengeId,
|
[certTypes.backEnd]: backEndChallengeId,
|
||||||
@ -77,84 +67,10 @@ const certText = {
|
|||||||
[certTypes.infosecQa]: 'Information Security and Quality Assurance Certified'
|
[certTypes.infosecQa]: 'Information Security and Quality Assurance Certified'
|
||||||
};
|
};
|
||||||
|
|
||||||
const dateFormat = 'MMM DD, YYYY';
|
|
||||||
|
|
||||||
function isAlgorithm(challenge) {
|
|
||||||
// test if name starts with hike/waypoint/basejump/zipline
|
|
||||||
// fix for bug that saved different challenges with incorrect
|
|
||||||
// challenge types
|
|
||||||
return !(/^(waypoint|hike|zipline|basejump)/i).test(challenge.name) &&
|
|
||||||
+challenge.challengeType === 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isProject(challenge) {
|
|
||||||
return +challenge.challengeType === 3 ||
|
|
||||||
+challenge.challengeType === 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChallengeGroup(challenge) {
|
|
||||||
if (isProject(challenge)) {
|
|
||||||
return 'projects';
|
|
||||||
} else if (isAlgorithm(challenge)) {
|
|
||||||
return 'algorithms';
|
|
||||||
}
|
|
||||||
return 'challenges';
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildDisplayChallenges(
|
|
||||||
// entities: { challenge: Object, challengeIdToName: Object },
|
|
||||||
// challengeMap: Object,
|
|
||||||
// tz: String
|
|
||||||
// ) => Observable[{
|
|
||||||
// algorithms: Array,
|
|
||||||
// projects: Array,
|
|
||||||
// challenges: Array
|
|
||||||
// }]
|
|
||||||
function buildDisplayChallenges(
|
|
||||||
{ challengeMap, challengeIdToName },
|
|
||||||
userChallengeMap = {},
|
|
||||||
timezone
|
|
||||||
) {
|
|
||||||
return Observable.from(Object.keys(userChallengeMap))
|
|
||||||
.map(challengeId => userChallengeMap[challengeId])
|
|
||||||
.map(userChallenge => {
|
|
||||||
const challengeId = userChallenge.id;
|
|
||||||
const challenge = challengeMap[ challengeIdToName[challengeId] ];
|
|
||||||
let finalChallenge = { ...userChallenge, ...challenge };
|
|
||||||
if (userChallenge.completedDate) {
|
|
||||||
finalChallenge.completedDate = moment
|
|
||||||
.tz(userChallenge.completedDate, timezone)
|
|
||||||
.format(dateFormat);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userChallenge.lastUpdated) {
|
|
||||||
finalChallenge.lastUpdated = moment
|
|
||||||
.tz(userChallenge.lastUpdated, timezone)
|
|
||||||
.format(dateFormat);
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalChallenge;
|
|
||||||
})
|
|
||||||
.filter(({ challengeType }) => challengeType !== 6)
|
|
||||||
.groupBy(getChallengeGroup)
|
|
||||||
.flatMap(group$ => {
|
|
||||||
return group$.toArray().map(challenges => ({
|
|
||||||
[getChallengeGroup(challenges[0])]: challenges
|
|
||||||
}));
|
|
||||||
})
|
|
||||||
.reduce((output, group) => ({ ...output, ...group}), {})
|
|
||||||
.map(groups => ({
|
|
||||||
algorithms: groups.algorithms || [],
|
|
||||||
projects: groups.projects ? groups.projects.reverse() : [],
|
|
||||||
challenges: groups.challenges ? groups.challenges.reverse() : []
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = function(app) {
|
module.exports = function(app) {
|
||||||
const router = app.loopback.Router();
|
const router = app.loopback.Router();
|
||||||
const api = app.loopback.Router();
|
const api = app.loopback.Router();
|
||||||
const { Email, User } = app.models;
|
const { Email, User } = app.models;
|
||||||
const map$ = cachedMap(app.models);
|
|
||||||
|
|
||||||
function findUserByUsername$(username, fields) {
|
function findUserByUsername$(username, fields) {
|
||||||
return observeQuery(
|
return observeQuery(
|
||||||
@ -194,16 +110,15 @@ module.exports = function(app) {
|
|||||||
showCert
|
showCert
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get('/:username', showUserProfile);
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:username/report-user/',
|
'/user/:username/report-user/',
|
||||||
sendNonUserToMap,
|
sendNonUserToMapWithMessage('You must be signed in to report a user'),
|
||||||
ifNotVerifiedRedirectToSettings,
|
ifNotVerifiedRedirectToSettings,
|
||||||
getReportUserProfile
|
getReportUserProfile
|
||||||
);
|
);
|
||||||
|
|
||||||
api.post(
|
api.post(
|
||||||
'/:username/report-user/',
|
'/user/:username/report-user/',
|
||||||
ifNoUser401,
|
ifNoUser401,
|
||||||
postReportUserProfile
|
postReportUserProfile
|
||||||
);
|
);
|
||||||
@ -270,102 +185,6 @@ module.exports = function(app) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUserProfile(req, res, next) {
|
|
||||||
const username = req.params.username.toLowerCase();
|
|
||||||
const { user } = req;
|
|
||||||
|
|
||||||
// timezone of signed-in account
|
|
||||||
// to show all date related components
|
|
||||||
// using signed-in account's timezone
|
|
||||||
// not of the profile she is viewing
|
|
||||||
const timezone = user && user.timezone ?
|
|
||||||
user.timezone :
|
|
||||||
'EST';
|
|
||||||
|
|
||||||
const query = {
|
|
||||||
where: { username },
|
|
||||||
include: 'pledge'
|
|
||||||
};
|
|
||||||
|
|
||||||
return User.findOne$(query)
|
|
||||||
.filter(userPortfolio => {
|
|
||||||
if (!userPortfolio) {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
return !!userPortfolio;
|
|
||||||
})
|
|
||||||
.flatMap(userPortfolio => {
|
|
||||||
userPortfolio = userPortfolio.toJSON();
|
|
||||||
|
|
||||||
const timestamps = userPortfolio
|
|
||||||
.progressTimestamps
|
|
||||||
.map(objOrNum => {
|
|
||||||
return typeof objOrNum === 'number' ?
|
|
||||||
objOrNum :
|
|
||||||
objOrNum.timestamp;
|
|
||||||
});
|
|
||||||
|
|
||||||
const uniqueHours = prepUniqueDaysByHours(timestamps, timezone);
|
|
||||||
|
|
||||||
userPortfolio.currentStreak = calcCurrentStreak(uniqueHours, timezone);
|
|
||||||
userPortfolio.longestStreak = calcLongestStreak(uniqueHours, timezone);
|
|
||||||
|
|
||||||
const calender = userPortfolio
|
|
||||||
.progressTimestamps
|
|
||||||
.map((objOrNum) => {
|
|
||||||
return typeof objOrNum === 'number' ?
|
|
||||||
objOrNum :
|
|
||||||
objOrNum.timestamp;
|
|
||||||
})
|
|
||||||
.filter((timestamp) => {
|
|
||||||
return !!timestamp;
|
|
||||||
})
|
|
||||||
.reduce((data, timeStamp) => {
|
|
||||||
data[(timeStamp / 1000)] = 1;
|
|
||||||
return data;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
if (userPortfolio.isCheater && !user) {
|
|
||||||
req.flash(
|
|
||||||
'danger',
|
|
||||||
dedent`
|
|
||||||
Upon review, this account has been flagged for academic
|
|
||||||
dishonesty. If you’re the owner of this account contact
|
|
||||||
team@freecodecamp.org for details.
|
|
||||||
`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userPortfolio.bio) {
|
|
||||||
userPortfolio.bio = emoji.emojify(userPortfolio.bio);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getChallengeInfo(map$)
|
|
||||||
.flatMap(challengeInfo => buildDisplayChallenges(
|
|
||||||
challengeInfo,
|
|
||||||
userPortfolio.challengeMap,
|
|
||||||
timezone
|
|
||||||
))
|
|
||||||
.map(displayChallenges => ({
|
|
||||||
...userPortfolio,
|
|
||||||
...displayChallenges,
|
|
||||||
title: 'Camper ' + userPortfolio.username + '\'s Code Portfolio',
|
|
||||||
calender,
|
|
||||||
github: userPortfolio.githubURL,
|
|
||||||
moment,
|
|
||||||
encodeFcc,
|
|
||||||
supportedLanguages
|
|
||||||
}));
|
|
||||||
})
|
|
||||||
.doOnNext(data => {
|
|
||||||
return res.render('account/show', data);
|
|
||||||
})
|
|
||||||
.subscribe(
|
|
||||||
() => {},
|
|
||||||
next
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCert(req, res, next) {
|
function showCert(req, res, next) {
|
||||||
let { username, cert } = req.params;
|
let { username, cert } = req.params;
|
||||||
username = username.toLowerCase();
|
username = username.toLowerCase();
|
||||||
|
@ -2,21 +2,40 @@ import { Observable } from 'rx';
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
userPropsForSession,
|
getProgress,
|
||||||
normaliseUserFields
|
normaliseUserFields,
|
||||||
|
userPropsForSession
|
||||||
} from '../utils/publicUserProps';
|
} from '../utils/publicUserProps';
|
||||||
|
|
||||||
export default function userServices() {
|
export default function userServices() {
|
||||||
return {
|
return {
|
||||||
name: 'user',
|
name: 'user',
|
||||||
read: (req, resource, params, config, cb) => {
|
read: function readUserService(
|
||||||
const { user } = req;
|
req,
|
||||||
|
resource,
|
||||||
|
params,
|
||||||
|
config,
|
||||||
|
cb) {
|
||||||
|
const queryUser = req.user;
|
||||||
|
const source = queryUser && Observable.forkJoin(
|
||||||
|
queryUser.getChallengeMap$(),
|
||||||
|
queryUser.getPoints$(),
|
||||||
|
(challengeMap, progressTimestamps) => ({
|
||||||
|
challengeMap,
|
||||||
|
progress: getProgress(progressTimestamps, queryUser.timezone)
|
||||||
|
})
|
||||||
|
);
|
||||||
Observable.if(
|
Observable.if(
|
||||||
() => !user,
|
() => !queryUser,
|
||||||
Observable.of({}),
|
Observable.of({}),
|
||||||
Observable.defer(() => user.getChallengeMap$())
|
Observable.defer(() => source)
|
||||||
.map(challengeMap => ({ ...user.toJSON(), challengeMap }))
|
.map(({ challengeMap, progress }) => ({
|
||||||
.map(user => ({
|
...queryUser.toJSON(),
|
||||||
|
...progress,
|
||||||
|
challengeMap
|
||||||
|
}))
|
||||||
|
.map(
|
||||||
|
user => ({
|
||||||
entities: {
|
entities: {
|
||||||
user: {
|
user: {
|
||||||
[user.username]: {
|
[user.username]: {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { isURL } from 'validator';
|
import { isURL } from 'validator';
|
||||||
|
|
||||||
import { addPlaceholderImage } from '../utils';
|
import { addPlaceholderImage } from './';
|
||||||
import {
|
import {
|
||||||
prepUniqueDaysByHours,
|
prepUniqueDaysByHours,
|
||||||
calcCurrentStreak,
|
calcCurrentStreak,
|
||||||
|
@ -21,7 +21,7 @@ include styles
|
|||||||
h1
|
h1
|
||||||
strong Advanced Frontend Projects
|
strong Advanced Frontend Projects
|
||||||
h4 1 of 3 legacy freeCodeCamp certificates, representing approximately 400 hours of coursework
|
h4 1 of 3 legacy freeCodeCamp certificates, representing approximately 400 hours of coursework
|
||||||
|
|
||||||
footer
|
footer
|
||||||
.row.signatures
|
.row.signatures
|
||||||
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
||||||
@ -29,4 +29,4 @@ include styles
|
|||||||
strong Quincy Larson
|
strong Quincy Larson
|
||||||
p Executive Director, freeCodeCamp.org
|
p Executive Director, freeCodeCamp.org
|
||||||
.row
|
.row
|
||||||
p.verify Verify this certificate at: https://freecodecamp.org/#{username}/advanced-front-end-certification
|
p.verify Verify this certificate at: https://freecodecamp.org/c/#{username}/advanced-front-end-certification
|
||||||
|
@ -21,7 +21,7 @@ include styles
|
|||||||
h1
|
h1
|
||||||
strong APIs and Microservices Projects
|
strong APIs and Microservices Projects
|
||||||
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
||||||
|
|
||||||
footer
|
footer
|
||||||
.row.signatures
|
.row.signatures
|
||||||
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
||||||
@ -29,4 +29,4 @@ include styles
|
|||||||
strong Quincy Larson
|
strong Quincy Larson
|
||||||
p Executive Director, freeCodeCamp.org
|
p Executive Director, freeCodeCamp.org
|
||||||
.row
|
.row
|
||||||
p.verify Verify this certificate at: https://freecodecamp.org/#{username}/apis-and-microservices-certification
|
p.verify Verify this certificate at: https://freecodecamp.org/c/#{username}/apis-and-microservices-certification
|
||||||
|
@ -21,7 +21,7 @@ include styles
|
|||||||
h1
|
h1
|
||||||
strong Back End Development Projects
|
strong Back End Development Projects
|
||||||
h4 1 of 3 legacy freeCodeCamp certificates, representing approximately 400 hours of coursework
|
h4 1 of 3 legacy freeCodeCamp certificates, representing approximately 400 hours of coursework
|
||||||
|
|
||||||
footer
|
footer
|
||||||
.row.signatures
|
.row.signatures
|
||||||
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
||||||
@ -29,4 +29,4 @@ include styles
|
|||||||
strong Quincy Larson
|
strong Quincy Larson
|
||||||
p Executive Director, freeCodeCamp.org
|
p Executive Director, freeCodeCamp.org
|
||||||
.row
|
.row
|
||||||
p.verify Verify this certificate at: https://freecodecamp.org/#{username}/back-end-certification
|
p.verify Verify this certificate at: https://freecodecamp.org/c/#{username}/back-end-certification
|
||||||
|
@ -21,7 +21,7 @@ include styles
|
|||||||
h1
|
h1
|
||||||
strong Data Visualization Projects
|
strong Data Visualization Projects
|
||||||
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
||||||
|
|
||||||
footer
|
footer
|
||||||
.row.signatures
|
.row.signatures
|
||||||
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
||||||
@ -29,4 +29,4 @@ include styles
|
|||||||
strong Quincy Larson
|
strong Quincy Larson
|
||||||
p Executive Director, freeCodeCamp.org
|
p Executive Director, freeCodeCamp.org
|
||||||
.row
|
.row
|
||||||
p.verify Verify this certificate at: https://freecodecamp.org/#{username}/data-visualization-certification
|
p.verify Verify this certificate at: https://freecodecamp.org/c/#{username}/data-visualization-certification
|
||||||
|
@ -21,7 +21,7 @@ include styles
|
|||||||
h1
|
h1
|
||||||
strong Front End Libraries Projects
|
strong Front End Libraries Projects
|
||||||
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
||||||
|
|
||||||
footer
|
footer
|
||||||
.row.signatures
|
.row.signatures
|
||||||
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
||||||
@ -29,4 +29,4 @@ include styles
|
|||||||
strong Quincy Larson
|
strong Quincy Larson
|
||||||
p Executive Director, freeCodeCamp.org
|
p Executive Director, freeCodeCamp.org
|
||||||
.row
|
.row
|
||||||
p.verify Verify this certificate at: https://freecodecamp.org/#{username}/front-end-libraries-certification
|
p.verify Verify this certificate at: https://freecodecamp.org/c/#{username}/front-end-libraries-certification
|
||||||
|
@ -21,7 +21,7 @@ include styles
|
|||||||
h1
|
h1
|
||||||
strong Front End Development Projects
|
strong Front End Development Projects
|
||||||
h4 1 of 3 legacy freeCodeCamp certificates, representing approximately 400 hours of coursework
|
h4 1 of 3 legacy freeCodeCamp certificates, representing approximately 400 hours of coursework
|
||||||
|
|
||||||
footer
|
footer
|
||||||
.row.signatures
|
.row.signatures
|
||||||
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
||||||
@ -29,4 +29,4 @@ include styles
|
|||||||
strong Quincy Larson
|
strong Quincy Larson
|
||||||
p Executive Director, freeCodeCamp.org
|
p Executive Director, freeCodeCamp.org
|
||||||
.row
|
.row
|
||||||
p.verify Verify this certificate at: https://freecodecamp.org/#{username}/front-end-certification
|
p.verify Verify this certificate at: https://freecodecamp.org/c/#{username}/front-end-certification
|
||||||
|
@ -21,7 +21,7 @@ include styles
|
|||||||
h1
|
h1
|
||||||
strong Full Stack Development Projects
|
strong Full Stack Development Projects
|
||||||
h4 1 of 3 legacy freeCodeCamp certificates, representing approximately 400 hours of coursework
|
h4 1 of 3 legacy freeCodeCamp certificates, representing approximately 400 hours of coursework
|
||||||
|
|
||||||
footer
|
footer
|
||||||
.row.signatures
|
.row.signatures
|
||||||
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
||||||
@ -29,4 +29,4 @@ include styles
|
|||||||
strong Quincy Larson
|
strong Quincy Larson
|
||||||
p Executive Director, freeCodeCamp.org
|
p Executive Director, freeCodeCamp.org
|
||||||
.row
|
.row
|
||||||
p.verify Verify this certificate at: https://freecodecamp.org/#{username}/full-stack-certification
|
p.verify Verify this certificate at: https://freecodecamp.org/c/#{username}/full-stack-certification
|
||||||
|
@ -21,7 +21,7 @@ include styles
|
|||||||
h1
|
h1
|
||||||
strong Information Security and Quality Assurance Projects
|
strong Information Security and Quality Assurance Projects
|
||||||
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
||||||
|
|
||||||
footer
|
footer
|
||||||
.row.signatures
|
.row.signatures
|
||||||
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
||||||
@ -29,4 +29,4 @@ include styles
|
|||||||
strong Quincy Larson
|
strong Quincy Larson
|
||||||
p Executive Director, freeCodeCamp.org
|
p Executive Director, freeCodeCamp.org
|
||||||
.row
|
.row
|
||||||
p.verify Verify this certificate at: https://freecodecamp.org/#{username}/information-security-and-quality-assurance-certification
|
p.verify Verify this certificate at: https://freecodecamp.org/c/#{username}/information-security-and-quality-assurance-certification
|
||||||
|
@ -21,7 +21,7 @@ include styles
|
|||||||
h1
|
h1
|
||||||
strong JavaScript Algorithms and Data Structures Certificate
|
strong JavaScript Algorithms and Data Structures Certificate
|
||||||
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
||||||
|
|
||||||
footer
|
footer
|
||||||
.row.signatures
|
.row.signatures
|
||||||
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
||||||
@ -29,4 +29,4 @@ include styles
|
|||||||
strong Quincy Larson
|
strong Quincy Larson
|
||||||
p Executive Director, freeCodeCamp.org
|
p Executive Director, freeCodeCamp.org
|
||||||
.row
|
.row
|
||||||
p.verify Verify this certificate at: https://freecodecamp.org/#{username}/javascript-algorithms-and-data-structures-certification
|
p.verify Verify this certificate at: https://freecodecamp.org/c/#{username}/javascript-algorithms-and-data-structures-certification
|
||||||
|
@ -21,7 +21,7 @@ include styles
|
|||||||
h1
|
h1
|
||||||
strong Responsive Web Design Projects
|
strong Responsive Web Design Projects
|
||||||
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
||||||
|
|
||||||
footer
|
footer
|
||||||
.row.signatures
|
.row.signatures
|
||||||
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
img(class='img-responsive', src='https://i.imgur.com/OJFVJKg.png', alt="Quincy Larson's Signature")
|
||||||
@ -29,4 +29,4 @@ include styles
|
|||||||
strong Quincy Larson
|
strong Quincy Larson
|
||||||
p Executive Director, freeCodeCamp.org
|
p Executive Director, freeCodeCamp.org
|
||||||
.row
|
.row
|
||||||
p.verify Verify this certificate at: https://freecodecamp.org/#{username}/responsive-web-design-certification
|
p.verify Verify this certificate at: https://freecodecamp.org/c/#{username}/responsive-web-design-certification
|
||||||
|
Reference in New Issue
Block a user