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:
Stuart Taylor
2018-02-19 20:32:14 +00:00
committed by Quincy Larson
parent 24ef69cf7a
commit 3131c55782
41 changed files with 1368 additions and 242 deletions

View File

@ -16,6 +16,7 @@ import Toasts from './Toasts';
import NotFound from './NotFound';
import { mainRouteSelector } from './routes/redux';
import Challenges from './routes/Challenges';
import Profile from './routes/Profile';
import Settings from './routes/Settings';
const mapDispatchToProps = {
@ -44,6 +45,7 @@ const propTypes = {
const routes = {
challenges: Challenges,
profile: Profile,
settings: Settings
};

View File

@ -5,7 +5,6 @@ import { connect } from 'react-redux';
import capitalize from 'lodash/capitalize';
import { createSelector } from 'reselect';
import FCCSearchBar from 'react-freecodecamp-search';
import {
MenuItem,
Nav,
@ -15,6 +14,7 @@ import {
NavbarBrand
} from 'react-bootstrap';
import NoPropsPassThrough from '../utils/No-Props-Passthrough.jsx';
import { Link } from '../Router';
import navLinks from './links.json';
import SignUp from './Sign-Up.jsx';
@ -30,6 +30,7 @@ import {
} from './redux';
import { isSignedInSelector, signInLoadingSelector } from '../redux';
import { panesSelector } from '../Panes/redux';
import { onRouteCurrentChallenge } from '../routes/Challenges/redux';
const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg';
@ -224,11 +225,16 @@ export class FCCNav extends React.Component {
))
}
{ shouldShowMapButton ?
<BinButton
content='Map'
handleClick={ clickOnMap }
key='Map'
/> :
<NoPropsPassThrough>
<li>
<Link
onClick={ clickOnMap }
to={ onRouteCurrentChallenge() }
>
Map
</Link>
</li>
</NoPropsPassThrough> :
null
}
{

View File

@ -151,7 +151,17 @@ export const isChallengeLoaded = (state, { dashedName }) =>
export default composeReducers(
ns,
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 {
...state,
...action.meta.entities
@ -159,7 +169,7 @@ export default composeReducers(
}
return state;
},
function(state = defaultState, action) {
function entitiesReducer(state = defaultState, action) {
if (getEntityAction(action)) {
const { payload: { username, theme } } = getEntityAction(action);
return {

View File

@ -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 {
types,
fetchUserComplete,
fetchOtherUserComplete,
createErrorObservable,
showSignIn
} from './';
import { userFound } from '../routes/Profile/redux';
export default function getUserEpic(actions, _, { services }) {
function getUserEpic(actions, _, { services }) {
return actions::ofType('' + types.fetchUser)
.flatMap(() => {
return services.readService$({ service: 'user' })
@ -17,3 +22,21 @@ export default function getUserEpic(actions, _, { services }) {
.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
);

View File

@ -17,6 +17,7 @@ import nightModeEpic from './night-mode-epic.js';
import { createFilesMetaCreator } from '../files';
import { updateThemeMetacreator, entitiesSelector } from '../entities';
import { utils } from '../Flash/redux';
import { paramsSelector } from '../Router/redux';
import { types as challenges } from '../routes/Challenges/redux';
import { challengeToFiles } from '../routes/Challenges/utils';
@ -41,6 +42,7 @@ export const types = createTypes([
createAsyncTypes('fetchChallenge'),
createAsyncTypes('fetchChallenges'),
'updateChallenges',
createAsyncTypes('fetchOtherUser'),
createAsyncTypes('fetchUser'),
'showSignIn',
@ -109,9 +111,20 @@ export const fetchChallengesCompleted = createAction(
entities => ({ entities })
);
export const updateChallenges = createAction(types.updateChallenges);
// updateTitle(title: String) => Action
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
// used in combination with fetch-user-epic
export const fetchUser = createAction(types.fetchUser);
@ -190,6 +203,12 @@ export const userSelector = createSelector(
(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(
userSelector,
user => user.theme || themes.default

View 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);

View 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);

View 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);

View 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);

View 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);

View File

@ -8,7 +8,7 @@ import {
} from 'react-bootstrap';
import FontAwesome from 'react-fontawesome';
import { userSelector } from '../../../redux';
import { userByNameSelector } from '../../../redux';
const propTypes = {
email: PropTypes.string,
@ -18,12 +18,13 @@ const propTypes = {
isTwitter: PropTypes.bool,
isWebsite: PropTypes.bool,
linkedIn: PropTypes.string,
show: PropTypes.bool,
twitter: PropTypes.string,
website: PropTypes.string
};
const mapStateToProps = createSelector(
userSelector,
userByNameSelector,
({
githubURL,
isLinkedIn,
@ -40,6 +41,7 @@ const mapStateToProps = createSelector(
isTwitter,
isWebsite,
linkedIn,
show: (isLinkedIn || isGithub || isTwitter || isWebsite),
twitter,
website
})
@ -101,9 +103,13 @@ function SocialIcons(props) {
isTwitter,
isWebsite,
linkedIn,
show,
twitter,
website
} = props;
if (!show) {
return null;
}
return (
<Row>
<Col

View 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&nbsp;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.&nbsp;
<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);

View File

@ -0,0 +1,7 @@
import { types } from './redux';
export { default } from './Profile.jsx';
export const routes = {
[types.onRouteProfile]: '/:username'
};

View File

@ -0,0 +1 @@
"profile"

View 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;
}
}
}

View 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
);

View File

@ -8,6 +8,7 @@ import FA from 'react-fontawesome';
import ns from './ns.json';
import { FullWidthRow, Spacer, Loader } from '../../helperComponents';
import { Link } from '../../Router';
import AboutSettings from './components/About-Settings.jsx';
import InternetSettings from './components/Internet-Settings.jsx';
import EmailSettings from './components/Email-Settings.jsx';
@ -70,6 +71,7 @@ export class Settings extends React.Component {
componentWillMount() {
this.props.updateTitle('Settings');
}
componentWillReceiveProps({ username, showLoading, hardGoTo }) {
if (!username && !showLoading) {
hardGoTo('/signup');
@ -87,16 +89,17 @@ export class Settings extends React.Component {
return (
<div className={ `${ns}-container` }>
<FullWidthRow>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
className='btn-link-social'
href={ `/${username}` }
>
<FA name='user' />
Show me my public profile
</Button>
<Link to={ `/${username}` }>
<Button
block={ true }
bsSize='lg'
bsStyle='primary'
className='btn-link-social'
>
<FA name='user' />
Show me my public profile
</Button>
</Link>
<Button
block={ true }
bsSize='lg'

View File

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Col, Row } from 'react-bootstrap';
import SocialIcons from './SocialIcons.jsx';
import SocialIcons from '../../Profile/components/SocialIcons.jsx';
const propTypes = {
about: PropTypes.string,
@ -37,9 +37,7 @@ function Camper({
/>
</Col>
</Row>
<br />
<SocialIcons />
<br/>
<h2 className='text-center username'>@{ username }</h2>
{ name && <p className='text-center name'>{ name }</p> }
{ location && <p className='text-center location'>{ location }</p> }

View File

@ -24,6 +24,11 @@
color: #333;
}
a:hover {
text-decoration: none;
text-decoration-line: none;
}
.panel {
background-color: #fff;
}

View File

@ -1,6 +1,7 @@
import { routes as challengesRoutes } from './Challenges';
import { routes as mapRoutes } from './Map';
import { routes as settingsRoutes } from './Settings';
import { routes as profileRoutes } from './Profile';
// import { addLang } from '../utils/lang';
@ -9,7 +10,9 @@ export { createPanesMap } from './Challenges';
export default {
...challengesRoutes,
...mapRoutes,
...settingsRoutes
...settingsRoutes,
// ensure profile routes are last else they hijack other routes
...profileRoutes
};
// export default function createChildRoute(deps) {

View File

@ -1,2 +1,3 @@
&{ @import "./Challenges/challenges.less"; }
&{ @import "./Profile/profile.less"; }
&{ @import "./Settings/index.less"; }

View File

@ -3,8 +3,10 @@ import { combineReducers } from 'berkeleys-redux-utils';
import challengeReducer from './Challenges/redux';
import profileReducer from './Profile/redux';
import settingsReducer from './Settings/redux';
import { routes as challengeRoutes } from './Challenges';
import { routes as profileRoutes } from './Profile';
import { routes as settingsRoutes } from './Settings';
const ns = 'mainRouter';
@ -22,6 +24,9 @@ export function mainRouter(state = 'NotFound', action) {
if (settingsRoutes[type]) {
return 'settings';
}
if (profileRoutes[type]) {
return 'profile';
}
return '';
}
@ -29,6 +34,7 @@ mainRouter.toString = () => ns;
export default combineReducers(
challengeReducer,
profileReducer,
settingsReducer,
mainRouter
);

View File

@ -17,6 +17,11 @@ import {
getServerFullURL,
getEmailSender
} from '../../server/utils/url-utils.js';
import {
normaliseUserFields,
getProgress,
publicUserProps
} from '../../server/utils/publicUserProps';
const debug = debugFactory('fcc:models:user');
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 =
function giveBrowniePoints(receiver, giver, data = {}, dev = false, cb) {
const findUser = observeMethod(User, 'findOne');

View File

@ -348,6 +348,13 @@
"permission": "ALLOW",
"property": "about"
},
{
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "getPublicProfile"
},
{
"accessType": "EXECUTE",
"principalType": "ROLE",

9
package-lock.json generated
View File

@ -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": {
"version": "15.6.2",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-15.6.2.tgz",

View File

@ -113,6 +113,7 @@
"react-addons-shallow-compare": "~15.4.2",
"react-bootstrap": "~0.31.2",
"react-codemirror": "^0.3.0",
"react-d3": "^0.4.0",
"react-dom": "^15.6.2",
"react-fontawesome": "^1.2.0",
"react-freecodecamp-search": "^1.4.1",

145
public/css/cal-heatmap.css Normal file
View 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;
}

View File

@ -21,7 +21,8 @@ const routes = [
'/challenges/*',
'/map',
'/settings',
'/settings/*'
'/settings/*',
'/:username'
];
const devRoutes = [];

View File

@ -1,9 +1,7 @@
import dedent from 'dedent';
import moment from 'moment-timezone';
import { Observable } from 'rx';
import debugFactory from 'debug';
// import { curry } from 'lodash';
import emoji from 'node-emoji';
import { curry } from 'lodash';
import {
frontEndChallengeId,
@ -24,18 +22,10 @@ import {
ifNotVerifiedRedirectToSettings
} from '../utils/middleware';
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 sendNonUserToMap = ifNoUserRedirectTo('/map');
// const sendNonUserToMapWithMessage = curry(ifNoUserRedirectTo, 2)('/map');
const sendNonUserToMapWithMessage = curry(ifNoUserRedirectTo, 2)('/map');
const certIds = {
[certTypes.frontEnd]: frontEndChallengeId,
[certTypes.backEnd]: backEndChallengeId,
@ -77,84 +67,10 @@ const certText = {
[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) {
const router = app.loopback.Router();
const api = app.loopback.Router();
const { Email, User } = app.models;
const map$ = cachedMap(app.models);
function findUserByUsername$(username, fields) {
return observeQuery(
@ -194,16 +110,15 @@ module.exports = function(app) {
showCert
);
router.get('/:username', showUserProfile);
router.get(
'/:username/report-user/',
sendNonUserToMap,
'/user/:username/report-user/',
sendNonUserToMapWithMessage('You must be signed in to report a user'),
ifNotVerifiedRedirectToSettings,
getReportUserProfile
);
api.post(
'/:username/report-user/',
'/user/:username/report-user/',
ifNoUser401,
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 youre 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) {
let { username, cert } = req.params;
username = username.toLowerCase();

View File

@ -2,21 +2,40 @@ import { Observable } from 'rx';
import _ from 'lodash';
import {
userPropsForSession,
normaliseUserFields
getProgress,
normaliseUserFields,
userPropsForSession
} from '../utils/publicUserProps';
export default function userServices() {
return {
name: 'user',
read: (req, resource, params, config, cb) => {
const { user } = req;
read: function readUserService(
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(
() => !user,
() => !queryUser,
Observable.of({}),
Observable.defer(() => user.getChallengeMap$())
.map(challengeMap => ({ ...user.toJSON(), challengeMap }))
.map(user => ({
Observable.defer(() => source)
.map(({ challengeMap, progress }) => ({
...queryUser.toJSON(),
...progress,
challengeMap
}))
.map(
user => ({
entities: {
user: {
[user.username]: {

View File

@ -1,6 +1,6 @@
import { isURL } from 'validator';
import { addPlaceholderImage } from '../utils';
import { addPlaceholderImage } from './';
import {
prepUniqueDaysByHours,
calcCurrentStreak,

View File

@ -29,4 +29,4 @@ include styles
strong Quincy Larson
p Executive Director, freeCodeCamp.org
.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

View File

@ -29,4 +29,4 @@ include styles
strong Quincy Larson
p Executive Director, freeCodeCamp.org
.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

View File

@ -29,4 +29,4 @@ include styles
strong Quincy Larson
p Executive Director, freeCodeCamp.org
.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

View File

@ -29,4 +29,4 @@ include styles
strong Quincy Larson
p Executive Director, freeCodeCamp.org
.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

View File

@ -29,4 +29,4 @@ include styles
strong Quincy Larson
p Executive Director, freeCodeCamp.org
.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

View File

@ -29,4 +29,4 @@ include styles
strong Quincy Larson
p Executive Director, freeCodeCamp.org
.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

View File

@ -29,4 +29,4 @@ include styles
strong Quincy Larson
p Executive Director, freeCodeCamp.org
.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

View File

@ -29,4 +29,4 @@ include styles
strong Quincy Larson
p Executive Director, freeCodeCamp.org
.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

View File

@ -29,4 +29,4 @@ include styles
strong Quincy Larson
p Executive Director, freeCodeCamp.org
.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

View File

@ -29,4 +29,4 @@ include styles
strong Quincy Larson
p Executive Director, freeCodeCamp.org
.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