diff --git a/client/package-lock.json b/client/package-lock.json
index 5b31992619..21487535cb 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -4428,6 +4428,11 @@
}
}
},
+ "date-fns": {
+ "version": "1.29.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.29.0.tgz",
+ "integrity": "sha512-lbTXWZ6M20cWH8N9S6afb0SBm6tMk+uUg6z3MqHPKE9atmsY3kJkTm8vKe93izJ2B2+q5MV990sM2CHgtAZaOw=="
+ },
"date-now": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz",
diff --git a/client/package.json b/client/package.json
index 562a836d3d..80703370d1 100644
--- a/client/package.json
+++ b/client/package.json
@@ -19,6 +19,7 @@
"axios": "^0.18.0",
"browser-cookies": "^1.2.0",
"chai": "^4.2.0",
+ "date-fns": "^1.29.0",
"enzyme": "^3.6.0",
"enzyme-adapter-react-16": "^1.5.0",
"fetchr": "^0.5.37",
diff --git a/client/src/client-only-routes/ShowProfileOrFourOhFour.js b/client/src/client-only-routes/ShowProfileOrFourOhFour.js
new file mode 100644
index 0000000000..1ff86350d6
--- /dev/null
+++ b/client/src/client-only-routes/ShowProfileOrFourOhFour.js
@@ -0,0 +1,98 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import { isEmpty } from 'lodash';
+
+import Loader from '../components/helpers/Loader';
+import Layout from '../components/layouts/Default';
+import {
+ userByNameSelector,
+ userProfileFetchStateSelector,
+ fetchProfileForUser,
+ usernameSelector
+} from '../redux';
+import FourOhFourPage from '../components/FourOhFour';
+import Profile from '../components/profile/Profile';
+
+const propTypes = {
+ fetchProfileForUser: PropTypes.func.isRequired,
+ isSessionUser: PropTypes.bool,
+ maybeUser: PropTypes.string,
+ requestedUser: PropTypes.shape({
+ username: PropTypes.string,
+ profileUI: PropTypes.object
+ }),
+ showLoading: PropTypes.bool,
+ splat: PropTypes.string
+};
+
+const createRequestedUserSelector = () => (state, { maybeUser }) =>
+ userByNameSelector(maybeUser)(state);
+const createIsSessionUserSelector = () => (state, { maybeUser }) =>
+ maybeUser === usernameSelector(state);
+
+const makeMapStateToProps = () => (state, props) => {
+ const requestedUserSelector = createRequestedUserSelector();
+ const isSessionUserSelector = createIsSessionUserSelector();
+ const fetchState = userProfileFetchStateSelector(state, props);
+ return {
+ requestedUser: requestedUserSelector(state, props),
+ isSessionUser: isSessionUserSelector(state, props),
+ showLoading: fetchState.pending,
+ fetchState
+ };
+};
+
+const mapDispatchToProps = dispatch =>
+ bindActionCreators({ fetchProfileForUser }, dispatch);
+
+class ShowFourOhFour extends Component {
+ componentDidMount() {
+ const { requestedUser, maybeUser, splat, fetchProfileForUser } = this.props;
+ if (!splat && isEmpty(requestedUser)) {
+ console.log(requestedUser);
+ return fetchProfileForUser(maybeUser);
+ }
+ return null;
+ }
+
+ render() {
+ const { isSessionUser, requestedUser, showLoading, splat } = this.props;
+ if (splat) {
+ // the uri path for this component is /:maybeUser/:splat
+ // if splat is defined then we on a route that is not a profile
+ // and we should just 404
+ return ;
+ }
+ if (showLoading) {
+ // We don't know if /:maybeUser is a user or not, we will show the loader
+ // until we get a response from the API
+ return (
+
+
+
+
+
+ );
+ }
+ if (isEmpty(requestedUser)) {
+ // We have a response from the API, but there is nothing in the store
+ // for /:maybeUser. We can derive from this state the /:maybeUser is not
+ // a user the API recognises, so we 404
+ return ;
+ }
+
+ // We have a response from the API, and we have some state in the
+ // store for /:maybeUser, we now handover rendering to the Profile component
+ return ;
+ }
+}
+
+ShowFourOhFour.displayName = 'ShowFourOhFour';
+ShowFourOhFour.propTypes = propTypes;
+
+export default connect(
+ makeMapStateToProps,
+ mapDispatchToProps
+)(ShowFourOhFour);
diff --git a/client/src/pages/404.css b/client/src/components/FourOhFour/404.css
similarity index 100%
rename from client/src/pages/404.css
rename to client/src/components/FourOhFour/404.css
diff --git a/client/src/components/FourOhFour/index.js b/client/src/components/FourOhFour/index.js
new file mode 100644
index 0000000000..1864f96669
--- /dev/null
+++ b/client/src/components/FourOhFour/index.js
@@ -0,0 +1,64 @@
+import React, { Component } from 'react';
+import Helmet from 'react-helmet';
+import Spinner from 'react-spinkit';
+import { Link } from 'gatsby';
+
+import Layout from '../layouts/Default';
+
+import notFoundLogo from '../../images/freeCodeCamp-404.svg';
+import { quotes } from '../../resources/quotes.json';
+
+import './404.css';
+
+class NotFoundPage extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ randomQuote: null
+ };
+ }
+
+ componentDidMount() {
+ this.updateQuote();
+ }
+
+ updateQuote() {
+ this.setState({
+ randomQuote: quotes[Math.floor(Math.random() * quotes.length)]
+ });
+ }
+
+ render() {
+ return (
+
+
+
+
+
NOT FOUND
+ {this.state.randomQuote ? (
+
+
+ We couldn't find what you were looking for, but here is a
+ quote:
+
+
+
+ “
+ {this.state.randomQuote.quote}
+
+
- {this.state.randomQuote.author}
+
+
+ ) : (
+
+ )}
+
+ View the Curriculum
+
+
+
+ );
+ }
+}
+
+export default NotFoundPage;
diff --git a/client/src/components/helpers/CurrentChallengeLink.js b/client/src/components/helpers/CurrentChallengeLink.js
new file mode 100644
index 0000000000..d71b378c02
--- /dev/null
+++ b/client/src/components/helpers/CurrentChallengeLink.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import { apiLocation } from '../../../config/env.json';
+
+import { hardGoTo } from '../../redux';
+
+const currentChallengeApi = '/challenges/current-challenge';
+
+const propTypes = {
+ children: PropTypes.any,
+ hardGoTo: PropTypes.func.isRequired
+};
+
+const mapStateToProps = () => ({});
+const mapDispatchToProps = dispatch =>
+ bindActionCreators({ hardGoTo }, dispatch);
+
+const createClickHandler = hardGoTo => e => {
+ e.preventDefault();
+ return hardGoTo(`${apiLocation}${currentChallengeApi}`);
+};
+
+function CurrentChallengeLink({ children, hardGoTo }) {
+ return (
+
+ {children}
+
+ );
+}
+
+CurrentChallengeLink.displayName = 'CurrentChallengeLink';
+CurrentChallengeLink.propTypes = propTypes;
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(CurrentChallengeLink);
diff --git a/client/src/components/profile/Profile.js b/client/src/components/profile/Profile.js
new file mode 100644
index 0000000000..33b8a23105
--- /dev/null
+++ b/client/src/components/profile/Profile.js
@@ -0,0 +1,176 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { Alert, Button, Grid, Row, Col } from '@freecodecamp/react-bootstrap';
+import Helmet from 'react-helmet';
+import { Link } from 'gatsby';
+
+import Layout from '../layouts/Default';
+import CurrentChallengeLink from '../helpers/CurrentChallengeLink';
+import FullWidthRow from '../helpers/FullWidthRow';
+import Spacer from '../helpers/Spacer';
+import Camper from './components/Camper';
+import HeatMap from './components/HeatMap';
+import Certifications from './components/Certifications';
+import Portfolio from './components/Portfolio';
+import Timeline from './components/TimeLine';
+
+const propTypes = {
+ isSessionUser: PropTypes.bool,
+ user: PropTypes.shape({
+ profileUI: PropTypes.shape({
+ isLocked: PropTypes.bool,
+ showAbout: PropTypes.bool,
+ showCerts: PropTypes.bool,
+ showHeatMap: PropTypes.bool,
+ showLocation: PropTypes.bool,
+ showName: PropTypes.bool,
+ showPoints: PropTypes.bool,
+ showPortfolio: PropTypes.bool,
+ showTimeLine: PropTypes.bool
+ }),
+ username: PropTypes.string
+ })
+};
+
+function TakeMeToTheChallenges() {
+ return (
+
+
+ Take me to the Challenges
+
+
+ );
+}
+
+function renderIsLocked(username) {
+ return (
+
+
+ {username} | freeCodeCamp.org
+
+
+
+
+
+ {username} has not made their profile public.
+
+
+
+
+
+ {username} needs to change their privacy setting in order for you
+ to view their profile
+
+
+
+
+
+
+
+
+ );
+}
+
+function renderSettingsButton() {
+ return (
+
+
+
+
+
+ Update my settings
+
+
+
+
+
+
+ );
+}
+
+function Profile({ user, isSessionUser }) {
+ const {
+ profileUI: {
+ isLocked = true,
+ showAbout = false,
+ showCerts = false,
+ showHeatMap = false,
+ showLocation = false,
+ showName = false,
+ showPoints = false,
+ showPortfolio = false,
+ showTimeLine = false
+ },
+ calendar,
+ completedChallenges,
+ streak,
+ githubProfile,
+ isLinkedIn,
+ isGithub,
+ isTwitter,
+ isWebsite,
+ linkedin,
+ twitter,
+ website,
+ name,
+ username,
+ location,
+ points,
+ picture,
+ portfolio,
+ about,
+ yearsTopContributor
+ } = user;
+
+ if (isLocked) {
+ return renderIsLocked(username);
+ }
+ return (
+
+
+ {username} | freeCodeCamp.org
+
+
+
+ {isSessionUser ? renderSettingsButton() : null}
+
+
+ {showHeatMap ? : null}
+ {showCerts ? : null}
+ {showPortfolio ? : null}
+ {showTimeLine ? (
+
+ ) : null}
+
+ );
+}
+
+Profile.displayName = 'Profile';
+Profile.propTypes = propTypes;
+
+export default Profile;
diff --git a/client/src/components/profile/components/Camper.js b/client/src/components/profile/components/Camper.js
new file mode 100644
index 0000000000..49d1f291e4
--- /dev/null
+++ b/client/src/components/profile/components/Camper.js
@@ -0,0 +1,113 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Col, Row, Image } from '@freecodecamp/react-bootstrap';
+import FontAwesomeIcon from '@fortawesome/react-fontawesome';
+import { faAward } from '@fortawesome/free-solid-svg-icons';
+
+import SocialIcons from './SocialIcons';
+
+import './camper.css';
+
+const propTypes = {
+ about: PropTypes.string,
+ githubProfile: PropTypes.string,
+ isGithub: PropTypes.bool,
+ isLinkedIn: PropTypes.bool,
+ isTwitter: PropTypes.bool,
+ isWebsite: PropTypes.bool,
+ linkedin: PropTypes.string,
+ location: PropTypes.string,
+ name: PropTypes.string,
+ picture: PropTypes.string,
+ points: PropTypes.number,
+ twitter: PropTypes.string,
+ username: PropTypes.string,
+ website: PropTypes.string,
+ yearsTopContributor: PropTypes.array
+};
+
+function pluralise(word, condition) {
+ return condition ? word + 's' : word;
+}
+
+function joinArray(array) {
+ return array.reduce((string, item, index, array) => {
+ if (string.length > 0) {
+ if (index === array.length - 1) {
+ return `${string} and ${item}`;
+ } else {
+ return `${string}, ${item}`;
+ }
+ } else {
+ return item;
+ }
+ });
+}
+
+function Camper({
+ name,
+ username,
+ location,
+ points,
+ picture,
+ about,
+ yearsTopContributor,
+ githubProfile,
+ isLinkedIn,
+ isGithub,
+ isTwitter,
+ isWebsite,
+ linkedin,
+ twitter,
+ website
+}) {
+ return (
+
+
+
+
+
+
+
+
+
@{username}
+ {name &&
{name}
}
+ {location &&
{location}
}
+ {about &&
{about}
}
+ {typeof points === 'number' ? (
+
+ {`${points} ${pluralise('point', points !== 1)}`}
+
+ ) : null}
+ {yearsTopContributor.filter(Boolean).length > 0 && (
+
+
+
+ Top Contributor
+
+
{joinArray(yearsTopContributor)}
+
+ )}
+
+
+ );
+}
+
+Camper.displayName = 'Camper';
+Camper.propTypes = propTypes;
+
+export default Camper;
diff --git a/client/src/components/profile/components/Certifications.js b/client/src/components/profile/components/Certifications.js
new file mode 100644
index 0000000000..a781a4550d
--- /dev/null
+++ b/client/src/components/profile/components/Certifications.js
@@ -0,0 +1,165 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { curry } from 'lodash';
+import { createSelector } from 'reselect';
+import { connect } from 'react-redux';
+import { Button, Row, Col } from '@freecodecamp/react-bootstrap';
+
+import { userByNameSelector } from '../../../redux';
+import FullWidthRow from '../../helpers/FullWidthRow';
+
+const mapStateToProps = (state, props) =>
+ createSelector(
+ userByNameSelector(props.username),
+ ({
+ isRespWebDesignCert,
+ is2018DataVisCert,
+ isFrontEndLibsCert,
+ isJsAlgoDataStructCert,
+ isApisMicroservicesCert,
+ isInfosecQaCert,
+ isFrontEndCert,
+ isBackEndCert,
+ isDataVisCert,
+ isFullStackCert
+ }) => ({
+ hasModernCert:
+ isRespWebDesignCert ||
+ is2018DataVisCert ||
+ isFrontEndLibsCert ||
+ isJsAlgoDataStructCert ||
+ isApisMicroservicesCert ||
+ isInfosecQaCert ||
+ isFullStackCert,
+ hasLegacyCert: isFrontEndCert || isBackEndCert || isDataVisCert,
+ currentCerts: [
+ {
+ show: isFullStackCert,
+ title: 'Full Stack Certification',
+ showURL: 'full-stack'
+ },
+ {
+ show: isRespWebDesignCert,
+ title: 'Responsive Web Design Certification',
+ showURL: 'responsive-web-design'
+ },
+ {
+ show: isJsAlgoDataStructCert,
+ title: 'JavaScript Algorithms and Data Structures Certification',
+ showURL: 'javascript-algorithms-and-data-structures'
+ },
+ {
+ show: isFrontEndLibsCert,
+ title: 'Front End Libraries Certification',
+ showURL: 'front-end-libraries'
+ },
+ {
+ show: is2018DataVisCert,
+ title: 'Data Visualization Certification',
+ showURL: 'data-visualization'
+ },
+ {
+ show: isApisMicroservicesCert,
+ title: 'APIs and Microservices Certification',
+ showURL: 'apis-and-microservices'
+ },
+ {
+ show: isInfosecQaCert,
+ title: 'Information Security and Quality Assurance Certification',
+ showURL: 'information-security-and-quality-assurance'
+ }
+ ],
+ legacyCerts: [
+ {
+ show: isFullStackCert,
+ title: 'Full Stack Certification',
+ showURL: 'legacy-full-stack'
+ },
+ {
+ show: isFrontEndCert,
+ title: 'Front End Certification',
+ showURL: 'legacy-front-end'
+ },
+ {
+ show: isBackEndCert,
+ title: 'Back End Certification',
+ showURL: 'legacy-back-end'
+ },
+ {
+ show: isDataVisCert,
+ title: 'Data Visualization Certification',
+ showURL: 'legacy-data-visualization'
+ }
+ ]
+ })
+ )(state, props);
+
+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 ? (
+
+
+
+ View {cert.title}
+
+
+
+ ) : null;
+}
+
+function Certificates({
+ currentCerts,
+ legacyCerts,
+ hasLegacyCert,
+ hasModernCert,
+ username
+}) {
+ const renderCertShowWithUsername = curry(renderCertShow)(username);
+ return (
+
+ freeCodeCamp Certifications
+
+ {hasModernCert ? (
+ currentCerts.map(renderCertShowWithUsername)
+ ) : (
+
+ No certifications have been earned under the current curriculum
+
+ )}
+ {hasLegacyCert ? (
+
+
+
Legacy Certifications
+
+ {legacyCerts.map(renderCertShowWithUsername)}
+
+ ) : null}
+
+
+ );
+}
+
+Certificates.propTypes = propTypes;
+Certificates.displayName = 'Certificates';
+
+export default connect(mapStateToProps)(Certificates);
diff --git a/client/src/components/profile/components/HeatMap.js b/client/src/components/profile/components/HeatMap.js
new file mode 100644
index 0000000000..550c7ba6da
--- /dev/null
+++ b/client/src/components/profile/components/HeatMap.js
@@ -0,0 +1,60 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { Helmet } from 'react-helmet';
+
+import FullWidthRow from '../../helpers/FullWidthRow';
+
+import './heatmap.css';
+
+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);
+ }
+
+ render() {
+ const { streak = {} } = this.props;
+ return (
+
+
+
+
+
+
+ This needs a refactor to use something like
+
+ react-calendar-heatmap
+
+
+
+
+
+ Longest Streak: {streak.longest || 1}
+
+
+ Current Streak: {streak.current || 1}
+
+
+
+
+
+ );
+ }
+}
+
+HeatMap.displayName = 'HeatMap';
+HeatMap.propTypes = propTypes;
+
+export default HeatMap;
diff --git a/client/src/components/profile/components/Portfolio.js b/client/src/components/profile/components/Portfolio.js
new file mode 100644
index 0000000000..9c3dc3b8eb
--- /dev/null
+++ b/client/src/components/profile/components/Portfolio.js
@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Thumbnail, Media } from '@freecodecamp/react-bootstrap';
+
+import { FullWidthRow } from '../../helpers';
+
+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 (
+
+
+ Portfolio
+ {portfolio.map(({ title, url, image, description, id }) => (
+
+
+ {image && (
+
+
+
+ )}
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+ ))}
+
+
+
+ );
+}
+
+Portfolio.displayName = 'Portfolio';
+Portfolio.propTypes = propTypes;
+
+export default Portfolio;
diff --git a/client/src/components/profile/components/SocialIcons.js b/client/src/components/profile/components/SocialIcons.js
new file mode 100644
index 0000000000..fbadd2be7a
--- /dev/null
+++ b/client/src/components/profile/components/SocialIcons.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Row, Col } from '@freecodecamp/react-bootstrap';
+import FontAwesomeIcon from '@fortawesome/react-fontawesome';
+import {
+ faLinkedin,
+ faGithub,
+ faTwitter
+} from '@fortawesome/free-brands-svg-icons';
+import { faLink } from '@fortawesome/free-solid-svg-icons';
+
+import './social-icons.css';
+
+const propTypes = {
+ email: PropTypes.string,
+ githubProfile: PropTypes.string,
+ isGithub: PropTypes.bool,
+ isLinkedIn: PropTypes.bool,
+ isTwitter: PropTypes.bool,
+ isWebsite: PropTypes.bool,
+ linkedin: PropTypes.string,
+ show: PropTypes.bool,
+ twitter: PropTypes.string,
+ website: PropTypes.string
+};
+
+function LinkedInIcon(linkedIn) {
+ return (
+
+
+
+ );
+}
+
+function GithubIcon(ghURL) {
+ return (
+
+
+
+ );
+}
+
+function WebsiteIcon(website) {
+ return (
+
+
+
+ );
+}
+
+function TwitterIcon(handle) {
+ return (
+
+
+
+ );
+}
+
+function SocialIcons(props) {
+ const {
+ githubProfile,
+ isLinkedIn,
+ isGithub,
+ isTwitter,
+ isWebsite,
+ linkedin,
+ twitter,
+ website
+ } = props;
+ const show = isLinkedIn || isGithub || isTwitter || isWebsite;
+ if (!show) {
+ return null;
+ }
+
+ return (
+
+
+ {isLinkedIn ? LinkedInIcon(linkedin) : null}
+ {isGithub ? GithubIcon(githubProfile) : null}
+ {isWebsite ? WebsiteIcon(website) : null}
+ {isTwitter ? TwitterIcon(twitter) : null}
+
+
+ );
+}
+
+SocialIcons.displayName = 'SocialIcons';
+SocialIcons.propTypes = propTypes;
+
+export default SocialIcons;
diff --git a/client/src/components/profile/components/TimeLine.js b/client/src/components/profile/components/TimeLine.js
new file mode 100644
index 0000000000..5c78b28c0f
--- /dev/null
+++ b/client/src/components/profile/components/TimeLine.js
@@ -0,0 +1,170 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { createSelector } from 'reselect';
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import format from 'date-fns/format';
+import { find, reverse, sortBy, isEmpty } from 'lodash';
+import { Button, Modal, Table } from '@freecodecamp/react-bootstrap';
+import { Link } from 'gatsby';
+
+import {
+ challengeIdToNameMapSelector,
+ fetchIdToNameMap
+} from '../../../templates/Challenges/redux';
+import { blockNameify } from '../../../../utils/blockNameify';
+import { FullWidthRow } from '../../helpers';
+import SolutionViewer from '../../settings/SolutionViewer';
+
+const mapStateToProps = createSelector(
+ challengeIdToNameMapSelector,
+ idToNameMap => ({
+ idToNameMap
+ })
+);
+
+const mapDispatchToProps = dispatch =>
+ bindActionCreators({ fetchIdToNameMap }, dispatch);
+
+const propTypes = {
+ completedMap: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string,
+ completedDate: PropTypes.number,
+ challengeType: PropTypes.number,
+ solution: PropTypes.string,
+ files: PropTypes.shape({
+ ext: PropTypes.string,
+ contents: PropTypes.string
+ })
+ })
+ ),
+ fetchIdToNameMap: PropTypes.func.isRequired,
+ idToNameMap: PropTypes.objectOf(PropTypes.string),
+ username: PropTypes.string
+};
+
+class Timeline extends Component {
+ 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 (isEmpty(this.props.idToNameMap)) {
+ return this.props.fetchIdToNameMap();
+ }
+ return null;
+ }
+
+ renderCompletion(completed) {
+ const { idToNameMap } = this.props;
+ const { id, completedDate } = completed;
+ const challengeDashedName = idToNameMap[id];
+ return (
+
+
+
+ {blockNameify(challengeDashedName)}
+
+
+
+
+ {format(completedDate, 'MMMM D, YYYY')}
+
+
+
+
+ );
+ }
+ 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 (isEmpty(idToNameMap)) {
+ return null;
+ }
+ return (
+
+ Timeline
+ {completedMap.length === 0 ? (
+
+ No challenges have been completed yet.
+ Get started here.
+
+ ) : (
+
+
+
+ Challenge
+ Completed
+
+
+
+ {reverse(
+ sortBy(completedMap, ['completedDate']).filter(
+ ({ id }) => id in idToNameMap
+ )
+ ).map(this.renderCompletion)}
+
+
+ )}
+ {id && (
+
+
+
+ {`${username}'s Solution to ${blockNameify(idToNameMap[id])}`}
+
+
+
+ completedId === id
+ )}
+ />
+
+
+ Close
+
+
+ )}
+
+ );
+ }
+}
+
+Timeline.displayName = 'Timeline';
+Timeline.propTypes = propTypes;
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(Timeline);
diff --git a/client/src/components/profile/components/camper.css b/client/src/components/profile/components/camper.css
new file mode 100644
index 0000000000..71280fbe5c
--- /dev/null
+++ b/client/src/components/profile/components/camper.css
@@ -0,0 +1,8 @@
+.avatar-container .avatar {
+ height: 180px;
+}
+
+.avatar-container {
+ display: flex;
+ justify-content: center;
+}
diff --git a/client/src/components/profile/components/heatmap.css b/client/src/components/profile/components/heatmap.css
new file mode 100644
index 0000000000..b5cd07f372
--- /dev/null
+++ b/client/src/components/profile/components/heatmap.css
@@ -0,0 +1,15 @@
+.streak-container {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ font-size: 18px;
+ color: #006400;
+}
+
+.streak strong {
+ color: #333;
+}
+
+.night .streak strong {
+ color: #ccc;
+}
diff --git a/client/src/components/profile/components/social-icons.css b/client/src/components/profile/components/social-icons.css
new file mode 100644
index 0000000000..b508f539ec
--- /dev/null
+++ b/client/src/components/profile/components/social-icons.css
@@ -0,0 +1,7 @@
+.social-media-icons > a {
+ margin: 0 10px 0 0;
+}
+
+.social-media-icons > a:last-child {
+ margin: 0;
+}
diff --git a/client/src/pages/404.js b/client/src/pages/404.js
index d0d882e48c..af2a081ab6 100644
--- a/client/src/pages/404.js
+++ b/client/src/pages/404.js
@@ -1,64 +1,17 @@
import React from 'react';
-import Helmet from 'react-helmet';
-import Spinner from 'react-spinkit';
-import { Link } from 'gatsby';
+import { Router } from '@reach/router';
+// eslint-disable-next-line max-len
+import ShowProfileOrFourOhFour from '../client-only-routes/ShowProfileOrFourOhFour';
-import Layout from '../components/layouts/Default';
-
-import notFoundLogo from '../images/freeCodeCamp-404.svg';
-import { quotes } from '../resources/quotes.json';
-
-import './404.css';
-
-class NotFoundPage extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- randomQuote: null
- };
- }
-
- componentDidMount() {
- this.updateQuote();
- }
-
- updateQuote() {
- this.setState({
- randomQuote: quotes[Math.floor(Math.random() * quotes.length)]
- });
- }
-
- render() {
- return (
-
-
-
-
-
NOT FOUND
- {this.state.randomQuote ? (
-
-
- We couldn't find what you were looking for, but here is a
- quote:
-
-
-
- “
- {this.state.randomQuote.quote}
-
-
- {this.state.randomQuote.author}
-
-
- ) : (
-
- )}
-
- View the Curriculum
-
-
-
- );
- }
+function FourOhFourPage() {
+ return (
+
+
+
+
+ );
}
-export default NotFoundPage;
+FourOhFourPage.displayName = 'FourOhFourPage';
+
+export default FourOhFourPage;
diff --git a/client/src/redux/fetch-user-saga.js b/client/src/redux/fetch-user-saga.js
index d6bb6c0696..86babd45d0 100644
--- a/client/src/redux/fetch-user-saga.js
+++ b/client/src/redux/fetch-user-saga.js
@@ -1,7 +1,12 @@
import { call, put, takeEvery } from 'redux-saga/effects';
-import { fetchUserComplete, fetchUserError } from './';
-import { getSessionUser } from '../utils/ajax';
+import {
+ fetchUserComplete,
+ fetchUserError,
+ fetchProfileForUserError,
+ fetchProfileForUserComplete
+} from './';
+import { getSessionUser, getUserProfile } from '../utils/ajax';
import { jwt } from './cookieValues';
function* fetchSessionUser() {
@@ -13,13 +18,30 @@ function* fetchSessionUser() {
const {
data: { user = {}, result = '' }
} = yield call(getSessionUser);
- const appUser = user[result];
+ const appUser = user[result] || {};
yield put(fetchUserComplete({ user: appUser, username: result }));
} catch (e) {
yield put(fetchUserError(e));
}
}
-export function createFetchUserSaga(types) {
- return [takeEvery(types.fetchUser, fetchSessionUser)];
+function* fetchOtherUser({ payload: maybeUser }) {
+ try {
+ const { data } = yield call(getUserProfile, maybeUser);
+
+ const { entities: { user = {} } = {}, result = '' } = data;
+ const otherUser = user[result] || {};
+ yield put(
+ fetchProfileForUserComplete({ user: otherUser, username: result })
+ );
+ } catch (e) {
+ yield put(fetchProfileForUserError(e));
+ }
+}
+
+export function createFetchUserSaga(types) {
+ return [
+ takeEvery(types.fetchUser, fetchSessionUser),
+ takeEvery(types.fetchProfileForUser, fetchOtherUser)
+ ];
}
diff --git a/client/src/redux/index.js b/client/src/redux/index.js
index dc29f9444f..bc72bc2081 100644
--- a/client/src/redux/index.js
+++ b/client/src/redux/index.js
@@ -39,6 +39,9 @@ const initialState = {
userFetchState: {
...defaultFetchState
},
+ userProfileFetchState: {
+ ...defaultFetchState
+ },
showDonationModal: false,
isOnline: true
};
@@ -53,6 +56,7 @@ export const types = createTypes(
'updateComplete',
'updateFailed',
...createAsyncTypes('fetchUser'),
+ ...createAsyncTypes('fetchProfileForUser'),
...createAsyncTypes('acceptTerms'),
...createAsyncTypes('showCert'),
...createAsyncTypes('reportUser')
@@ -60,11 +64,7 @@ export const types = createTypes(
ns
);
-export const epics = [
- hardGoToEpic,
- failedUpdatesEpic,
- updateCompleteEpic
-];
+export const epics = [hardGoToEpic, failedUpdatesEpic, updateCompleteEpic];
export const sagas = [
...createAcceptTermsSaga(types),
@@ -98,6 +98,14 @@ export const fetchUser = createAction(types.fetchUser);
export const fetchUserComplete = createAction(types.fetchUserComplete);
export const fetchUserError = createAction(types.fetchUserError);
+export const fetchProfileForUser = createAction(types.fetchProfileForUser);
+export const fetchProfileForUserComplete = createAction(
+ types.fetchProfileForUserComplete
+);
+export const fetchProfileForUserError = createAction(
+ types.fetchProfileForUserError
+);
+
export const reportUser = createAction(types.reportUser);
export const reportUserComplete = createAction(types.reportUserComplete);
export const reportUserError = createAction(types.reportUserError);
@@ -144,6 +152,8 @@ export const userByNameSelector = username => state => {
return username in user ? user[username] : {};
};
export const userFetchStateSelector = state => state[ns].userFetchState;
+export const userProfileFetchStateSelector = state =>
+ state[ns].userProfileFetchState;
export const usernameSelector = state => state[ns].appUsername;
export const userSelector = state => {
const username = usernameSelector(state);
@@ -170,11 +180,15 @@ export const reducer = handleActions(
...state,
userFetchState: { ...defaultFetchState }
}),
+ [types.fetchProfileForUser]: state => ({
+ ...state,
+ userProfileFetchState: { ...defaultFetchState }
+ }),
[types.fetchUserComplete]: (state, { payload: { user, username } }) => ({
...state,
user: {
...state.user,
- [username]: user
+ [username]: { ...user, sessionUser: true }
},
appUsername: username,
userFetchState: {
@@ -193,6 +207,35 @@ export const reducer = handleActions(
error: payload
}
}),
+ [types.fetchProfileForUserComplete]: (
+ state,
+ { payload: { user, username } }
+ ) => {
+ const previousUserObject =
+ username in state.user ? state.user[username] : {};
+ return {
+ ...state,
+ user: {
+ ...state.user,
+ [username]: { ...previousUserObject, ...user }
+ },
+ userProfileFetchState: {
+ pending: false,
+ complete: true,
+ errored: false,
+ error: null
+ }
+ };
+ },
+ [types.fetchProfileForUserError]: (state, { payload }) => ({
+ ...state,
+ userFetchState: {
+ pending: false,
+ complete: false,
+ errored: true,
+ error: payload
+ }
+ }),
[types.onlineStatusChange]: (state, { payload: isOnline }) => ({
...state,
isOnline
diff --git a/client/src/redux/rootSaga.js b/client/src/redux/rootSaga.js
index d5965a2a40..99671f072d 100644
--- a/client/src/redux/rootSaga.js
+++ b/client/src/redux/rootSaga.js
@@ -2,7 +2,8 @@ import { all } from 'redux-saga/effects';
import { sagas as appSagas } from './';
import { sagas as settingsSagas } from './settings';
+import { sagas as challengeSagas } from '../templates/Challenges/redux';
export default function* rootSaga() {
- yield all([...appSagas, ...settingsSagas]);
+ yield all([...appSagas, ...challengeSagas, ...settingsSagas]);
}
diff --git a/client/src/templates/Challenges/redux/id-to-name-map-saga.js b/client/src/templates/Challenges/redux/id-to-name-map-saga.js
new file mode 100644
index 0000000000..c4a4b4f1a0
--- /dev/null
+++ b/client/src/templates/Challenges/redux/id-to-name-map-saga.js
@@ -0,0 +1,22 @@
+import { call, put, takeEvery } from 'redux-saga/effects';
+
+import { getIdToNameMap } from '../../../utils/ajax';
+import { fetchIdToNameMapComplete, fetchIdToNameMapError } from './';
+
+function* fetchIdToNameMapSaga() {
+ try {
+ const { data } = yield call(getIdToNameMap);
+
+ yield put(
+ fetchIdToNameMapComplete(data)
+ );
+ } catch (e) {
+ yield put(fetchIdToNameMapError(e));
+ }
+}
+
+export function createIdToNameMapSaga(types) {
+ return [
+ takeEvery(types.fetchIdToNameMap, fetchIdToNameMapSaga)
+ ];
+}
diff --git a/client/src/templates/Challenges/redux/index.js b/client/src/templates/Challenges/redux/index.js
index 60b7604452..a9badf55d4 100644
--- a/client/src/templates/Challenges/redux/index.js
+++ b/client/src/templates/Challenges/redux/index.js
@@ -2,6 +2,8 @@ import { createAction, handleActions } from 'redux-actions';
import { reducer as reduxFormReducer } from 'redux-form';
import { createTypes } from '../../../../utils/stateManagement';
+import { createAsyncTypes } from '../../../utils/createTypes';
+
import { createPoly } from '../utils/polyvinyl';
import challengeModalEpic from './challenge-modal-epic';
import completionEpic from './completion-epic';
@@ -11,11 +13,14 @@ import createQuestionEpic from './create-question-epic';
import codeStorageEpic from './code-storage-epic';
import currentChallengeEpic from './current-challenge-epic';
+import { createIdToNameMapSaga } from './id-to-name-map-saga';
+
const ns = 'challenge';
export const backendNS = 'backendChallenge';
const initialState = {
challengeFiles: {},
+ challengeIdToNameMap: {},
challengeMeta: {
id: '',
nextChallengePath: '/'
@@ -34,16 +39,6 @@ const initialState = {
successMessage: 'Happy Coding!'
};
-export const epics = [
- challengeModalEpic,
- codeLockEpic,
- completionEpic,
- createQuestionEpic,
- executeChallengeEpic,
- codeStorageEpic,
- currentChallengeEpic
-];
-
export const types = createTypes(
[
'createFiles',
@@ -76,11 +71,25 @@ export const types = createTypes(
'executeChallenge',
'resetChallenge',
'submitChallenge',
- 'submitComplete'
+ 'submitComplete',
+
+ ...createAsyncTypes('fetchIdToNameMap')
],
ns
);
+export const epics = [
+ challengeModalEpic,
+ codeLockEpic,
+ completionEpic,
+ createQuestionEpic,
+ executeChallengeEpic,
+ codeStorageEpic,
+ currentChallengeEpic
+];
+
+export const sagas = [...createIdToNameMapSaga(types)];
+
export const createFiles = createAction(types.createFiles, challengeFiles =>
Object.keys(challengeFiles)
.filter(key => challengeFiles[key])
@@ -96,6 +105,13 @@ export const createFiles = createAction(types.createFiles, challengeFiles =>
{}
)
);
+
+export const fetchIdToNameMap = createAction(types.fetchIdToNameMap);
+export const fetchIdToNameMapComplete = createAction(
+ types.fetchIdToNameMapComplete
+);
+export const fetchIdToNameMapError = createAction(types.fetchIdToNameMapError);
+
export const createQuestion = createAction(types.createQuestion);
export const initTests = createAction(types.initTests);
export const updateTests = createAction(types.updateTests);
@@ -131,6 +147,8 @@ export const submitChallenge = createAction(types.submitChallenge);
export const submitComplete = createAction(types.submitComplete);
export const challengeFilesSelector = state => state[ns].challengeFiles;
+export const challengeIdToNameMapSelector = state =>
+ state[ns].challengeIdToNameMap;
export const challengeMetaSelector = state => state[ns].challengeMeta;
export const challengeTestsSelector = state => state[ns].challengeTests;
export const consoleOutputSelector = state => state[ns].consoleOut;
@@ -149,6 +167,10 @@ export const projectFormValuesSelector = state =>
export const reducer = handleActions(
{
+ [types.fetchIdToNameMapComplete]: (state, { payload }) => ({
+ ...state,
+ challengeIdToNameMap: payload
+ }),
[types.createFiles]: (state, { payload }) => ({
...state,
challengeFiles: payload
diff --git a/client/src/utils/ajax.js b/client/src/utils/ajax.js
index 9ae3c5efe3..a5b71ca95d 100644
--- a/client/src/utils/ajax.js
+++ b/client/src/utils/ajax.js
@@ -24,6 +24,14 @@ export function getSessionUser() {
return get('/user/get-session-user');
}
+export function getIdToNameMap() {
+ return get('/api/challenges/get-id-to-name');
+}
+
+export function getUserProfile(username) {
+ return get(`/api/users/get-public-profile?username=${username}`);
+}
+
export function getShowCert(username, cert) {
return get(`/certificate/showCert/${username}/${cert}`);
}
diff --git a/client/static/css/cal-heatmap.css b/client/static/css/cal-heatmap.css
new file mode 100644
index 0000000000..4faa770dca
--- /dev/null
+++ b/client/static/css/cal-heatmap.css
@@ -0,0 +1,133 @@
+/* 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;
+}