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 { 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
|
||||
};
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
{
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -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
|
||||
|
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';
|
||||
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
|
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 { 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'
|
||||
|
@ -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> }
|
||||
|
@ -24,6 +24,11 @@
|
||||
color: #333;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
text-decoration-line: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -1,2 +1,3 @@
|
||||
&{ @import "./Challenges/challenges.less"; }
|
||||
&{ @import "./Profile/profile.less"; }
|
||||
&{ @import "./Settings/index.less"; }
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -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');
|
||||
|
@ -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
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": {
|
||||
"version": "15.6.2",
|
||||
"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-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
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/*',
|
||||
'/map',
|
||||
'/settings',
|
||||
'/settings/*'
|
||||
'/settings/*',
|
||||
'/:username'
|
||||
];
|
||||
|
||||
const devRoutes = [];
|
||||
|
@ -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 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) {
|
||||
let { username, cert } = req.params;
|
||||
username = username.toLowerCase();
|
||||
|
@ -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]: {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { isURL } from 'validator';
|
||||
|
||||
import { addPlaceholderImage } from '../utils';
|
||||
import { addPlaceholderImage } from './';
|
||||
import {
|
||||
prepUniqueDaysByHours,
|
||||
calcCurrentStreak,
|
||||
|
@ -21,7 +21,7 @@ include styles
|
||||
h1
|
||||
strong Advanced Frontend Projects
|
||||
h4 1 of 3 legacy freeCodeCamp certificates, representing approximately 400 hours of coursework
|
||||
|
||||
|
||||
footer
|
||||
.row.signatures
|
||||
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
|
||||
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
|
||||
|
@ -21,7 +21,7 @@ include styles
|
||||
h1
|
||||
strong APIs and Microservices Projects
|
||||
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
||||
|
||||
|
||||
footer
|
||||
.row.signatures
|
||||
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
|
||||
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
|
||||
|
@ -21,7 +21,7 @@ include styles
|
||||
h1
|
||||
strong Back End Development Projects
|
||||
h4 1 of 3 legacy freeCodeCamp certificates, representing approximately 400 hours of coursework
|
||||
|
||||
|
||||
footer
|
||||
.row.signatures
|
||||
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
|
||||
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
|
||||
|
@ -21,7 +21,7 @@ include styles
|
||||
h1
|
||||
strong Data Visualization Projects
|
||||
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
||||
|
||||
|
||||
footer
|
||||
.row.signatures
|
||||
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
|
||||
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
|
||||
|
@ -21,7 +21,7 @@ include styles
|
||||
h1
|
||||
strong Front End Libraries Projects
|
||||
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
||||
|
||||
|
||||
footer
|
||||
.row.signatures
|
||||
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
|
||||
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
|
||||
|
@ -21,7 +21,7 @@ include styles
|
||||
h1
|
||||
strong Front End Development Projects
|
||||
h4 1 of 3 legacy freeCodeCamp certificates, representing approximately 400 hours of coursework
|
||||
|
||||
|
||||
footer
|
||||
.row.signatures
|
||||
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
|
||||
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
|
||||
|
@ -21,7 +21,7 @@ include styles
|
||||
h1
|
||||
strong Full Stack Development Projects
|
||||
h4 1 of 3 legacy freeCodeCamp certificates, representing approximately 400 hours of coursework
|
||||
|
||||
|
||||
footer
|
||||
.row.signatures
|
||||
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
|
||||
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
|
||||
|
@ -21,7 +21,7 @@ include styles
|
||||
h1
|
||||
strong Information Security and Quality Assurance Projects
|
||||
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
||||
|
||||
|
||||
footer
|
||||
.row.signatures
|
||||
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
|
||||
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
|
||||
|
@ -21,7 +21,7 @@ include styles
|
||||
h1
|
||||
strong JavaScript Algorithms and Data Structures Certificate
|
||||
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
||||
|
||||
|
||||
footer
|
||||
.row.signatures
|
||||
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
|
||||
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
|
||||
|
@ -21,7 +21,7 @@ include styles
|
||||
h1
|
||||
strong Responsive Web Design Projects
|
||||
h4 1 of 6 freeCodeCamp certificates, representing approximately 300 hours of coursework
|
||||
|
||||
|
||||
footer
|
||||
.row.signatures
|
||||
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
|
||||
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
|
||||
|
Reference in New Issue
Block a user