diff --git a/api-server/server/boot/news.js b/api-server/server/boot/news.js
index 8ee501075a..b7dc2e88b9 100644
--- a/api-server/server/boot/news.js
+++ b/api-server/server/boot/news.js
@@ -1,214 +1,259 @@
-// import React from 'react';
-// import { renderToString } from 'react-dom/server';
-// // import { StaticRouter } from 'react-router-dom';
-// import { has } from 'lodash';
+import { has, pick, isEmpty } from 'lodash';
import debug from 'debug';
+import { reportError } from '../middlewares/error-reporter';
-// import NewsApp from '../../news/NewsApp';
-
-const routerLog = debug('fcc:boot:news:router');
-const apiLog = debug('fcc:boot:news:api');
+const log = debug('fcc:boot:news');
export default function newsBoot(app) {
- // const router = app.loopback.Router();
- // const api = app.loopback.Router();
+ const api = app.loopback.Router();
- // router.get('/n', (req, res) => res.redirect('/news'));
- // router.get('/n/:shortId', createShortLinkHandler(app));
+ api.get('/n/:shortId', createShortLinkHandler(app));
- // router.get('/news', serveNewsApp);
- // router.get('/news/*', serveNewsApp);
+ api.post('/p', createPopularityHandler(app));
- // api.post('/p', createPopularityHandler(app));
-
- // app.use(api);
- // app.use(router);
+ app.use('/internal', api);
}
-// function serveNewsApp(req, res) {
-// const context = {};
-// const markup = renderToString(
-//
-//
-//
-// );
-// if (context.url) {
-// routerLog('redirect found in `renderToString`');
-// // 'client-side' routing hit on a redirect
-// return res.redirect(context.url);
-// }
-// routerLog('news markup sending');
-// return res.render('layout-news', { title: 'News | freeCodeCamp', markup });
-// }
+function createShortLinkHandler(app) {
+ const { Article } = app.models;
-// function createShortLinkHandler(app) {
-// const { Article } = app.models;
+ const referralHandler = createReferralHandler(app);
-// const referralHandler = createRerralHandler(app);
+ return function shortLinkHandler(req, res, next) {
+ const { query, user } = req;
+ const { shortId } = req.params;
-// return function shortLinkHandler(req, res, next) {
-// const { query, user } = req;
-// const { shortId } = req.params;
+ // We manually report the error here as it should not affect this request
+ referralHandler(query, shortId, !!user).catch(err => reportError(err));
-// referralHandler(query, shortId, !!user);
+ if (!shortId) {
+ return res.sendStatus(400);
+ }
+ return Article.findOne(
+ {
+ where: {
+ or: [{ shortId }, { slugPart: shortId }]
+ }
+ },
+ (err, article) => {
+ if (err) {
+ next(err);
+ }
+ if (!article) {
+ return res.status(404).send('Could not find article by shortId');
+ }
+ const {
+ slugPart,
+ shortId,
+ author: { username }
+ } = article;
+ const slug = `/news/${username}/${slugPart}--${shortId}`;
+ const articleData = {
+ ...pick(article, [
+ 'author',
+ 'renderableContent',
+ 'firstPublishedDate',
+ 'viewCount',
+ 'title',
+ 'featureImage',
+ 'slugPart',
+ 'shortId',
+ 'meta'
+ ]),
+ slug
+ };
+ return res.json(articleData);
+ }
+ );
+ };
+}
-// routerLog(req.origin);
-// routerLog(query.refsource);
-// if (!shortId) {
-// return res.redirect('/news');
-// }
-// routerLog('shortId', shortId);
-// return Article.findOne(
-// {
-// where: {
-// or: [{ shortId }, { slugPart: shortId }]
-// }
-// },
-// (err, article) => {
-// if (err) {
-// next(err);
-// }
-// if (!article) {
-// return res.redirect('/news');
-// }
-// const {
-// slugPart,
-// shortId,
-// author: { username }
-// } = article;
-// const slug = `/news/${username}/${slugPart}--${shortId}`;
-// return res.redirect(slug);
-// }
-// );
-// };
-// }
+function createPopularityHandler(app) {
+ const { Article, Popularity } = app.models;
-// function createPopularityHandler(app) {
-// const { Article, Popularity } = app.models;
+ function findArticleByShortId(shortId) {
+ return new Promise((resolve, reject) =>
+ Article.findOne({ where: { shortId } }, (err, article) => {
+ if (err) {
+ log('Error returned from Article.findOne(shortId)');
+ return reject(err);
+ }
+ log('article found');
+ return resolve(article);
+ })
+ );
+ }
-// return function handlePopularityStats(req, res, next) {
-// const { body, user } = req;
+ function findPopularityByShortId(shortId) {
+ return new Promise((resolve, reject) =>
+ Popularity.findOne(
+ { where: { articleId: shortId } },
+ (err, popularity) => {
+ if (err) {
+ log('Error returned from Popularity.findOne(shortId)');
+ return reject(err);
+ }
+ log('popularity found');
+ return resolve(popularity);
+ }
+ )
+ );
+ }
-// if (
-// !has(body, 'event') ||
-// !has(body, 'timestamp') ||
-// !has(body, 'shortId')
-// ) {
-// console.warn('Popularity event recieved from client is malformed');
-// console.log(JSON.stringify(body, null, 2));
-// // sending 200 because the client shouldn't care for this
-// return res.sendStatus(200);
-// }
-// res.sendStatus(200);
-// const { shortId } = body;
-// apiLog('shortId', shortId);
-// const populartiyUpdate = {
-// ...body,
-// byAuthenticatedUser: !!user
-// };
-// Popularity.findOne({ where: { articleId: shortId } }, (err, popularity) => {
-// if (err) {
-// apiLog(err);
-// return next(err);
-// }
-// if (popularity) {
-// return popularity.updateAttribute(
-// 'events',
-// [populartiyUpdate, ...popularity.events],
-// err => {
-// if (err) {
-// apiLog(err);
-// return next(err);
-// }
-// return apiLog('poplarity updated');
-// }
-// );
-// }
-// return Popularity.create(
-// {
-// events: [populartiyUpdate],
-// articleId: shortId
-// },
-// err => {
-// if (err) {
-// apiLog(err);
-// return next(err);
-// }
-// return apiLog('poulartiy created');
-// }
-// );
-// });
-// return body.event === 'view'
-// ? Article.findOne({ where: { shortId } }, (err, article) => {
-// if (err) {
-// apiLog(err);
-// next(err);
-// }
-// return article.updateAttributes(
-// { viewCount: article.viewCount + 1 },
-// err => {
-// if (err) {
-// apiLog(err);
-// return next(err);
-// }
-// return apiLog('article views updated');
-// }
-// );
-// })
-// : null;
-// };
-// }
+ function createPopularity(popularityUpdate, shortId) {
+ return new Promise((resolve, reject) =>
+ Popularity.create(
+ {
+ events: [popularityUpdate],
+ articleId: shortId
+ },
+ err => {
+ if (err) {
+ return reject(err);
+ }
+ log('poulartiy created');
+ return resolve();
+ }
+ )
+ );
+ }
-// function createRerralHandler(app) {
-// const { Popularity } = app.models;
+ function updatePopularity(popularity, popularityUpdate) {
+ return new Promise((resolve, reject) =>
+ popularity.updateAttribute(
+ 'events',
+ [popularityUpdate, ...popularity.events],
+ err => {
+ if (err) {
+ log('Error returned from popularity.updateAttribute()');
+ return reject(err);
+ }
+ log('poplarity updated');
+ return resolve();
+ }
+ )
+ );
+ }
-// return function referralHandler(query, shortId, byAuthenticatedUser) {
-// if (!query.refsource) {
-// return null;
-// }
-// const eventUpdate = {
-// event: `referral - ${query.refsource}`,
-// timestamp: new Date(Date.now()),
-// byAuthenticatedUser
-// };
-// return Popularity.findOne(
-// { where: { articleId: shortId } },
-// (err, popularity) => {
-// if (err) {
-// console.error(
-// 'Failed finding a `Popularity` in a referral handler',
-// err
-// );
-// return null;
-// }
+ function incrementArticleViews(article) {
+ return new Promise((resolve, reject) =>
+ article.updateAttributes({ $inc: { viewCount: 1 } }, err => {
+ if (err) {
+ log(err);
+ return reject(err);
+ }
+ log('article views updated');
+ return resolve();
+ })
+ );
+ }
-// if (popularity) {
-// return popularity.updateAttribute(
-// 'events',
-// [eventUpdate, ...popularity.events],
-// err => {
-// if (err) {
-// console.error(
-// 'Failed in updating the `events` attribute of a `popularity`',
-// err
-// );
-// }
-// }
-// );
-// }
-// return Popularity.create(
-// {
-// events: [eventUpdate],
-// articleId: shortId
-// },
-// err => {
-// if (err) {
-// return console.error('Failed creating a new `Popularity`', err);
-// }
-// return apiLog('poulartiy created');
-// }
-// );
-// }
-// );
-// };
-// }
+ return async function handlePopularityStats(req, res, next) {
+ const { body, user } = req;
+
+ if (
+ !has(body, 'event') ||
+ !has(body, 'timestamp') ||
+ !has(body, 'shortId')
+ ) {
+ console.warn('Popularity event recieved from client is malformed');
+ console.log(JSON.stringify(body, null, 2));
+ // sending 200 because the client shouldn't care for this
+ return res.sendStatus(200);
+ }
+ const { shortId } = body;
+ log('shortId', shortId);
+
+ const articlePromise = findArticleByShortId(shortId);
+ const popularityPromise = findPopularityByShortId(shortId);
+
+ const [article, popularity] = await Promise.all([
+ articlePromise,
+ popularityPromise
+ ]).catch(err => {
+ log('find catch');
+ return next(err);
+ });
+ if (!article || isEmpty(article)) {
+ log('No article found to handle the populartity update');
+ // sending 200 because the client shouldn't care for this
+ return res.sendStatus(200);
+ }
+
+ const populartiyUpdate = {
+ ...body,
+ byAuthenticatedUser: !!user
+ };
+
+ const populartiyUpdateOrCreatePromise = isEmpty(popularity)
+ ? createPopularity(populartiyUpdate, shortId)
+ : updatePopularity(popularity, populartiyUpdate);
+ const maybeUpdateArticlePromise =
+ body.event === 'view' ? incrementArticleViews(article) : null;
+ return Promise.all([
+ populartiyUpdateOrCreatePromise,
+ maybeUpdateArticlePromise
+ ])
+ .then(() => res.sendStatus(200))
+ .catch(err => {
+ log('updates catch');
+ return next(err);
+ });
+ };
+}
+
+function createReferralHandler(app) {
+ const { Popularity } = app.models;
+
+ return function referralHandler(query, shortId, byAuthenticatedUser) {
+ if (!query.refsource) {
+ return Promise.resolve();
+ }
+ const eventUpdate = {
+ event: `referral - ${query.refsource}`,
+ timestamp: new Date(Date.now()),
+ byAuthenticatedUser
+ };
+ return new Promise((resolve, reject) =>
+ Popularity.findOne(
+ { where: { articleId: shortId } },
+ (err, popularity) => {
+ if (err) {
+ console.error(
+ 'Failed finding a `Popularity` in a referral handler',
+ err
+ );
+ return reject(err);
+ }
+
+ if (popularity) {
+ return popularity.updateAttribute(
+ 'events',
+ [eventUpdate, ...popularity.events],
+ err => {
+ if (err) {
+ return reject(err);
+ }
+ log('populartiy updated');
+ return resolve();
+ }
+ );
+ }
+ return Popularity.create(
+ {
+ events: [eventUpdate],
+ articleId: shortId
+ },
+ err => {
+ if (err) {
+ return reject(err);
+ }
+ log('poulartiy created');
+ return resolve();
+ }
+ );
+ }
+ )
+ );
+ };
+}
diff --git a/api-server/server/datasources.production.js b/api-server/server/datasources.production.js
index b70b97e731..57cf572238 100644
--- a/api-server/server/datasources.production.js
+++ b/api-server/server/datasources.production.js
@@ -5,7 +5,8 @@ module.exports = {
connector: 'mongodb',
connectionTimeout: 10000,
url: secrets.db,
- useNewUrlParser: true
+ useNewUrlParser: true,
+ allowExtendedOperators: true
},
mail: {
connector: 'mail',
diff --git a/api-server/server/middlewares/error-handlers.js b/api-server/server/middlewares/error-handlers.js
index 14df134e18..5f3744869c 100644
--- a/api-server/server/middlewares/error-handlers.js
+++ b/api-server/server/middlewares/error-handlers.js
@@ -8,47 +8,10 @@ import { unwrapHandledError } from '../utils/create-handled-error.js';
const isDev = process.env.NODE_ENV !== 'production';
-// const toString = Object.prototype.toString;
-// is full error or just trace
-// _.toString(new Error('foo')) => "Error: foo
-// Object.prototype.toString.call(new Error('foo')) => "[object Error]"
-// const isInspect = val => !val.stack && _.toString(val) === toString.call(val);
-// const stringifyErr = val => {
-// if (val.stack) {
-// return String(val.stack);
-// }
-
-// const str = String(val);
-
-// return isInspect(val) ?
-// inspect(val) :
-// str;
-// };
-
-// const createStackHtml = _.flow(
-// _.cond([
-// [isInspect, err => [err]],
-// // may be stack or just err.msg
-// [_.stubTrue, _.flow(stringifyErr, _.split('\n'), _.tail) ]
-// ]),
-// _.map(_.escape),
-// _.map(line => `
${line}`),
-// _.join('')
-// );
-
-// const createErrorTitle = _.cond([
-// [
-// _.negate(isInspect),
-// _.flow(stringifyErr, _.split('\n'), _.head, _.defaultTo('Error'))
-// ],
-// [_.stubTrue, _.constant('Error')]
-// ]);
-
export default function prodErrorHandler() {
// error handling in production.
- // disabling eslint due to express parity rules for error handlers
+ // eslint-disable-next-line no-unused-vars
return function(err, req, res, next) {
- // eslint-disable-line
const handled = unwrapHandledError(err);
// respect handled error status
let status = handled.status || err.status || res.statusCode;
diff --git a/api-server/server/middlewares/error-reporter.js b/api-server/server/middlewares/error-reporter.js
index 196b2dbe5d..514d3227c5 100644
--- a/api-server/server/middlewares/error-reporter.js
+++ b/api-server/server/middlewares/error-reporter.js
@@ -5,20 +5,30 @@ import {
unwrapHandledError
} from '../utils/create-handled-error.js';
-const { ROLLBAR_APP_ID } = process.env;
+import { rollbar } from '../../../config/secrets';
-const rollbar = new Rollbar(ROLLBAR_APP_ID);
+const { appId } = rollbar;
+const reporter = new Rollbar(appId);
const log = debug('fcc:middlewares:error-reporter');
-const errTemplate = ({message, ...restError}, req) => `
+const errTemplate = (error, req) => {
+ const { message, stack } = error;
+ return `
Time: ${new Date(Date.now()).toISOString()}
Error: ${message}
Is authenticated user: ${!!req.user}
Route: ${JSON.stringify(req.route, null, 2)}
+Stack: ${stack}
-${JSON.stringify(restError, null, 2)}
+// raw
+${JSON.stringify(error, null, 2)}
`;
+};
+
+export function reportError(err) {
+ return reporter.error(err.message, err);
+}
export default function errrorReporter() {
if (process.env.NODE_ENV !== 'production' && process.env.ERROR_REPORTER) {
@@ -44,6 +54,7 @@ export default function errrorReporter() {
// logging the error provides us with more information,
// i.e isAuthenticatedUser, req.route
console.error(errTemplate(err, req));
- return rollbar.error(err.message, err);
+ reportError(err);
+ return next(err);
};
}
diff --git a/api-server/server/middlewares/jwt-authorization.js b/api-server/server/middlewares/jwt-authorization.js
index 78250a559e..4d7043fd87 100644
--- a/api-server/server/middlewares/jwt-authorization.js
+++ b/api-server/server/middlewares/jwt-authorization.js
@@ -6,9 +6,17 @@ import { homeLocation } from '../../../config/env';
import { wrapHandledError } from '../utils/create-handled-error';
+// We need to tunnel through a proxy path set up within
+// the gatsby app, at this time, that path is /internal
+const whiteListRE = new RegExp([
+ '^/internal/n/',
+ '^/internal/p\??'
+].join('|'));
+
+
export default () => function authorizeByJWT(req, res, next) {
const path = req.path.split('/')[1];
- if (/^external$|^internal$/.test(path)) {
+ if (/^external$|^internal$/.test(path) && !whiteListRE.test(req.path)) {
const cookie = req.signedCookies && req.signedCookies['jwt_access_token'] ||
req.cookie && req.cookie['jwt_access_token'];
if (!cookie) {
diff --git a/client/gatsby-config.js b/client/gatsby-config.js
index d0236b4018..a0c262e5c1 100644
--- a/client/gatsby-config.js
+++ b/client/gatsby-config.js
@@ -32,7 +32,8 @@ module.exports = {
'/certification/*',
'/unsubscribed/*',
'/user/*',
- '/settings/*'
+ '/settings/*',
+ '/n/*'
]
}
},
@@ -45,6 +46,12 @@ module.exports = {
curriculumPath: localeChallengesRootDir
}
},
+ {
+ resolve: 'fcc-source-news',
+ options: {
+ maximumStaticRenderCount: 100
+ }
+ },
{
resolve: 'gatsby-source-filesystem',
options: {
diff --git a/client/gatsby-node.js b/client/gatsby-node.js
index 8dbc1d649e..e3a4f80e66 100644
--- a/client/gatsby-node.js
+++ b/client/gatsby-node.js
@@ -8,8 +8,10 @@ const {
createChallengePages,
createBlockIntroPages,
createSuperBlockIntroPages,
- createGuideArticlePages
+ createGuideArticlePages,
+ createNewsArticle
} = require('./utils/gatsby');
+const { createArticleSlug } = require('./utils/news');
const createByIdentityMap = {
guideMarkdown: createGuideArticlePages,
@@ -35,6 +37,15 @@ exports.onCreateNode = function onCreateNode({ node, actions, getNode }) {
createNodeField({ node, name: 'slug', value: slug });
}
}
+ if (node.internal.type === 'NewsArticleNode') {
+ const {
+ author: { username },
+ slugPart,
+ shortId
+ } = node;
+ const slug = createArticleSlug({ username, shortId, slugPart });
+ createNodeField({ node, name: 'slug', value: slug });
+ }
};
exports.createPages = function createPages({ graphql, actions }) {
@@ -86,6 +97,19 @@ exports.createPages = function createPages({ graphql, actions }) {
}
}
}
+ allNewsArticleNode(
+ sort: { fields: firstPublishedDate, order: DESC }
+ ) {
+ edges {
+ node {
+ id
+ shortId
+ fields {
+ slug
+ }
+ }
+ }
+ }
}
`).then(result => {
if (result.errors) {
@@ -126,6 +150,11 @@ exports.createPages = function createPages({ graphql, actions }) {
return null;
});
+ // Create news article pages
+ result.data.allNewsArticleNode.edges.forEach(
+ createNewsArticle(createPage)
+ );
+
return null;
})
);
@@ -161,7 +190,9 @@ exports.onCreateWebpackConfig = ({ stage, rules, plugins, actions }) => {
HOME_PATH: JSON.stringify(
process.env.HOME_PATH || 'http://localhost:3000'
),
- STRIPE_PUBLIC_KEY: JSON.stringify(process.env.STRIPE_PUBLIC_KEY || '')
+ STRIPE_PUBLIC_KEY: JSON.stringify(process.env.STRIPE_PUBLIC_KEY || ''),
+ ROLLBAR_CLIENT_ID: JSON.stringify(process.env.ROLLBAR_CLIENT_ID || ''),
+ ENVIRONMENT: JSON.stringify(process.env.NODE_ENV || 'development')
}),
new RmServiceWorkerPlugin()
]
diff --git a/client/gatsby-ssr.js b/client/gatsby-ssr.js
index 87be57ed92..fea4c0b15d 100644
--- a/client/gatsby-ssr.js
+++ b/client/gatsby-ssr.js
@@ -1,3 +1,4 @@
+/* global ROLLBAR_CLIENT_ID ENVIRONMENT */
import React from 'react';
import PropTypes from 'prop-types';
import { Provider } from 'react-redux';
@@ -5,14 +6,14 @@ import { Provider } from 'react-redux';
import headComponents from './src/head';
import { createStore } from './src/redux/createStore';
-import GuideNavigationContextProvider from './src/contexts/GuideNavigationContext';
+import GuideNavContextProvider from './src/contexts/GuideNavigationContext';
const store = createStore();
export const wrapRootElement = ({ element }) => {
return (
- {element}
+ {element}
);
};
@@ -23,28 +24,57 @@ wrapRootElement.propTypes = {
export const onRenderBody = ({ setHeadComponents, setPostBodyComponents }) => {
setHeadComponents([...headComponents]);
- setPostBodyComponents([
- ,
- ,
-
+ ) : null,
+ /* eslint-enable max-len */
+ ,
+ ,
+ ,
-
- ]);
+ }}
+ key='gtag-dataLayer'
+ />,
+
+ ].filter(Boolean)
+ );
};
diff --git a/client/package-lock.json b/client/package-lock.json
index 3673bbb66f..41b875522c 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1205,9 +1205,10 @@
}
},
"@sinonjs/commons": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.0.2.tgz",
- "integrity": "sha512-WR3dlgqJP4QNrLC4iXN/5/2WaLQQ0VijOOkmflqFGVJ6wLEpbSjo7c0ZeGIdtY8Crk7xBBp87sM6+Mkerz7alw==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.3.0.tgz",
+ "integrity": "sha512-j4ZwhaHmwsCb4DlDOIWnI5YyKDNMoNThsmwEpfHx6a1EpsGZ9qYLxP++LMlmBRjtGptGHFsGItJ768snllFWpA==",
+ "dev": true,
"requires": {
"type-detect": "4.0.8"
}
@@ -1216,6 +1217,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.0.0.tgz",
"integrity": "sha512-vdjoYLDptCgvtJs57ULshak3iJe4NW3sJ3g36xVDGff5AE8P30S6A093EIEPjdi2noGhfuNOEkbxt3J3awFW1w==",
+ "dev": true,
"requires": {
"@sinonjs/samsam": "2.1.0"
},
@@ -1224,6 +1226,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-2.1.0.tgz",
"integrity": "sha512-5x2kFgJYupaF1ns/RmharQ90lQkd2ELS8A9X0ymkAAdemYHGtI2KiUHG8nX2WU0T1qgnOU5YMqnBM2V7NUanNw==",
+ "dev": true,
"requires": {
"array-from": "^2.1.1"
}
@@ -1231,12 +1234,10 @@
}
},
"@sinonjs/samsam": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-2.1.1.tgz",
- "integrity": "sha512-7oX6PXMulvdN37h88dvlvRyu61GYZau40fL4wEZvPEHvrjpJc3lDv6xDM5n4Z0StufUVB5nDvVZUM+jZHdMOOQ==",
- "requires": {
- "array-from": "^2.1.1"
- }
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-2.1.2.tgz",
+ "integrity": "sha512-ZwTHAlC9akprWDinwEPD4kOuwaYZlyMwVJIANsKNC3QVp0AHB04m7RnB4eqeWfgmxw8MGTzS9uMaw93Z3QcZbw==",
+ "dev": true
},
"@types/configstore": {
"version": "2.1.1",
@@ -1781,7 +1782,8 @@
"array-from": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz",
- "integrity": "sha1-z+nYwmYoudxa7MYqn12PHzUsEZU="
+ "integrity": "sha1-z+nYwmYoudxa7MYqn12PHzUsEZU=",
+ "dev": true
},
"array-includes": {
"version": "3.0.3",
@@ -4731,7 +4733,8 @@
"diff": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz",
- "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA=="
+ "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==",
+ "dev": true
},
"diffie-hellman": {
"version": "5.0.3",
@@ -10758,7 +10761,8 @@
"just-extend": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/just-extend/-/just-extend-3.0.0.tgz",
- "integrity": "sha512-Fu3T6pKBuxjWT/p4DkqGHFRsysc8OauWr4ZRTY9dIx07Y9O0RkoR5jcv28aeD1vuAwhm3nLkDurwLXoALp4DpQ=="
+ "integrity": "sha512-Fu3T6pKBuxjWT/p4DkqGHFRsysc8OauWr4ZRTY9dIx07Y9O0RkoR5jcv28aeD1vuAwhm3nLkDurwLXoALp4DpQ==",
+ "dev": true
},
"kebab-case": {
"version": "1.0.0",
@@ -11017,7 +11021,8 @@
"lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
- "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
+ "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=",
+ "dev": true
},
"lodash.isequal": {
"version": "4.5.0",
@@ -11143,7 +11148,8 @@
"lolex": {
"version": "2.7.5",
"resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.5.tgz",
- "integrity": "sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q=="
+ "integrity": "sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q==",
+ "dev": true
},
"longest-streak": {
"version": "2.0.2",
@@ -11779,9 +11785,10 @@
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="
},
"nise": {
- "version": "1.4.5",
- "resolved": "https://registry.npmjs.org/nise/-/nise-1.4.5.tgz",
- "integrity": "sha512-OHRVvdxKgwZELf2DTgsJEIA4MOq8XWvpSUzoOXyxJ2mY0mMENWC66+70AShLR2z05B1dzrzWlUQJmJERlOUpZw==",
+ "version": "1.4.6",
+ "resolved": "https://registry.npmjs.org/nise/-/nise-1.4.6.tgz",
+ "integrity": "sha512-1GedetLKzmqmgwabuMSqPsT7oumdR77SBpDfNNJhADRIeA3LN/2RVqR4fFqwvzhAqcTef6PPCzQwITE/YQ8S8A==",
+ "dev": true,
"requires": {
"@sinonjs/formatio": "3.0.0",
"just-extend": "^3.0.0",
@@ -11793,12 +11800,14 @@
"isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
- "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
+ "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=",
+ "dev": true
},
"path-to-regexp": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz",
"integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=",
+ "dev": true,
"requires": {
"isarray": "0.0.1"
}
@@ -14775,6 +14784,23 @@
}
}
},
+ "react-youtube": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/react-youtube/-/react-youtube-7.8.0.tgz",
+ "integrity": "sha512-kQFR0XTpgGRtzJ54HKDaCtAGr34mgB/BVFeCAL0WUjpIKZBcWtFrKJhYkoKfvWK7aTzJuQ57xojTjG7V6JzILA==",
+ "requires": {
+ "fast-deep-equal": "^2.0.1",
+ "prop-types": "^15.5.3",
+ "youtube-player": "^5.5.1"
+ },
+ "dependencies": {
+ "fast-deep-equal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
+ "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk="
+ }
+ }
+ },
"read": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
@@ -15923,21 +15949,27 @@
}
},
"sinon": {
- "version": "6.3.4",
- "resolved": "https://registry.npmjs.org/sinon/-/sinon-6.3.4.tgz",
- "integrity": "sha512-NIaR56Z1mefuRBXYrf4otqBxkWiKveX+fvqs3HzFq2b07HcgpkMgIwmQM/owNjNFAHkx0kJXW+Q0mDthiuslXw==",
+ "version": "6.3.5",
+ "resolved": "https://registry.npmjs.org/sinon/-/sinon-6.3.5.tgz",
+ "integrity": "sha512-xgoZ2gKjyVRcF08RrIQc+srnSyY1JDJtxu3Nsz07j1ffjgXoY6uPLf/qja6nDBZgzYYEovVkFryw2+KiZz11xQ==",
+ "dev": true,
"requires": {
"@sinonjs/commons": "^1.0.2",
"@sinonjs/formatio": "^3.0.0",
- "@sinonjs/samsam": "^2.1.1",
+ "@sinonjs/samsam": "^2.1.2",
"diff": "^3.5.0",
"lodash.get": "^4.4.2",
- "lolex": "^2.7.4",
+ "lolex": "^2.7.5",
"nise": "^1.4.5",
"supports-color": "^5.5.0",
"type-detect": "^4.0.8"
}
},
+ "sister": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/sister/-/sister-3.0.1.tgz",
+ "integrity": "sha512-aG41gNRHRRxPq52MpX4vtm9tapnr6ENmHUx8LMAJWCOplEMwXzh/dp5WIo52Wl8Zlc/VUyHLJ2snX0ck+Nma9g=="
+ },
"sisteransi": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-0.1.1.tgz",
@@ -17150,7 +17182,8 @@
"text-encoding": {
"version": "0.6.4",
"resolved": "http://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz",
- "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk="
+ "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=",
+ "dev": true
},
"text-table": {
"version": "0.2.0",
@@ -18953,6 +18986,26 @@
"resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz",
"integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk="
},
+ "youtube-player": {
+ "version": "5.5.1",
+ "resolved": "https://registry.npmjs.org/youtube-player/-/youtube-player-5.5.1.tgz",
+ "integrity": "sha512-1d62W9She0B1uKNyY6K7jtWFbOW3dYsm6hyKzrh11BLOuYFzkt8K6AcQ3QdPF3aU47dzhZ82clzOJVVWus4HTw==",
+ "requires": {
+ "debug": "^2.6.6",
+ "load-script": "^1.0.0",
+ "sister": "^3.0.0"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ }
+ }
+ },
"yurnalist": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/yurnalist/-/yurnalist-0.2.1.tgz",
diff --git a/client/package.json b/client/package.json
index 9ed46318a1..d7f9575841 100644
--- a/client/package.json
+++ b/client/package.json
@@ -50,6 +50,7 @@
"react-reflex": "^2.2.9",
"react-spinkit": "^3.0.0",
"react-stripe-elements": "^2.0.1",
+ "react-youtube": "^7.8.0",
"redux": "^4.0.0",
"redux-actions": "^2.6.1",
"redux-devtools-extension": "^2.13.5",
@@ -58,7 +59,6 @@
"redux-saga": "^0.16.0",
"reselect": "^3.0.1",
"rxjs": "^6.3.3",
- "sinon": "^6.3.4",
"store": "^2.0.12",
"validator": "^10.7.0",
"webpack-remove-serviceworker-plugin": "^1.0.0"
@@ -88,6 +88,7 @@
"prettier": "^1.14.2",
"prettier-eslint-cli": "^4.7.1",
"react-test-renderer": "^16.5.2",
+ "sinon": "^6.3.5",
"webpack-cli": "^3.1.1"
},
"repository": {
diff --git a/client/plugins/fcc-create-nav-data/package-lock.json b/client/plugins/fcc-create-nav-data/package-lock.json
new file mode 100644
index 0000000000..1b5f44d5dd
--- /dev/null
+++ b/client/plugins/fcc-create-nav-data/package-lock.json
@@ -0,0 +1,218 @@
+{
+ "name": "fcc-create-nav-data",
+ "version": "0.0.1",
+ "lockfileVersion": 1,
+ "requires": true,
+ "dependencies": {
+ "assertion-error": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="
+ },
+ "balanced-match": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+ "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
+ },
+ "brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "requires": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "browser-stdout": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
+ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw=="
+ },
+ "chai": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-4.2.0.tgz",
+ "integrity": "sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw==",
+ "requires": {
+ "assertion-error": "^1.1.0",
+ "check-error": "^1.0.2",
+ "deep-eql": "^3.0.1",
+ "get-func-name": "^2.0.0",
+ "pathval": "^1.1.0",
+ "type-detect": "^4.0.5"
+ }
+ },
+ "check-error": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz",
+ "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII="
+ },
+ "commander": {
+ "version": "2.15.1",
+ "resolved": "http://registry.npmjs.org/commander/-/commander-2.15.1.tgz",
+ "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag=="
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+ },
+ "debug": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+ "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "deep-eql": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz",
+ "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==",
+ "requires": {
+ "type-detect": "^4.0.0"
+ }
+ },
+ "diff": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz",
+ "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA=="
+ },
+ "escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
+ },
+ "fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+ },
+ "get-func-name": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz",
+ "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE="
+ },
+ "glob": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz",
+ "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
+ "requires": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.0.4",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ }
+ },
+ "growl": {
+ "version": "1.10.5",
+ "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz",
+ "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA=="
+ },
+ "has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
+ },
+ "he": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz",
+ "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0="
+ },
+ "inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+ "requires": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "inherits": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+ "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
+ },
+ "minimatch": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+ "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+ "requires": {
+ "brace-expansion": "^1.1.7"
+ }
+ },
+ "minimist": {
+ "version": "0.0.8",
+ "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
+ "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
+ },
+ "mkdirp": {
+ "version": "0.5.1",
+ "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+ "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
+ "requires": {
+ "minimist": "0.0.8"
+ }
+ },
+ "mocha": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz",
+ "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==",
+ "requires": {
+ "browser-stdout": "1.3.1",
+ "commander": "2.15.1",
+ "debug": "3.1.0",
+ "diff": "3.5.0",
+ "escape-string-regexp": "1.0.5",
+ "glob": "7.1.2",
+ "growl": "1.10.5",
+ "he": "1.1.1",
+ "minimatch": "3.0.4",
+ "mkdirp": "0.5.1",
+ "supports-color": "5.4.0"
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+ },
+ "once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+ "requires": {
+ "wrappy": "1"
+ }
+ },
+ "path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
+ },
+ "pathval": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz",
+ "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA="
+ },
+ "supports-color": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz",
+ "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==",
+ "requires": {
+ "has-flag": "^3.0.0"
+ }
+ },
+ "type-detect": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="
+ },
+ "wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+ }
+ }
+}
diff --git a/client/plugins/fcc-create-nav-data/yarn.lock b/client/plugins/fcc-create-nav-data/yarn.lock
deleted file mode 100644
index b19c19f2ac..0000000000
--- a/client/plugins/fcc-create-nav-data/yarn.lock
+++ /dev/null
@@ -1,170 +0,0 @@
-# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
-# yarn lockfile v1
-
-
-assertion-error@^1.0.1:
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
-
-balanced-match@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
-
-brace-expansion@^1.1.7:
- version "1.1.11"
- resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
- dependencies:
- balanced-match "^1.0.0"
- concat-map "0.0.1"
-
-browser-stdout@1.3.1:
- version "1.3.1"
- resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60"
-
-chai@^4.1.2:
- version "4.1.2"
- resolved "https://registry.yarnpkg.com/chai/-/chai-4.1.2.tgz#0f64584ba642f0f2ace2806279f4f06ca23ad73c"
- dependencies:
- assertion-error "^1.0.1"
- check-error "^1.0.1"
- deep-eql "^3.0.0"
- get-func-name "^2.0.0"
- pathval "^1.0.0"
- type-detect "^4.0.0"
-
-check-error@^1.0.1:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
-
-commander@2.11.0:
- version "2.11.0"
- resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563"
-
-concat-map@0.0.1:
- version "0.0.1"
- resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
-
-debug@3.1.0:
- version "3.1.0"
- resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
- dependencies:
- ms "2.0.0"
-
-deep-eql@^3.0.0:
- version "3.0.1"
- resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
- dependencies:
- type-detect "^4.0.0"
-
-diff@3.5.0:
- version "3.5.0"
- resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
-
-escape-string-regexp@1.0.5:
- version "1.0.5"
- resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
-
-fs.realpath@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
-
-get-func-name@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
-
-glob@7.1.2:
- version "7.1.2"
- resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15"
- dependencies:
- fs.realpath "^1.0.0"
- inflight "^1.0.4"
- inherits "2"
- minimatch "^3.0.4"
- once "^1.3.0"
- path-is-absolute "^1.0.0"
-
-growl@1.10.3:
- version "1.10.3"
- resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.3.tgz#1926ba90cf3edfe2adb4927f5880bc22c66c790f"
-
-has-flag@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51"
-
-he@1.1.1:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
-
-inflight@^1.0.4:
- version "1.0.6"
- resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
- dependencies:
- once "^1.3.0"
- wrappy "1"
-
-inherits@2:
- version "2.0.3"
- resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
-
-minimatch@^3.0.4:
- version "3.0.4"
- resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
- dependencies:
- brace-expansion "^1.1.7"
-
-minimist@0.0.8:
- version "0.0.8"
- resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
-
-mkdirp@0.5.1:
- version "0.5.1"
- resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
- dependencies:
- minimist "0.0.8"
-
-mocha@^5.0.5:
- version "5.0.5"
- resolved "https://registry.yarnpkg.com/mocha/-/mocha-5.0.5.tgz#e228e3386b9387a4710007a641f127b00be44b52"
- dependencies:
- browser-stdout "1.3.1"
- commander "2.11.0"
- debug "3.1.0"
- diff "3.5.0"
- escape-string-regexp "1.0.5"
- glob "7.1.2"
- growl "1.10.3"
- he "1.1.1"
- mkdirp "0.5.1"
- supports-color "4.4.0"
-
-ms@2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
-
-once@^1.3.0:
- version "1.4.0"
- resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
- dependencies:
- wrappy "1"
-
-path-is-absolute@^1.0.0:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
-
-pathval@^1.0.0:
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0"
-
-supports-color@4.4.0:
- version "4.4.0"
- resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.4.0.tgz#883f7ddabc165142b2a61427f3352ded195d1a3e"
- dependencies:
- has-flag "^2.0.0"
-
-type-detect@^4.0.0:
- version "4.0.8"
- resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
-
-wrappy@1:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
diff --git a/client/plugins/fcc-source-news/create-news-node.js b/client/plugins/fcc-source-news/create-news-node.js
new file mode 100644
index 0000000000..c360e8017c
--- /dev/null
+++ b/client/plugins/fcc-source-news/create-news-node.js
@@ -0,0 +1,26 @@
+const crypto = require('crypto');
+
+function createNewsNode(article) {
+ const contentDigest = crypto
+ .createHash('md5')
+ .update(JSON.stringify(article))
+ .digest('hex');
+
+ const internal = {
+ contentDigest,
+ type: 'NewsArticleNode'
+ };
+
+ return JSON.parse(
+ JSON.stringify({
+ ...article,
+ id: article._id + ' >>>>>>> ' + internal.type,
+ children: [],
+ parent: null,
+ internal,
+ sourceInstanceName: 'article'
+ })
+ );
+}
+
+exports.createNewsNode = createNewsNode;
diff --git a/client/plugins/fcc-source-news/gatsby-node.js b/client/plugins/fcc-source-news/gatsby-node.js
new file mode 100644
index 0000000000..961414727c
--- /dev/null
+++ b/client/plugins/fcc-source-news/gatsby-node.js
@@ -0,0 +1,55 @@
+const path = require('path');
+require('dotenv').config({ path: path.resolve(__dirname, '../../../.env') });
+const { MongoClient } = require('mongodb');
+
+const { createNewsNode } = require('./create-news-node');
+const { db } = require('../../../config/secrets');
+
+exports.sourceNodes = function sourceChallengesSourceNodes(
+ { actions, reporter },
+ pluginOptions
+) {
+ function handleError(err, client, reject) {
+ if (err) {
+ if (client) {
+ client.close();
+ }
+ reject(err);
+ reporter.panic(err);
+ }
+ }
+ const { maximumStaticRenderCount = 100 } = pluginOptions;
+ const { createNode } = actions;
+
+ return new Promise((resolve, reject) =>
+ MongoClient.connect(
+ db,
+ { useNewUrlParser: true },
+ async function(err, client) {
+ handleError(err, client, reject);
+
+ reporter.info('fcc-source-news connected successfully to mongo');
+ const db = client.db('freecodecamp');
+ const articleCollection = db.collection('article');
+
+ articleCollection
+ .aggregate([
+ { $match: { featured: true } },
+ { $sort: { firstPublishedDate: -1 } },
+ { $limit: maximumStaticRenderCount }
+ ])
+ .toArray((err, articles) => {
+ handleError(err, client, reject);
+
+ articles
+ .map(article => createNewsNode(article))
+ .map(node => createNode(node));
+
+ client.close();
+ reporter.info('fcc-source-news mongo connection closed');
+ return resolve();
+ });
+ }
+ )
+ );
+};
diff --git a/client/plugins/fcc-source-news/package-lock.json b/client/plugins/fcc-source-news/package-lock.json
new file mode 100644
index 0000000000..1e506ea7c0
--- /dev/null
+++ b/client/plugins/fcc-source-news/package-lock.json
@@ -0,0 +1,80 @@
+{
+ "name": "fcc-source-news",
+ "requires": true,
+ "lockfileVersion": 1,
+ "dependencies": {
+ "bson": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.0.tgz",
+ "integrity": "sha512-9Aeai9TacfNtWXOYarkFJRW2CWo+dRon+fuLZYJmvLV3+MiUp0bEI6IAZfXEIg7/Pl/7IWlLaDnhzTsD81etQA=="
+ },
+ "memory-pager": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.1.0.tgz",
+ "integrity": "sha512-Mf9OHV/Y7h6YWDxTzX/b4ZZ4oh9NSXblQL8dtPCOomOtZciEHxePR78+uHFLLlsk01A6jVHhHsQZZ/WcIPpnzg==",
+ "optional": true
+ },
+ "mongodb": {
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.1.9.tgz",
+ "integrity": "sha512-f+Og32wK/ovzVlC1S6Ft7yjVTvNsAOs6pBpDrPd2/3wPO9ijNsQrTNntuECjOSxGZpPVl0aRqgHzF1e9e+KvnQ==",
+ "requires": {
+ "mongodb-core": "3.1.8",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "mongodb-core": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-3.1.8.tgz",
+ "integrity": "sha512-reWCqIRNehyuLaqaz5JMOmh3Xd8JIjNX34o8mnewXLK2Fyt/Ky6BZbU+X0OPzy8qbX+JZrOtnuay7ASCieTYZw==",
+ "requires": {
+ "bson": "^1.1.0",
+ "require_optional": "^1.0.1",
+ "safe-buffer": "^5.1.2",
+ "saslprep": "^1.0.0"
+ }
+ },
+ "require_optional": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz",
+ "integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==",
+ "requires": {
+ "resolve-from": "^2.0.0",
+ "semver": "^5.1.0"
+ }
+ },
+ "resolve-from": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz",
+ "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c="
+ },
+ "safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+ },
+ "saslprep": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.2.tgz",
+ "integrity": "sha512-4cDsYuAjXssUSjxHKRe4DTZC0agDwsCqcMqtJAQPzC74nJ7LfAJflAtC1Zed5hMzEQKj82d3tuzqdGNRsLJ4Gw==",
+ "optional": true,
+ "requires": {
+ "sparse-bitfield": "^3.0.3"
+ }
+ },
+ "semver": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz",
+ "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg=="
+ },
+ "sparse-bitfield": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
+ "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=",
+ "optional": true,
+ "requires": {
+ "memory-pager": "^1.0.2"
+ }
+ }
+ }
+}
diff --git a/client/plugins/fcc-source-news/package.json b/client/plugins/fcc-source-news/package.json
new file mode 100644
index 0000000000..78ebc005d2
--- /dev/null
+++ b/client/plugins/fcc-source-news/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "fcc-source-news",
+ "dependencies": {
+ "mongodb": "^3.1.9"
+ }
+}
diff --git a/client/src/__mocks__/news-article.js b/client/src/__mocks__/news-article.js
new file mode 100644
index 0000000000..4ca619f060
--- /dev/null
+++ b/client/src/__mocks__/news-article.js
@@ -0,0 +1,16 @@
+export const slugWithId = '/news/quincy/an-article-title--abcDEF123';
+export const slugWithIdAndQuery =
+ '/news/quincy/an-article-title--abcDEF123?some=query';
+export const slugWithIdAndHash =
+ '/news/quincy/an-article-title--abcDEF123#top-of-page';
+export const slugWithIdAndTrailingSlash =
+ '/news/quincy/an-article-title--abcDEF123/';
+
+export const mockId = 'abcDEF123';
+export const slugWithoutId = '/news/quincy/an-article-title';
+
+export const mockArguments = {
+ username: 'quincy',
+ slugPart: 'an-article-title',
+ shortId: 'abcDEF123'
+};
diff --git a/client/src/__tests__/integration/handled-error.test.js b/client/src/__tests__/integration/handled-error.test.js
new file mode 100644
index 0000000000..e8d633ef4d
--- /dev/null
+++ b/client/src/__tests__/integration/handled-error.test.js
@@ -0,0 +1,42 @@
+/* global describe it expect */
+import {
+ wrapHandledError,
+ unwrapHandledError
+} from '../../utils/handled-error';
+
+describe('handled-error integration', () => {
+ const handledA = {
+ type: 'info',
+ message: 'something helpful',
+ redirectTo: '/a-path-we-choose'
+ };
+ const handledB = {
+ type: 'danger',
+ message: 'Oh noes!',
+ redirectTo: '/whoops'
+ };
+ const handledC = {
+ type: 'success',
+ message: 'great news!',
+ redirectTo: '/awesome'
+ };
+ const handledD = {};
+
+ it('can wrap and unwrap handled errors', () => {
+ expect.assertions(4);
+ const wrappedA = wrapHandledError(new Error(), handledA);
+ const wrappedB = wrapHandledError(new Error(), handledB);
+ const wrappedC = wrapHandledError(new Error(), handledC);
+ const wrappedD = wrapHandledError(new Error(), handledD);
+
+ const unwrappedA = unwrapHandledError(wrappedA);
+ const unwrappedB = unwrapHandledError(wrappedB);
+ const unwrappedC = unwrapHandledError(wrappedC);
+ const unwrappedD = unwrapHandledError(wrappedD);
+
+ expect(unwrappedA).toEqual(handledA);
+ expect(unwrappedB).toEqual(handledB);
+ expect(unwrappedC).toEqual(handledC);
+ expect(unwrappedD).toEqual(handledD);
+ });
+});
diff --git a/client/src/__tests__/integration/news-slug.test.js b/client/src/__tests__/integration/news-slug.test.js
new file mode 100644
index 0000000000..f79357dbdc
--- /dev/null
+++ b/client/src/__tests__/integration/news-slug.test.js
@@ -0,0 +1,33 @@
+/* global describe it expect */
+import faker from 'faker';
+import { kebabCase } from 'lodash';
+import shortid from 'shortid';
+
+import { createArticleSlug } from '../../../utils/news';
+import { getShortIdFromSlug } from '../../utils';
+
+shortid.characters(
+ '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$+'
+);
+const shortId = () => shortid.generate();
+
+describe('news-slug integration', () => {
+ it('returns the correct id from a generated slug', () => {
+ expect.assertions(100);
+
+ const generatedArguments = Array(100)
+ .fill(null)
+ .map(() => ({
+ username: faker.internet.userName(),
+ slugPart: kebabCase(faker.lorem.sentence()).toLowerCase(),
+ shortId: shortId()
+ }));
+
+ return generatedArguments.forEach(arg => {
+ const { shortId } = arg;
+ const generatedSlug = createArticleSlug(arg);
+ const extractedId = getShortIdFromSlug(generatedSlug);
+ return expect(extractedId).toEqual(shortId);
+ });
+ });
+});
diff --git a/client/src/client-only-routes/ShowDynamicNewsOrFourOhFour.js b/client/src/client-only-routes/ShowDynamicNewsOrFourOhFour.js
new file mode 100644
index 0000000000..bb62d5b783
--- /dev/null
+++ b/client/src/client-only-routes/ShowDynamicNewsOrFourOhFour.js
@@ -0,0 +1,89 @@
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { isNull, pick, isEmpty } from 'lodash';
+
+import Layout from '../components/layouts/Default';
+import Loader from '../components/helpers/Loader';
+
+import { getShortIdFromSlug } from '../utils';
+import { createArticleSlug } from '../../utils/news';
+import {
+ resolveShortId,
+ resolveShortIdFetchStateSelector,
+ dynamicArticleByIdMapSelector
+} from '../templates/News/redux';
+import { createFlashMessage } from '../components/Flash/redux';
+import ShowArticle from '../templates/News/ShowArticle';
+
+const mapStateToProps = () => (state, { articleSlug = '' }) => {
+ const shortId = getShortIdFromSlug(articleSlug);
+ const articleMap = dynamicArticleByIdMapSelector(state);
+ const article = articleMap[shortId] || null;
+ const fetchState = resolveShortIdFetchStateSelector(state);
+ return { article, fetchState, shortId };
+};
+const mapDispatchToProps = dispatch =>
+ bindActionCreators({ createFlashMessage, resolveShortId }, dispatch);
+
+class DynamicNewsArticle extends Component {
+ componentDidMount() {
+ const { shortId, article, resolveShortId } = this.props;
+ if (isNull(article)) {
+ return resolveShortId(shortId);
+ }
+ return null;
+ }
+
+ getArticleAsGatsbyProps(article) {
+ const {
+ author: { username },
+ slugPart,
+ shortId,
+ meta: { readTime }
+ } = article;
+
+ return {
+ data: {
+ newsArticleNode: {
+ ...pick(article, [
+ 'title',
+ 'renderableContent',
+ 'youtube',
+ 'author',
+ 'firstPublishedDate',
+ 'shortId',
+ 'featureImage'
+ ]),
+ fields: { slug: createArticleSlug({ username, slugPart, shortId }) },
+ meta: { readTime }
+ }
+ }
+ };
+ }
+
+ render() {
+ const {
+ fetchState: { pending },
+ article
+ } = this.props;
+ if (pending) {
+ return (
+
+
+
+
+
+ );
+ }
+ return isEmpty(article) ? null : (
+
+ );
+ }
+}
+DynamicNewsArticle.displayName = 'DynamicNewsArticle';
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(DynamicNewsArticle);
diff --git a/client/src/client-only-routes/ShowProfileOrFourOhFour.js b/client/src/client-only-routes/ShowProfileOrFourOhFour.js
index 1ff86350d6..2e5f8e9726 100644
--- a/client/src/client-only-routes/ShowProfileOrFourOhFour.js
+++ b/client/src/client-only-routes/ShowProfileOrFourOhFour.js
@@ -23,8 +23,7 @@ const propTypes = {
username: PropTypes.string,
profileUI: PropTypes.object
}),
- showLoading: PropTypes.bool,
- splat: PropTypes.string
+ showLoading: PropTypes.bool
};
const createRequestedUserSelector = () => (state, { maybeUser }) =>
@@ -49,22 +48,15 @@ const mapDispatchToProps = dispatch =>
class ShowFourOhFour extends Component {
componentDidMount() {
- const { requestedUser, maybeUser, splat, fetchProfileForUser } = this.props;
- if (!splat && isEmpty(requestedUser)) {
- console.log(requestedUser);
+ const { requestedUser, maybeUser, fetchProfileForUser } = this.props;
+ if (isEmpty(requestedUser)) {
return fetchProfileForUser(maybeUser);
}
return null;
}
render() {
- const { isSessionUser, requestedUser, showLoading, splat } = this.props;
- if (splat) {
- // the uri path for this component is /:maybeUser/:splat
- // if splat is defined then we on a route that is not a profile
- // and we should just 404
- return ;
- }
+ const { isSessionUser, requestedUser, showLoading } = this.props;
if (showLoading) {
// We don't know if /:maybeUser is a user or not, we will show the loader
// until we get a response from the API
diff --git a/client/src/components/Flash/redux/index.js b/client/src/components/Flash/redux/index.js
index e71e5e1aac..905a35d6da 100644
--- a/client/src/components/Flash/redux/index.js
+++ b/client/src/components/Flash/redux/index.js
@@ -3,7 +3,7 @@ import nanoId from 'nanoid';
import { createTypes } from '../../../utils/createTypes';
-const ns = 'flash';
+export const ns = 'flash';
const initialState = {
messages: []
diff --git a/client/src/components/Header/header.css b/client/src/components/Header/header.css
index 861452e6c4..c2864966f4 100644
--- a/client/src/components/Header/header.css
+++ b/client/src/components/Header/header.css
@@ -7,7 +7,6 @@ header {
#top-nav {
background: #006400;
- margin-bottom: 0.45rem;
height: 38px;
margin-bottom: 0px;
border-radius: 0;
@@ -42,7 +41,8 @@ header {
justify-content: space-around;
}
-#top-right-nav a, #top-right-nav img {
+#top-right-nav a,
+#top-right-nav img {
max-height: 40px;
}
@@ -62,15 +62,43 @@ header {
justify-content: center;
align-items: center;
height: 100%;
- margin: 0 3px;
+ margin: 0 5px;
}
-#top-right-nav li > a, #top-right-nav li > span {
- color:#fff;
+#top-right-nav li,
+#top-right-nav li > a {
+ color: #fff;
font-size: 17px;
}
-#top-right-nav li > a:hover, #top-right-nav li > a:focus {
+#top-right-nav li:hover,
+#top-right-nav li:hover a,
+#top-right-nav li > a:hover,
+#top-right-nav li:focus,
+#top-right-nav li:focus a,
+#top-right-nav li > a:focus {
+ background-color: #fff;
+ color: #006400;
+}
+
+li.user-state-link,
+li.user-state-link:hover,
+li.user-state-link:focus,
+li.user-state-link > a,
+li.user-state-link > a:hover,
+li.user-state-link > a:focus {
+ background-color: #006400 !important;
+}
+
+#top-right-nav {
+ margin-left: 20px;
+}
+#top-right-nav li:last-child {
+ margin-right: 30px;
+}
+
+#top-right-nav li > a:hover,
+#top-right-nav li > a:focus {
text-decoration: none;
}
@@ -92,4 +120,4 @@ header {
}
.ais-Hits {
background-color: #fff;
-}
\ No newline at end of file
+}
diff --git a/client/src/components/Header/index.js b/client/src/components/Header/index.js
index f9e9e87f5c..c6e1b8b46d 100644
--- a/client/src/components/Header/index.js
+++ b/client/src/components/Header/index.js
@@ -30,6 +30,9 @@ function Header({ disableSettings }) {
+ News
+
+
diff --git a/client/src/components/Map/redux/index.js b/client/src/components/Map/redux/index.js
index 4ef7ee7db5..de3e6a3af3 100644
--- a/client/src/components/Map/redux/index.js
+++ b/client/src/components/Map/redux/index.js
@@ -2,7 +2,7 @@ import { createAction, handleActions } from 'redux-actions';
import { createTypes } from '../../../../utils/stateManagement';
-const ns = 'curriculumMap';
+export const ns = 'curriculumMap';
export const getNS = () => ns;
diff --git a/client/src/components/RedirectHome.js b/client/src/components/RedirectHome.js
index b2d8a86f06..ad5bb65171 100644
--- a/client/src/components/RedirectHome.js
+++ b/client/src/components/RedirectHome.js
@@ -1,10 +1,3 @@
-import { navigate } from 'gatsby';
+import createRedirect from './createRedirect';
-const RedirectHome = () => {
- if (typeof window !== 'undefined') {
- navigate('/');
- }
- return null;
-};
-
-export default RedirectHome;
+export default createRedirect('/');
diff --git a/client/src/components/RedirectNews.js b/client/src/components/RedirectNews.js
new file mode 100644
index 0000000000..a4c2a7178d
--- /dev/null
+++ b/client/src/components/RedirectNews.js
@@ -0,0 +1,3 @@
+import createRedirect from './createRedirect';
+
+export default createRedirect('/news');
diff --git a/client/src/components/createRedirect.js b/client/src/components/createRedirect.js
new file mode 100644
index 0000000000..67360a0b75
--- /dev/null
+++ b/client/src/components/createRedirect.js
@@ -0,0 +1,10 @@
+import { navigate } from 'gatsby';
+
+const createRedirect = (to = '/') => () => {
+ if (typeof window !== 'undefined') {
+ navigate(to);
+ }
+ return null;
+};
+
+export default createRedirect;
diff --git a/client/src/pages/404.js b/client/src/pages/404.js
index af2a081ab6..4743023e15 100644
--- a/client/src/pages/404.js
+++ b/client/src/pages/404.js
@@ -1,13 +1,20 @@
import React from 'react';
import { Router } from '@reach/router';
-// eslint-disable-next-line max-len
+
+import NotFoundPage from '../components/FourOhFour';
+import RedirectNews from '../components/RedirectNews';
+/* eslint-disable max-len */
import ShowProfileOrFourOhFour from '../client-only-routes/ShowProfileOrFourOhFour';
+import ShowDynamicNewsOrFourOhFour from '../client-only-routes/ShowDynamicNewsOrFourOhFour';
+/* eslint-enable max-len */
function FourOhFourPage() {
return (
-
+
+
+
);
}
diff --git a/client/src/pages/n.js b/client/src/pages/n.js
new file mode 100644
index 0000000000..091967ec4a
--- /dev/null
+++ b/client/src/pages/n.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import { Router } from '@reach/router';
+
+import NewsReferalLinkHandler from '../templates/News/NewsReferalLinkHandler';
+import RedirectNews from '../components/RedirectNews';
+
+export default function NewsReferalLinkRouter() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/client/src/pages/news.js b/client/src/pages/news.js
new file mode 100644
index 0000000000..bb4d064310
--- /dev/null
+++ b/client/src/pages/news.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Grid } from '@freecodecamp/react-bootstrap';
+import { graphql } from 'gatsby';
+
+import Layout from '../components/layouts/Default';
+
+import FullWidthRow from '../components/helpers/FullWidthRow';
+import Featured from '../templates/News/Featured';
+
+const propTypes = {
+ data: PropTypes.shape({
+ allNewsArticleNode: PropTypes.shape({
+ edges: PropTypes.arrayOf(
+ PropTypes.shape({
+ title: PropTypes.string,
+ shortId: PropTypes.string,
+ slugPart: PropTypes.string,
+ featureImage: PropTypes.shape({
+ src: PropTypes.string,
+ alt: PropTypes.string,
+ caption: PropTypes.string
+ }),
+ meta: PropTypes.shape({
+ readTime: PropTypes.number
+ }),
+ author: PropTypes.shape({
+ name: PropTypes.string,
+ avatar: PropTypes.string,
+ twitter: PropTypes.string,
+ username: PropTypes.string,
+ bio: PropTypes.string
+ }),
+ viewCount: PropTypes.number,
+ firstPublishedDate: PropTypes.string
+ })
+ )
+ })
+ })
+};
+
+export default function NewsIndexPage(props) {
+ const {
+ allNewsArticleNode: { edges }
+ } = props.data;
+ const articles = edges.map(({ node }) => node);
+ return (
+
+
+
+ News - freeCodeCamp.org
+
+
+
+
+
+
+ );
+}
+
+export const query = graphql`
+ {
+ allNewsArticleNode(sort: { fields: firstPublishedDate, order: DESC }) {
+ edges {
+ node {
+ title
+ shortId
+ slugPart
+ featureImage {
+ src
+ alt
+ caption
+ }
+ meta {
+ readTime
+ }
+ author {
+ name
+ avatar
+ twitter
+ bio
+ username
+ }
+ viewCount
+ firstPublishedDate
+ fields {
+ slug
+ }
+ }
+ }
+ }
+ }
+`;
+
+NewsIndexPage.displayName = 'NewsIndexPage';
+NewsIndexPage.propTypes = propTypes;
diff --git a/client/src/redux/error-saga.js b/client/src/redux/error-saga.js
new file mode 100644
index 0000000000..1d0ed176e8
--- /dev/null
+++ b/client/src/redux/error-saga.js
@@ -0,0 +1,25 @@
+import { navigate } from 'gatsby';
+import { takeEvery, put } from 'redux-saga/effects';
+import { isError } from 'lodash';
+
+import { isHandledError, unwrapHandledError } from '../utils/handled-error';
+import { reportClientSideError } from '../utils/report-error';
+import { createFlashMessage } from '../components/Flash/redux';
+import reportedErrorMessage from '../utils/reportedErrorMessage';
+
+const errorActionSelector = action => isError(action.payload);
+
+function* errorHandlerSaga({ payload: error }) {
+ if (isHandledError(error)) {
+ const { type, message, redirectTo } = unwrapHandledError(error);
+ if (redirectTo) {
+ navigate(redirectTo);
+ }
+ yield put(createFlashMessage({ type, message }));
+ return;
+ }
+ reportClientSideError('Unhandled Error caught in error-saga', error);
+ yield put(createFlashMessage(reportedErrorMessage));
+}
+
+export default [takeEvery(errorActionSelector, errorHandlerSaga)];
diff --git a/client/src/redux/index.js b/client/src/redux/index.js
index bc72bc2081..e2a1dce7b6 100644
--- a/client/src/redux/index.js
+++ b/client/src/redux/index.js
@@ -19,9 +19,9 @@ import { types as settingsTypes } from './settings';
const challengeReduxTypes = {};
/** ***********************************/
-const ns = 'app';
+export const ns = 'app';
-const defaultFetchState = {
+export const defaultFetchState = {
pending: true,
complete: false,
errored: false,
@@ -220,16 +220,15 @@ export const reducer = handleActions(
[username]: { ...previousUserObject, ...user }
},
userProfileFetchState: {
+ ...defaultFetchState,
pending: false,
- complete: true,
- errored: false,
- error: null
+ complete: true
}
};
},
[types.fetchProfileForUserError]: (state, { payload }) => ({
...state,
- userFetchState: {
+ userProfileFetchState: {
pending: false,
complete: false,
errored: true,
@@ -253,10 +252,9 @@ export const reducer = handleActions(
...state,
showCert: payload,
showCertFetchState: {
+ ...defaultFetchState,
pending: false,
- complete: true,
- errored: false,
- error: null
+ complete: true
}
}),
[types.showCertError]: (state, { payload }) => ({
diff --git a/client/src/redux/rootReducer.js b/client/src/redux/rootReducer.js
index 734cb22a9e..df875b795a 100644
--- a/client/src/redux/rootReducer.js
+++ b/client/src/redux/rootReducer.js
@@ -1,17 +1,28 @@
import { combineReducers } from 'redux';
-import {reducer as formReducer} from 'redux-form';
+import { reducer as formReducer } from 'redux-form';
-import { reducer as app } from './';
-import { reducer as flash } from '../components/Flash/redux';
-import { reducer as settings } from './settings';
-import { reducer as curriculumMap } from '../components/Map/redux';
-import { reducer as challenge } from '../templates/Challenges/redux';
+import { reducer as app, ns as appNameSpace } from './';
+import {
+ reducer as flash,
+ ns as flashNameSpace
+} from '../components/Flash/redux';
+import { reducer as settings, ns as settingsNameSpace } from './settings';
+import {
+ reducer as curriculumMap,
+ ns as curriculumMapNameSpace
+} from '../components/Map/redux';
+import {
+ reducer as challenge,
+ ns as challengeNameSpace
+} from '../templates/Challenges/redux';
+import { reducer as news, ns as newsNameSpace } from '../templates/News/redux';
export default combineReducers({
- app,
- challenge,
- curriculumMap,
- flash,
+ [appNameSpace]: app,
+ [challengeNameSpace]: challenge,
+ [curriculumMapNameSpace]: curriculumMap,
+ [flashNameSpace]: flash,
form: formReducer,
- settings
+ [newsNameSpace]: news,
+ [settingsNameSpace]: settings
});
diff --git a/client/src/redux/rootSaga.js b/client/src/redux/rootSaga.js
index 99671f072d..4d053e9b30 100644
--- a/client/src/redux/rootSaga.js
+++ b/client/src/redux/rootSaga.js
@@ -1,9 +1,17 @@
import { all } from 'redux-saga/effects';
+import errorSagas from './error-saga';
import { sagas as appSagas } from './';
-import { sagas as settingsSagas } from './settings';
import { sagas as challengeSagas } from '../templates/Challenges/redux';
+import { sagas as newsSagas } from '../templates/News/redux';
+import { sagas as settingsSagas } from './settings';
export default function* rootSaga() {
- yield all([...appSagas, ...challengeSagas, ...settingsSagas]);
+ yield all([
+ ...errorSagas,
+ ...appSagas,
+ ...challengeSagas,
+ ...newsSagas,
+ ...settingsSagas
+ ]);
}
diff --git a/client/src/redux/settings/index.js b/client/src/redux/settings/index.js
index 1b00e02342..ae1d796804 100644
--- a/client/src/redux/settings/index.js
+++ b/client/src/redux/settings/index.js
@@ -4,7 +4,7 @@ import { createTypes, createAsyncTypes } from '../../utils/createTypes';
import { createSettingsSagas } from './settings-sagas';
import { createUpdateMyEmailSaga } from './update-email-saga';
-const ns = 'settings';
+export const ns = 'settings';
const defaultFetchState = {
pending: false,
diff --git a/client/src/templates/Challenges/redux/index.js b/client/src/templates/Challenges/redux/index.js
index a9badf55d4..cc00d5c53f 100644
--- a/client/src/templates/Challenges/redux/index.js
+++ b/client/src/templates/Challenges/redux/index.js
@@ -15,7 +15,7 @@ import currentChallengeEpic from './current-challenge-epic';
import { createIdToNameMapSaga } from './id-to-name-map-saga';
-const ns = 'challenge';
+export const ns = 'challenge';
export const backendNS = 'backendChallenge';
const initialState = {
diff --git a/client/src/templates/News/Featured/Featured.js b/client/src/templates/News/Featured/Featured.js
new file mode 100644
index 0000000000..c905c02125
--- /dev/null
+++ b/client/src/templates/News/Featured/Featured.js
@@ -0,0 +1,62 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { navigate } from 'gatsby';
+import { Image } from '@freecodecamp/react-bootstrap';
+
+import Spacer from '../../../components/helpers/Spacer';
+import BannerWide from '../components/BannerWide';
+import ArticleMeta from '../components/ArticleMeta';
+
+import './featured.css';
+
+const propTypes = {
+ featuredList: PropTypes.arrayOf(PropTypes.object)
+};
+
+class Featured extends Component {
+ createHandleArticleClick(slug) {
+ return e => {
+ e.preventDefault();
+ return navigate(slug);
+ };
+ }
+
+ renderFeatured(articles) {
+ return articles.map(article => {
+ const { featureImage, shortId, title, fields: {slug} } = article;
+ return (
+
+
+ {title}
+ {featureImage && featureImage.src ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+ });
+ }
+
+ render() {
+ const { featuredList } = this.props;
+ return (
+ {this.renderFeatured(featuredList)}
+ );
+ }
+}
+
+Featured.displayName = 'Featured';
+Featured.propTypes = propTypes;
+
+export default Featured;
diff --git a/client/src/templates/News/Featured/featured.css b/client/src/templates/News/Featured/featured.css
new file mode 100644
index 0000000000..9b00e36b29
--- /dev/null
+++ b/client/src/templates/News/Featured/featured.css
@@ -0,0 +1,33 @@
+.featured-list {
+ list-style: none;
+ padding-left: 0;
+ margin-top: 40px;
+}
+
+.featured-list-item {
+ padding-bottom: 20px;
+}
+
+.featured-list-item .title {
+ color: #333;
+ padding-bottom: 20px;
+}
+
+.featured-list-item a {
+ padding-top: 5px;
+}
+
+.featured-list-image {
+ margin: 0 auto;
+}
+
+.featured-list-item a:hover,
+.featured-list-item a:focus {
+ text-decoration: none;
+ text-decoration-line: none;
+ text-decoration-color: transparaent;
+}
+.featured-list-item a:hover > .meta-wrapper,
+.featured-list-item a:focus > .meta-wrapper {
+ color: #006400;
+}
diff --git a/news/routes/Featured/index.js b/client/src/templates/News/Featured/index.js
similarity index 100%
rename from news/routes/Featured/index.js
rename to client/src/templates/News/Featured/index.js
diff --git a/news/NewsApp.js b/client/src/templates/News/NewsApp.js
similarity index 100%
rename from news/NewsApp.js
rename to client/src/templates/News/NewsApp.js
diff --git a/client/src/templates/News/NewsReferalLinkHandler/index.js b/client/src/templates/News/NewsReferalLinkHandler/index.js
new file mode 100644
index 0000000000..166b6b614a
--- /dev/null
+++ b/client/src/templates/News/NewsReferalLinkHandler/index.js
@@ -0,0 +1,61 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { navigate } from 'gatsby';
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import Layout from '../../../components/layouts/Default';
+import { resolveShortId, dynamicArticleSelector } from '../redux';
+import { Loader } from '../../../components/helpers';
+
+const propTypes = {
+ redirect: PropTypes.string,
+ resolveShortId: PropTypes.func.isRequired,
+ shortId: PropTypes.string.isRequired
+};
+
+const mapStateToProps = () => (state, props) => {
+ const article = dynamicArticleSelector(state, props);
+ return {
+ redirect: article.redirect
+ };
+};
+
+const mapDispatchToProps = dispatch =>
+ bindActionCreators({ resolveShortId }, dispatch);
+
+class NewsReferalLinkHandler extends Component {
+ componentDidMount() {
+ const { shortId, redirect, resolveShortId } = this.props;
+ if (!redirect) {
+ return resolveShortId(shortId);
+ }
+ return navigate(redirect);
+ }
+
+ componentDidUpdate() {
+ const { redirect } = this.props;
+ if (redirect) {
+ return navigate(redirect);
+ }
+ return null;
+ }
+
+ render() {
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+NewsReferalLinkHandler.displayName = 'NewsReferalLinkHandler';
+NewsReferalLinkHandler.propTypes = propTypes;
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(NewsReferalLinkHandler);
diff --git a/news/routes/Show/components/Author.js b/client/src/templates/News/ShowArticle/components/Author.js
similarity index 94%
rename from news/routes/Show/components/Author.js
rename to client/src/templates/News/ShowArticle/components/Author.js
index 7a7062d7e0..aea68ae184 100644
--- a/news/routes/Show/components/Author.js
+++ b/client/src/templates/News/ShowArticle/components/Author.js
@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import Helmet from 'react-helmet';
-import ArticleMeta from '../../../components/ArticleMeta';
+import ArticleMeta from '../../components/ArticleMeta';
const propTypes = {
article: PropTypes.shape({
diff --git a/client/src/templates/News/ShowArticle/index.js b/client/src/templates/News/ShowArticle/index.js
new file mode 100644
index 0000000000..b31d7871d8
--- /dev/null
+++ b/client/src/templates/News/ShowArticle/index.js
@@ -0,0 +1,187 @@
+import React, { Component, Fragment } from 'react';
+import PropTypes from 'prop-types';
+import Helmet from 'react-helmet';
+import { graphql } from 'gatsby';
+import Youtube from 'react-youtube';
+import { Image, Grid } from '@freecodecamp/react-bootstrap';
+
+import Layout from '../../../components/layouts/Default';
+import Author from './components/Author';
+import FullWidthRow from '../../../components/helpers/FullWidthRow';
+import Spacer from '../../../components/helpers/Spacer';
+import { postPopularityEvent } from '../../../utils/ajax';
+import { newsArticleNodePropTypes } from './proptypes';
+
+import './show-article.css';
+
+const propTypes = {
+ data: PropTypes.shape({
+ newsArticleNode: newsArticleNodePropTypes
+ })
+};
+
+const youtubeOpts = {
+ playerVars: {
+ // https://developers.google.com/youtube/player_parameters
+ autoplay: 0
+ }
+};
+
+class ShowArticle extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ fetchState: {
+ pending: false,
+ complete: false,
+ errored: false,
+ error: null
+ },
+ currentArticle: {},
+ requiredArticle: ''
+ };
+ }
+
+ componentDidMount() {
+ window.scrollTo(0, 0);
+
+ const { shortId } = this.props.data.newsArticleNode;
+ return postPopularityEvent({
+ event: 'view',
+ timestamp: Date.now(),
+ shortId
+ });
+ }
+
+ youtubeReady(event) {
+ event.target.pauseVideo();
+ }
+
+ render() {
+ const {
+ data: {
+ newsArticleNode: {
+ title,
+ renderableContent,
+ youtubeId,
+ featureImage,
+ fields: { slug }
+ },
+ newsArticleNode
+ }
+ } = this.props;
+
+ // RegEx finds the first paragraph and groups the content
+ const description = renderableContent.join('').match(/(.*?)<\/p>/)[1];
+ const pageTitle = `${title} | freeCodeCamp.org`;
+ return (
+
+
+ {pageTitle}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {title}
+
+ {featureImage ? (
+
+
+
+
+ {featureImage.caption ? (
+
+ ) : null}
+
+
+
+ ) : null}
+
+
+
+
+ {youtubeId ? (
+
+
+
+
+
+
+ ) : null}
+
+
+
+
+ );
+ }
+}
+
+ShowArticle.displayName = 'ShowArticle';
+ShowArticle.propTypes = propTypes;
+
+export default ShowArticle;
+
+export const query = graphql`
+ fragment newsArticleContent on NewsArticleNode {
+ title
+ renderableContent
+ featureImage {
+ src
+ alt
+ caption
+ }
+ fields {
+ slug
+ }
+ author {
+ name
+ avatar
+ twitter
+ bio
+ username
+ }
+ meta {
+ readTime
+ }
+ firstPublishedDate
+ shortId
+ }
+
+ query NewsArticleById($id: String!) {
+ newsArticleNode(id: { eq: $id }) {
+ ...newsArticleContent
+ }
+ }
+`;
diff --git a/client/src/templates/News/ShowArticle/proptypes.js b/client/src/templates/News/ShowArticle/proptypes.js
new file mode 100644
index 0000000000..1d968664ff
--- /dev/null
+++ b/client/src/templates/News/ShowArticle/proptypes.js
@@ -0,0 +1,26 @@
+import PropTypes from 'prop-types';
+
+export const newsArticleNodePropTypes = PropTypes.shape({
+ title: PropTypes.string,
+ renderableContent: PropTypes.arrayOf(PropTypes.string),
+ featureImage: PropTypes.shape({
+ src: PropTypes.string,
+ alt: PropTypes.string,
+ caption: PropTypes.string
+ }),
+ fields: PropTypes.shape({
+ slug: PropTypes.string
+ }),
+ author: PropTypes.shape({
+ name: PropTypes.string,
+ avatar: PropTypes.string,
+ twitter: PropTypes.string,
+ bio: PropTypes.string,
+ username: PropTypes.string
+ }),
+ meta: PropTypes.shape({
+ readTime: PropTypes.number
+ }),
+ firstPublishedDate: PropTypes.string,
+ shortId: PropTypes.string
+});
diff --git a/client/src/templates/News/ShowArticle/show-article.css b/client/src/templates/News/ShowArticle/show-article.css
new file mode 100644
index 0000000000..75761a65cb
--- /dev/null
+++ b/client/src/templates/News/ShowArticle/show-article.css
@@ -0,0 +1,34 @@
+.show-article figure {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.show-article figcaption > * {
+ font-size: 16px;
+}
+
+.show-article figcaption {
+ padding-top: 5px;
+}
+
+.show-article a {
+ text-decoration: underline;
+}
+
+.feature-image-wrapper {
+ padding-top: 32px;
+}
+.youtube-wrapper {
+ position: relative;
+ padding-bottom: 56.25%; /* 16:9 */
+ padding-top: 25px;
+ height: 0;
+ overflow: hidden;
+}
+.youtube-wrapper iframe {
+ position: absolute;
+ left: 0;
+ width: 100%;
+ height: 95%;
+}
diff --git a/news/components/ArticleMeta.js b/client/src/templates/News/components/ArticleMeta.js
similarity index 100%
rename from news/components/ArticleMeta.js
rename to client/src/templates/News/components/ArticleMeta.js
diff --git a/news/components/BannerWide.js b/client/src/templates/News/components/BannerWide.js
similarity index 100%
rename from news/components/BannerWide.js
rename to client/src/templates/News/components/BannerWide.js
diff --git a/client/src/templates/News/redux/index.js b/client/src/templates/News/redux/index.js
new file mode 100644
index 0000000000..1de714d14c
--- /dev/null
+++ b/client/src/templates/News/redux/index.js
@@ -0,0 +1,67 @@
+import { createAction, handleActions } from 'redux-actions';
+
+import { createTypes } from '../../../../utils/stateManagement';
+import { createAsyncTypes } from '../../../utils/createTypes';
+import { defaultFetchState } from '../../../redux';
+import { createShortIdSaga } from './shortId-saga';
+
+export const ns = 'news';
+const initialState = {
+ resolveShortIdFetchState: { ...defaultFetchState },
+ dynamicArticleByIdMap: {}
+};
+
+export const types = createTypes([...createAsyncTypes('resolveShortId')], ns);
+
+export const sagas = [...createShortIdSaga(types)];
+
+export const resolveShortId = createAction(types.resolveShortId);
+export const resolveShortIdComplete = createAction(
+ types.resolveShortIdComplete,
+ article => {
+ const { slug } = article;
+ article.redirect = slug;
+ return article;
+ }
+);
+export const resolveShortIdError = createAction(types.resolveShortIdError);
+
+export const resolveShortIdFetchStateSelector = state =>
+ state[ns].resolveShortIdFetchState;
+export const dynamicArticleByIdMapSelector = state =>
+ state[ns].dynamicArticleByIdMap;
+export const dynamicArticleSelector = (state, { shortId }) => {
+ const map = dynamicArticleByIdMapSelector(state);
+ return map[shortId] || {};
+};
+
+export const reducer = handleActions(
+ {
+ [types.resolveShortId]: state => ({
+ ...state,
+ resolveShortIdFetchState: { ...defaultFetchState }
+ }),
+ [types.resolveShortIdComplete]: (state, { payload }) => ({
+ ...state,
+ resolveShortIdFetchState: {
+ ...defaultFetchState,
+ pending: false,
+ complete: true
+ },
+ dynamicArticleByIdMap: {
+ ...state.dynamicArticleByIdMap,
+ [payload.shortId]: payload
+ }
+ }),
+ [types.resolveShortIdError]: (state, { payload: error }) => ({
+ ...state,
+ resolveShortIdFetchState: {
+ ...defaultFetchState,
+ pending: false,
+ errored: true,
+ error
+ }
+ })
+ },
+ initialState
+);
diff --git a/client/src/templates/News/redux/shortId-saga.js b/client/src/templates/News/redux/shortId-saga.js
new file mode 100644
index 0000000000..fe293f3068
--- /dev/null
+++ b/client/src/templates/News/redux/shortId-saga.js
@@ -0,0 +1,27 @@
+import { call, put, takeEvery } from 'redux-saga/effects';
+
+import { getArticleById } from '../../../utils/ajax';
+import { resolveShortIdComplete, resolveShortIdError } from './';
+import { handleAPIError, wrapHandledError } from '../../../utils/handled-error';
+
+function* fetchArticleByIdSaga({ payload }) {
+ try {
+ const { data } = yield call(getArticleById, payload);
+ yield put(resolveShortIdComplete(data));
+ } catch (e) {
+ const { response: { status } = {} } = e;
+ if (typeof status !== 'undefined') {
+ const handledError = wrapHandledError(
+ e,
+ handleAPIError(e, { redirectTo: '/news' })
+ );
+ yield put(resolveShortIdError(handledError));
+ return;
+ }
+ yield put(resolveShortIdError(e));
+ }
+}
+
+export function createShortIdSaga(types) {
+ return [takeEvery(types.resolveShortId, fetchArticleByIdSaga)];
+}
diff --git a/client/src/utils/ajax.js b/client/src/utils/ajax.js
index a5b71ca95d..5d522cc5f3 100644
--- a/client/src/utils/ajax.js
+++ b/client/src/utils/ajax.js
@@ -1,4 +1,5 @@
import axios from 'axios';
+import qs from 'query-string';
const base = '/internal';
@@ -40,8 +41,31 @@ export function getUsernameExists(username) {
return get(`/api/users/exists?username=${username}`);
}
+export function getArticleById(shortId) {
+ return get(
+ `/n/${shortId}`
+ );
+}
+
+export function getFeaturedList(skip = 0) {
+ return get(
+ `/api/articles?${qs.stringify({
+ filter: JSON.stringify({
+ where: { featured: true, published: true },
+ order: 'firstPublishedDate DESC',
+ limit: 10,
+ skip
+ })
+ })}`
+ );
+}
+
/** POST **/
+export function postPopularityEvent(event) {
+ return post('/p', event);
+}
+
export function postReportUser(body) {
return post('/user/report-user', body);
}
diff --git a/client/src/utils/handled-error.js b/client/src/utils/handled-error.js
new file mode 100644
index 0000000000..21e8f07025
--- /dev/null
+++ b/client/src/utils/handled-error.js
@@ -0,0 +1,84 @@
+import { has } from 'lodash';
+
+import standardErrorMessage from './standardErrorMessage';
+import reportedErrorMessage from './reportedErrorMessage';
+
+import { reportClientSideError } from './report-error';
+
+export const handledErrorSymbol = Symbol('handledError');
+
+export function isHandledError(err) {
+ return has(err, handledErrorSymbol);
+}
+
+export function unwrapHandledError(err) {
+ return handledErrorSymbol in err ? err[handledErrorSymbol] : {};
+}
+
+export function wrapHandledError(err, { type, message, redirectTo }) {
+ err[handledErrorSymbol] = { type, message, redirectTo };
+ return err;
+}
+
+export function handle400Error(e, options = { redirectTo: '/welcome' }) {
+ const {
+ response: { status }
+ } = e;
+ let { redirectTo } = options;
+ let flash = { ...standardErrorMessage, redirectTo };
+
+ switch (status) {
+ case 401:
+ case 403: {
+ return {
+ ...flash,
+ type: 'warn',
+ message: 'You are not authorised to continue on this route'
+ };
+ }
+ case 404: {
+ return {
+ ...flash,
+ type: 'info',
+ message:
+ "We couldn't find what you were looking for. " +
+ 'Please check and try again'
+ };
+ }
+ default: {
+ return flash;
+ }
+ }
+}
+
+export function handle500Error(
+ e,
+ options = {
+ redirectTo: '/welcome'
+ },
+ _reportClientSideError = reportClientSideError
+) {
+ const { redirectTo } = options;
+ _reportClientSideError(e, 'We just handled a 5** error on the client');
+ return { ...reportedErrorMessage, redirectTo };
+}
+
+export function handleAPIError(
+ e,
+ options,
+ _reportClientSideError = reportClientSideError
+) {
+ const { response: { status = 0 } = {} } = e;
+ if (status >= 400 && status < 500) {
+ return handle400Error(e, options);
+ }
+ if (status >= 500) {
+ return handle500Error(e, options, _reportClientSideError);
+ }
+ const { redirectTo } = options;
+ _reportClientSideError(
+ e,
+ 'We just handled an api error on the client without an error status code'
+ );
+ return { ...reportedErrorMessage, redirectTo };
+}
diff --git a/client/src/utils/handled-error.test.js b/client/src/utils/handled-error.test.js
new file mode 100644
index 0000000000..60751d43ac
--- /dev/null
+++ b/client/src/utils/handled-error.test.js
@@ -0,0 +1,150 @@
+/* global describe it expect */
+import { isObject } from 'lodash';
+import sinon from 'sinon';
+import {
+ isHandledError,
+ wrapHandledError,
+ unwrapHandledError,
+ handledErrorSymbol,
+ handleAPIError
+} from './handled-error';
+
+import reportedErrorMessage from './reportedErrorMessage';
+
+describe('client/src utilities', () => {
+ describe('handled-error.js', () => {
+ const mockHandledErrorData = {
+ type: 'info',
+ message: 'something helpful',
+ redirectTo: '/a-path-we-choose'
+ };
+
+ describe('isHandledError', () => {
+ it('returns a boolean', () => {
+ expect(typeof isHandledError({})).toEqual('boolean');
+ });
+
+ it('returns false for an unhandled error', () => {
+ expect(isHandledError(new Error())).toEqual(false);
+ });
+
+ it('returns true for a handled error', () => {
+ const handledError = new Error();
+ handledError[handledErrorSymbol] = {};
+
+ expect(isHandledError(handledError)).toEqual(true);
+ });
+ });
+
+ describe('wrapHandledError', () => {
+ // this is testing implementation details 👎
+ // we need to make these tests more robust 💪
+ it('returns an error with a handledError property', () => {
+ const handledError = wrapHandledError(
+ new Error(),
+ mockHandledErrorData
+ );
+ expect(handledErrorSymbol in handledError).toEqual(true);
+ });
+ it('assigns error handling details to the handledError property', () => {
+ const handledError = wrapHandledError(
+ new Error(),
+ mockHandledErrorData
+ );
+ expect(handledError[handledErrorSymbol]).toEqual(mockHandledErrorData);
+ });
+ });
+
+ describe('unwrapHandledError', () => {
+ // this is testing implementation details 👎
+ // we need to make these tests more robust 💪
+ it('returns an object by default', () => {
+ const error = new Error();
+ const unwrappedError = unwrapHandledError(error);
+ expect(isObject(unwrappedError)).toBe(true);
+ });
+
+ it('returns the data that was wrapped in the error', () => {
+ const handledError = new Error();
+ handledError[handledErrorSymbol] = mockHandledErrorData;
+ const unwrapped = unwrapHandledError(handledError);
+ expect(unwrapped).toEqual(mockHandledErrorData);
+ });
+ });
+
+ describe('handleAPIError', () => {
+ let reportMock;
+ beforeEach(() => {
+ reportMock = sinon.spy();
+ });
+
+ it('returns handled error data', () => {
+ expect.assertions(3);
+ const axiosErrorMock = {
+ response: {
+ status: 400
+ }
+ };
+ const result = handleAPIError(
+ axiosErrorMock,
+ { redirectTo: '/' },
+ reportMock
+ );
+ expect(result).toHaveProperty('type');
+ expect(result).toHaveProperty('message');
+ expect(result).toHaveProperty('redirectTo');
+ });
+
+ it('does not report 4** errors', () => {
+ expect.assertions(1);
+ for (let i = 400; i < 500; i++) {
+ const axiosErrorMock = {
+ response: {
+ status: i
+ }
+ };
+ handleAPIError(axiosErrorMock, { redirectTo: '/' }, reportMock);
+ }
+ expect(reportMock.called).toBe(false);
+ });
+
+ it('reports on 5** errors', () => {
+ const axiosErrorMock = {
+ response: {
+ status: 502
+ }
+ };
+ handleAPIError(axiosErrorMock, { redirectTo: '/' }, reportMock);
+ expect(reportMock.calledOnce).toBe(true);
+ });
+
+ it('returns a `reportedErrorMessage` for a 5** error', () => {
+ const axiosErrorMock = {
+ response: {
+ status: 502
+ }
+ };
+ const result = handleAPIError(
+ axiosErrorMock,
+ { redirectTo: '/' },
+ reportMock
+ );
+ expect(result).toEqual({ ...reportedErrorMessage, redirectTo: '/' });
+ });
+
+ it('respects a `null` redirectTo', () => {
+ const axiosErrorMock = {
+ response: {
+ status: 400
+ }
+ };
+ const result = handleAPIError(
+ axiosErrorMock,
+ { redirectTo: null },
+ reportMock
+ );
+ expect(result.redirectTo).toBe(null);
+ });
+ });
+ });
+});
diff --git a/client/src/utils/index.js b/client/src/utils/index.js
index d9d2a7fa53..84b6546c43 100644
--- a/client/src/utils/index.js
+++ b/client/src/utils/index.js
@@ -1,6 +1,22 @@
+import { findIndex } from 'lodash';
+
// These regex are not for validation, it is purely to see
// if we are looking at something like what we want to validate
// before we try to validate
export const maybeEmailRE = /.*@.*\.\w\w/;
export const maybeUrlRE = /https?:\/\/.*\..*/;
export const hasProtocolRE = /^http/;
+
+export const getShortIdFromSlug = (slug = '') => {
+ const slugToArray = [...slug];
+ const endOfUseableSlug = findIndex(
+ slugToArray,
+ char => char === '?' || char === '#'
+ );
+ let operableSlug = slug.slice(0);
+ if (endOfUseableSlug !== -1) {
+ operableSlug = slug.slice(0, endOfUseableSlug);
+ }
+ const [, maybeShortId = ''] = operableSlug.split('--');
+ return maybeShortId.replace(/\/*$/, '');
+};
diff --git a/client/src/utils/report-error.js b/client/src/utils/report-error.js
new file mode 100644
index 0000000000..7485acf433
--- /dev/null
+++ b/client/src/utils/report-error.js
@@ -0,0 +1,7 @@
+/* global Rollbar ENVIRONMENT */
+
+export function reportClientSideError(e, message = 'Unhandled error') {
+ return ENVIRONMENT === 'production'
+ ? Rollbar.error(`Client: ${message}`, e)
+ : console.error(`Stub Rollbar call - Client: ${message}`, e);
+}
diff --git a/client/src/utils/reportedErrorMessage.js b/client/src/utils/reportedErrorMessage.js
new file mode 100644
index 0000000000..b7d750e09a
--- /dev/null
+++ b/client/src/utils/reportedErrorMessage.js
@@ -0,0 +1,6 @@
+export default {
+ type: 'danger',
+ message:
+ 'Something is not quite right. A report has been generated and the ' +
+ 'freeCodeCamp.org team have been notified.'
+};
diff --git a/client/src/utils/utils.test.js b/client/src/utils/utils.test.js
new file mode 100644
index 0000000000..a63e70a792
--- /dev/null
+++ b/client/src/utils/utils.test.js
@@ -0,0 +1,45 @@
+/* global describe it expect */
+import {
+ slugWithId,
+ slugWithIdAndHash,
+ slugWithIdAndQuery,
+ slugWithIdAndTrailingSlash,
+ slugWithoutId,
+ mockId
+} from '../__mocks__/news-article';
+
+import { getShortIdFromSlug } from './';
+
+describe('client/src utilities', () => {
+ describe('getShortIdFromSlug', () => {
+ const emptyString = '';
+ it('returns a string', () => {
+ expect(typeof getShortIdFromSlug()).toEqual('string');
+ });
+
+ it('extracts the shortId when one is present', () => {
+ const result = getShortIdFromSlug(slugWithId);
+ expect(result).toEqual(mockId);
+ });
+
+ it('still returns a string when no id is found', () => {
+ const result = getShortIdFromSlug(slugWithoutId);
+ expect(result).toEqual(emptyString);
+ });
+
+ it('can handle query', () => {
+ const result = getShortIdFromSlug(slugWithIdAndQuery);
+ expect(result).toEqual(mockId);
+ });
+
+ it('can handle hash', () => {
+ const result = getShortIdFromSlug(slugWithIdAndHash);
+ expect(result).toEqual(mockId);
+ });
+
+ it('can handle trails slashes', () => {
+ const result = getShortIdFromSlug(slugWithIdAndTrailingSlash);
+ expect(result).toEqual(mockId);
+ });
+ });
+});
diff --git a/client/utils/gatsby/challengePageCreator.js b/client/utils/gatsby/challengePageCreator.js
new file mode 100644
index 0000000000..203e68feb2
--- /dev/null
+++ b/client/utils/gatsby/challengePageCreator.js
@@ -0,0 +1,114 @@
+const path = require('path');
+const { dasherize } = require('..');
+
+const { viewTypes } = require('../challengeTypes');
+
+const backend = path.resolve(
+ __dirname,
+ '../../src/templates/Challenges/backend/Show.js'
+);
+const classic = path.resolve(
+ __dirname,
+ '../../src/templates/Challenges/classic/Show.js'
+);
+const project = path.resolve(
+ __dirname,
+ '../../src/templates/Challenges/project/Show.js'
+);
+const intro = path.resolve(
+ __dirname,
+ '../../src/templates/Introduction/Intro.js'
+);
+const superBlockIntro = path.resolve(
+ __dirname,
+ '../../src/templates/Introduction/SuperBlockIntro.js'
+);
+
+const views = {
+ backend,
+ classic,
+ modern: classic,
+ project
+ // quiz: Quiz
+};
+
+const getNextChallengePath = (node, index, nodeArray) => {
+ const next = nodeArray[index + 1];
+ return next ? next.node.fields.slug : '/';
+};
+const getTemplateComponent = challengeType => views[viewTypes[challengeType]];
+
+const getIntroIfRequired = (node, index, nodeArray) => {
+ const next = nodeArray[index + 1];
+ const isEndOfBlock = next && next.node.challengeOrder === 0;
+ let nextSuperBlock = '';
+ let nextBlock = '';
+ if (next) {
+ const { superBlock, block } = next.node;
+ nextSuperBlock = superBlock;
+ nextBlock = block;
+ }
+ return isEndOfBlock
+ ? `/learn/${dasherize(nextSuperBlock)}/${dasherize(nextBlock)}`
+ : '';
+};
+
+exports.createChallengePages = createPage => ({ node }, index, thisArray) => {
+ const {
+ fields: { slug },
+ required = [],
+ template,
+ challengeType,
+ id
+ } = node;
+ if (challengeType === 7) {
+ return null;
+ }
+
+ return createPage({
+ path: slug,
+ component: getTemplateComponent(challengeType),
+ context: {
+ challengeMeta: {
+ introPath: getIntroIfRequired(node, index, thisArray),
+ template,
+ required,
+ nextChallengePath: getNextChallengePath(node, index, thisArray),
+ id
+ },
+ slug
+ }
+ });
+};
+
+exports.createBlockIntroPages = createPage => edge => {
+ const {
+ fields: { slug },
+ frontmatter: { block }
+ } = edge.node;
+
+ return createPage({
+ path: slug,
+ component: intro,
+ context: {
+ block: dasherize(block),
+ slug
+ }
+ });
+};
+
+exports.createSuperBlockIntroPages = createPage => edge => {
+ const {
+ fields: { slug },
+ frontmatter: { superBlock }
+ } = edge.node;
+
+ return createPage({
+ path: slug,
+ component: superBlockIntro,
+ context: {
+ superBlock: dasherize(superBlock),
+ slug
+ }
+ });
+};
diff --git a/client/utils/gatsby/guidePageCreator.js b/client/utils/gatsby/guidePageCreator.js
new file mode 100644
index 0000000000..bebf6e3c21
--- /dev/null
+++ b/client/utils/gatsby/guidePageCreator.js
@@ -0,0 +1,41 @@
+const path = require('path');
+const select = require('unist-util-select');
+const { head } = require('lodash');
+
+const { isAStubRE } = require('../regEx');
+
+const guideArticle = path.resolve(
+ __dirname,
+ '../../src/templates/Guide/GuideArticle.js'
+);
+
+exports.createGuideArticlePages = createPage => ({
+ node: {
+ htmlAst,
+ excerpt,
+ fields: { slug },
+ id
+ }
+}) => {
+ let meta = {};
+
+ if (!isAStubRE.test(excerpt)) {
+ const featureImage = head(select(htmlAst, 'element[tagName=img]'));
+ meta.featureImage = featureImage
+ ? featureImage.properties.src
+ : 'https://s3.amazonaws.com/freecodecamp' +
+ '/reecodecamp-square-logo-large.jpg';
+
+ const description = head(select(htmlAst, 'element[tagName=p]'));
+ meta.description = description ? description.children[0].value : '';
+ }
+
+ return createPage({
+ path: `/guide${slug}`,
+ component: guideArticle,
+ context: {
+ id,
+ meta
+ }
+ });
+};
diff --git a/client/utils/gatsby/index.js b/client/utils/gatsby/index.js
index 79b77a40e3..60854efda6 100644
--- a/client/utils/gatsby/index.js
+++ b/client/utils/gatsby/index.js
@@ -1,154 +1,9 @@
-const path = require('path');
-const select = require('unist-util-select');
-const { head } = require('lodash');
+const challengePageCreators = require('./challengePageCreator');
+const guidePageCreators = require('./guidePageCreator');
+const newsPageCreators = require('./newsPageCreator');
-const { dasherize } = require('..');
-const { isAStubRE } = require('../regEx');
-
-const { viewTypes } = require('../challengeTypes');
-
-const backend = path.resolve(
- __dirname,
- '../../src/templates/Challenges/backend/Show.js'
-);
-const classic = path.resolve(
- __dirname,
- '../../src/templates/Challenges/classic/Show.js'
-);
-const project = path.resolve(
- __dirname,
- '../../src/templates/Challenges/project/Show.js'
-);
-const intro = path.resolve(
- __dirname,
- '../../src/templates/Introduction/Intro.js'
-);
-const superBlockIntro = path.resolve(
- __dirname,
- '../../src/templates/Introduction/SuperBlockIntro.js'
-);
-
-const guideArticle = path.resolve(
- __dirname,
- '../../src/templates/Guide/GuideArticle.js'
-);
-
-const views = {
- backend,
- classic,
- modern: classic,
- project
- // quiz: Quiz
-};
-
-const getNextChallengePath = (node, index, nodeArray) => {
- const next = nodeArray[index + 1];
- return next ? next.node.fields.slug : '/';
-};
-const getTemplateComponent = challengeType => views[viewTypes[challengeType]];
-
-const getIntroIfRequired = (node, index, nodeArray) => {
- const next = nodeArray[index + 1];
- const isEndOfBlock = next && next.node.challengeOrder === 0;
- let nextSuperBlock = '';
- let nextBlock = '';
- if (next) {
- const { superBlock, block } = next.node;
- nextSuperBlock = superBlock;
- nextBlock = block;
- }
- return isEndOfBlock
- ? `/learn/${dasherize(nextSuperBlock)}/${dasherize(nextBlock)}`
- : '';
-};
-
-exports.createChallengePages = createPage => ({ node }, index, thisArray) => {
- const {
- fields: { slug },
- required = [],
- template,
- challengeType,
- id
- } = node;
- if (challengeType === 7) {
- return null;
- }
-
- return createPage({
- path: slug,
- component: getTemplateComponent(challengeType),
- context: {
- challengeMeta: {
- introPath: getIntroIfRequired(node, index, thisArray),
- template,
- required,
- nextChallengePath: getNextChallengePath(node, index, thisArray),
- id
- },
- slug
- }
- });
-};
-
-exports.createBlockIntroPages = createPage => edge => {
- const {
- fields: { slug },
- frontmatter: { block }
- } = edge.node;
-
- return createPage({
- path: slug,
- component: intro,
- context: {
- block: dasherize(block),
- slug
- }
- });
-};
-
-exports.createSuperBlockIntroPages = createPage => edge => {
- const {
- fields: { slug },
- frontmatter: { superBlock }
- } = edge.node;
-
- return createPage({
- path: slug,
- component: superBlockIntro,
- context: {
- superBlock: dasherize(superBlock),
- slug
- }
- });
-};
-
-exports.createGuideArticlePages = createPage => ({
- node: {
- htmlAst,
- excerpt,
- fields: { slug },
- id
- }
-}) => {
- let meta = {};
-
- if (!isAStubRE.test(excerpt)) {
- const featureImage = head(select(htmlAst, 'element[tagName=img]'));
- meta.featureImage = featureImage
- ? featureImage.properties.src
- : 'https://s3.amazonaws.com/freecodecamp' +
- '/reecodecamp-square-logo-large.jpg';
-
- const description = head(select(htmlAst, 'element[tagName=p]'));
- meta.description = description ? description.children[0].value : '';
- }
-
- return createPage({
- path: `/guide${slug}`,
- component: guideArticle,
- context: {
- id,
- meta
- }
- });
+module.exports = {
+ ...challengePageCreators,
+ ...guidePageCreators,
+ ...newsPageCreators
};
diff --git a/client/utils/gatsby/newsPageCreator.js b/client/utils/gatsby/newsPageCreator.js
new file mode 100644
index 0000000000..1b8e15ac08
--- /dev/null
+++ b/client/utils/gatsby/newsPageCreator.js
@@ -0,0 +1,22 @@
+const path = require('path');
+
+const newsArticle = path.resolve(
+ __dirname, '../../src/templates/News/ShowArticle/index.js'
+);
+
+exports.createNewsArticle = createPage => ({
+ node: {
+ fields: { slug },
+ shortId,
+ id
+ }
+}) =>
+ createPage({
+ path: slug,
+ component: newsArticle,
+ context: {
+ slug,
+ shortId,
+ id
+ }
+ });
diff --git a/client/utils/news.js b/client/utils/news.js
new file mode 100644
index 0000000000..1187f78e0f
--- /dev/null
+++ b/client/utils/news.js
@@ -0,0 +1,18 @@
+exports.createArticleSlug = ({
+ username = '',
+ slugPart = '',
+ shortId = ''
+} = {}) => {
+ if (!username || !slugPart || !shortId) {
+ throw new Error(`
+ createArtcileSlug: One or more properties were missing, all are required
+
+ {
+ username: ${username},
+ slugPart: ${slugPart},
+ shortId: ${shortId}
+ }
+`);
+ }
+ return `/news/${username}/${slugPart.concat('--', shortId)}`;
+};
diff --git a/client/utils/news.test.js b/client/utils/news.test.js
new file mode 100644
index 0000000000..1d131fc91c
--- /dev/null
+++ b/client/utils/news.test.js
@@ -0,0 +1,32 @@
+/* global describe it expect */
+import { mockArguments, slugWithId} from '../src/__mocks__/news-article';
+import { createArticleSlug } from './news';
+
+describe('news utils', () => {
+ describe('createArticleSlug', () => {
+
+ it('returns a string', () => {
+ expect(typeof createArticleSlug(mockArguments)).toEqual('string');
+ });
+
+ it('throws when values are missing', () => {
+ expect.assertions(3);
+ /* eslint-disable no-undefined */
+ expect(() =>
+ createArticleSlug({ ...mockArguments, shortId: undefined })
+ ).toThrow();
+ expect(() =>
+ createArticleSlug({ ...mockArguments, slugPart: undefined })
+ ).toThrow();
+ expect(() =>
+ createArticleSlug({ ...mockArguments, username: undefined })
+ ).toThrow();
+ });
+
+ it('creates a slug in the expected format', () => {
+ const result = createArticleSlug(mockArguments);
+
+ expect(result).toEqual(slugWithId);
+ });
+ });
+});
diff --git a/config/secrets.js b/config/secrets.js
index a0f4559fd9..4fa2b1a4dc 100644
--- a/config/secrets.js
+++ b/config/secrets.js
@@ -27,6 +27,9 @@ const {
TWITTER_TOKEN,
TWITTER_TOKEN_SECRET,
+ ROLLBAR_APP_ID,
+ ROLLBAR_CLIENT_ID,
+
STRIPE_PUBLIC,
STRIPE_SECRET
} = process.env;
@@ -83,6 +86,11 @@ module.exports = {
passReqToCallback: true
},
+ rollbar: {
+ appId: ROLLBAR_APP_ID,
+ clientId: ROLLBAR_CLIENT_ID
+ },
+
stripe: {
public: STRIPE_PUBLIC,
secret: STRIPE_SECRET
diff --git a/lerna.json b/lerna.json
index a47fc8ba66..b8bc906675 100644
--- a/lerna.json
+++ b/lerna.json
@@ -2,6 +2,7 @@
"packages": [
"api-server",
"client",
+ "client/plugins/*",
"curriculum",
"tools/challenge-md-parser",
"tools/scripts/seed"
diff --git a/news/client.js b/news/client.js
deleted file mode 100644
index 5c0697efb7..0000000000
--- a/news/client.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import {BrowserRouter} from 'react-router-dom';
-import { render } from 'react-dom';
-
-import NewsApp from './NewsApp';
-
-const newsMountPoint = document.getElementById('news-app-mount');
-
-const App = (
-
-
-
-);
-
-render(
- App,
- newsMountPoint
-);
diff --git a/news/components/Nav/LargeNav.js b/news/components/Nav/LargeNav.js
deleted file mode 100644
index f6d431a528..0000000000
--- a/news/components/Nav/LargeNav.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from 'react';
-import Media from 'react-media';
-import { Col, Navbar, Row } from 'react-bootstrap';
-import FCCSearchBar from 'react-freecodecamp-search';
-import NavLogo from './components/NavLogo';
-import NavLinks from './components/NavLinks';
-
-import propTypes from './navPropTypes';
-
-function LargeNav({ clickOnLogo }) {
- return (
- (
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
- }
- />
- );
-}
-
-LargeNav.displayName = 'LargeNav';
-LargeNav.propTypes = propTypes;
-
-export default LargeNav;
diff --git a/news/components/Nav/MediumNav.js b/news/components/Nav/MediumNav.js
deleted file mode 100644
index fd03c86802..0000000000
--- a/news/components/Nav/MediumNav.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react';
-import Media from 'react-media';
-import { Navbar, Row } from 'react-bootstrap';
-import FCCSearchBar from 'react-freecodecamp-search';
-import NavLogo from './components/NavLogo';
-import NavLinks from './components/NavLinks';
-import propTypes from './navPropTypes';
-
-function MediumNav({ clickOnLogo }) {
- return (
-
- {
- matches => matches && typeof window !== 'undefined' && (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
- }
-
- );
-}
-
-MediumNav.displayName = 'MediumNav';
-MediumNav.propTypes = propTypes;
-
-export default MediumNav;
diff --git a/news/components/Nav/Nav.js b/news/components/Nav/Nav.js
deleted file mode 100644
index 5ad44e7036..0000000000
--- a/news/components/Nav/Nav.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-import { Navbar } from 'react-bootstrap';
-
-import LargeNav from './LargeNav';
-import MediumNav from './MediumNav';
-import SmallNav from './SmallNav';
-
-const allNavs = [
- LargeNav,
- MediumNav,
- SmallNav
-];
-
-export default function FCCNav() {
- const withNavProps = Component => (
-
- );
- return (
-
- {
- allNavs.map(withNavProps)
- }
-
- );
-}
diff --git a/news/components/Nav/SmallNav.js b/news/components/Nav/SmallNav.js
deleted file mode 100644
index 8c988aa919..0000000000
--- a/news/components/Nav/SmallNav.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import React from 'react';
-import Media from 'react-media';
-import { Navbar, Row } from 'react-bootstrap';
-import FCCSearchBar from 'react-freecodecamp-search';
-import NavLogo from './components/NavLogo';
-import NavLinks from './components/NavLinks';
-
-import propTypes from './navPropTypes';
-
-function SmallNav({ clickOnLogo }) {
- return (
-
- {
- matches => matches && typeof window !== 'undefined' && (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
- }
-
- );
-}
-
-SmallNav.displayName = 'SmallNav';
-SmallNav.propTypes = propTypes;
-
-export default SmallNav;
diff --git a/news/components/Nav/components/NavLinks.js b/news/components/Nav/components/NavLinks.js
deleted file mode 100644
index c4c878fcd8..0000000000
--- a/news/components/Nav/components/NavLinks.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from 'react';
-import { NavItem, Nav } from 'react-bootstrap';
-import { startCase } from 'lodash';
-
-const urls = {
- curriculum: 'https://learn.freecodecamp.org',
- forum: 'https://forum.freecodecamp.org',
- news: 'https://freecodecamp.org/news'
-};
-
-const Links = Object.keys(urls).map(key => (
-
- {startCase(key)}
-
-));
-const propTypes = {};
-
-function NavLinks() {
- return (
-
- );
-}
-
-NavLinks.displayName = 'NavLinks';
-NavLinks.propTypes = propTypes;
-
-export default NavLinks;
-
-/*
-
- */
diff --git a/news/components/Nav/components/NavLogo.js b/news/components/Nav/components/NavLogo.js
deleted file mode 100644
index b65e484ce7..0000000000
--- a/news/components/Nav/components/NavLogo.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from 'react';
-import { NavbarBrand } from 'react-bootstrap';
-import Media from 'react-media';
-
-const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg';
-const fCCglyph = 'https://s3.amazonaws.com/freecodecamp/FFCFire.png';
-
-const propTypes = {};
-
-function NavLogo() {
- return (
-
-
-
- {
- matches => matches ? (
-
- ) : (
-
- )
- }
-
-
-
- );
-}
-
-NavLogo.displayName = 'NavLogo';
-NavLogo.propTypes = propTypes;
-
-export default NavLogo;
diff --git a/news/components/Nav/index.js b/news/components/Nav/index.js
deleted file mode 100644
index 13fa327162..0000000000
--- a/news/components/Nav/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './Nav';
diff --git a/news/components/Nav/navPropTypes.js b/news/components/Nav/navPropTypes.js
deleted file mode 100644
index 1f3348ba74..0000000000
--- a/news/components/Nav/navPropTypes.js
+++ /dev/null
@@ -1,3 +0,0 @@
-// import PropTypes from 'prop-types';
-
-export default {};
diff --git a/news/routes/EditArticle/EditArticle.js b/news/routes/EditArticle/EditArticle.js
deleted file mode 100644
index 1834f586d3..0000000000
--- a/news/routes/EditArticle/EditArticle.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from 'react';
-
-const propTypes = {};
-
-function NewArticle() {
- return (
- New Article
- );
-}
-
-NewArticle.displayName = 'NewArticle';
-NewArticle.propTypes = propTypes;
-
-export default NewArticle;
diff --git a/news/routes/EditArticle/index.js b/news/routes/EditArticle/index.js
deleted file mode 100644
index 31dec6e2db..0000000000
--- a/news/routes/EditArticle/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './EditArticle';
diff --git a/news/routes/Editor/index.js b/news/routes/Editor/index.js
deleted file mode 100644
index e89c38a7a0..0000000000
--- a/news/routes/Editor/index.js
+++ /dev/null
@@ -1 +0,0 @@
-// no-op
diff --git a/news/routes/Featured/Featured.js b/news/routes/Featured/Featured.js
deleted file mode 100644
index ac5dd9d506..0000000000
--- a/news/routes/Featured/Featured.js
+++ /dev/null
@@ -1,166 +0,0 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import { withRouter } from 'react-router-dom';
-import { Image } from 'react-bootstrap';
-import Helmet from 'react-helmet';
-
-import { getFeaturedList } from '../../utils/ajax';
-import { Loader, Spacer } from '../../../common/app/helperComponents';
-import BannerWide from '../../components/BannerWide';
-import ArticleMeta from '../../components/ArticleMeta';
-
-const propTypes = {
- history: PropTypes.shape({
- push: PropTypes.func.isRequired
- })
-};
-
-const styles = `
- .featured-list {
- list-style: none;
- padding-left: 0;
- margin-top: 40px;
- }
-
- .featured-list-item {
- padding-bottom: 20px;
- }
-
- .featured-list-item .title {
- color: #333;
- padding-bottom: 20px;
- }
-
- .featured-list-item a {
- padding-top: 5px;
- }
-
- .featured-list-image {
- margin: 0 auto;
- }
-
- .featured-list-item a:hover,
- .featured-list-item a:focus {
- text-decoration: none;
- text-decoration-line: none;
- text-decoration-color: transparaent;
- }
- .featured-list-item a:hover > .meta-wrapper,
- .featured-list-item a:focus > .meta-wrapper {
- color: #006400;
- }
-`;
-
-class Featured extends Component {
- constructor(props) {
- super(props);
-
- this.state = {
- fetchState: {
- pending: false,
- complete: false,
- errored: false,
- error: null
- },
- featuredList: []
- };
-
- this.fetchFeaturedList = this.fetchFeaturedList.bind(this);
- }
-
- componentDidMount() {
- return this.fetchFeaturedList();
- }
-
- fetchFeaturedList() {
- return this.setState(
- {
- fetchState: { pending: true, complete: false, errored: false }
- },
- () =>
- getFeaturedList().then(({ data }) =>
- this.setState({
- featuredList: data,
- fetchState: {
- pending: false,
- complete: true,
- errored: false,
- error: null
- }
- })
- )
- );
- }
-
- createHandleArticleClick(slug, article) {
- const { history } = this.props;
- return e => {
- e.preventDefault();
- return history.push({ pathname: slug, state: { article } });
- };
- }
-
- renderFeatured(articles) {
- return articles.map(article => {
- const slug = `/${article.author.username}/`.concat(
- article.slugPart,
- '--',
- article.shortId
- );
- const { featureImage, shortId, title } = article;
- return (
-
-
- {title}
- {featureImage && featureImage.src ? (
-
- ) : (
-
- )}
-
-
-
-
- );
- });
- }
-
- render() {
- const {
- fetchState: { pending, complete, errored },
- featuredList
- } = this.state;
- if (pending || !complete) {
- return (
-
-
-
- );
- }
-
- if (complete && errored) {
- return Oh noes!! Something went wrong!
;
- }
- return (
-
-
-
- Featured | freeCodeCamp News
-
-
{this.renderFeatured(featuredList)}
-
- );
- }
-}
-
-Featured.displayName = 'Featured';
-Featured.propTypes = propTypes;
-
-export default withRouter(Featured);
diff --git a/news/routes/Latest/Latest.js b/news/routes/Latest/Latest.js
deleted file mode 100644
index 1eeef9108e..0000000000
--- a/news/routes/Latest/Latest.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from 'react';
-
-const propTypes = {};
-
-function Latest() {
- return (
- Latest
- );
-}
-
-Latest.displayName = 'Latest';
-Latest.propTypes = propTypes;
-
-export default Latest;
diff --git a/news/routes/Latest/index.js b/news/routes/Latest/index.js
deleted file mode 100644
index 138e047933..0000000000
--- a/news/routes/Latest/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './Latest';
diff --git a/news/routes/Show/Show.js b/news/routes/Show/Show.js
deleted file mode 100644
index 6412583b09..0000000000
--- a/news/routes/Show/Show.js
+++ /dev/null
@@ -1,237 +0,0 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import { withRouter } from 'react-router-dom';
-import Helmet from 'react-helmet';
-import Youtube from 'react-youtube';
-import { Image } from 'react-bootstrap';
-
-import Author from './components/Author';
-import { Loader, Spacer } from '../../../common/app/helperComponents';
-import { getArticleById, postPopularityEvent } from '../../utils/ajax';
-
-const propTypes = {
- history: PropTypes.shape({
- push: PropTypes.func.isRequired
- }),
- location: PropTypes.shape({
- state: PropTypes.object,
- pathname: PropTypes.string
- }),
- match: PropTypes.shape({
- params: PropTypes.shape({
- username: PropTypes.string,
- slug: PropTypes.string
- })
- })
-};
-
-const youtubeOpts = {
- playerVars: {
- // https://developers.google.com/youtube/player_parameters
- autoplay: 0
- }
-};
-
-const styles = `
-
- .show-article figure {
- display: flex;
- flex-direction: column;
- align-items: center;
- }
-
- .show-article figcaption > * {
- font-size: 16px;
- }
-
- .show-article figcaption {
- padding-top: 5px;
- }
-
- .show-article a {
- text-decoration: underline;
- }
-
- .feature-image-wrapper {
- padding-top: 32px;
- }
- .youtube-wrapper {
- position: relative;
- padding-bottom: 56.25%; /* 16:9 */
- padding-top: 25px;
- height: 0;
- overflow: hidden;
- }
- .youtube-wrapper iframe {
- position: absolute;
- left: 0;
- width: 100%;
- height: 95%;
- }
-`;
-
-class ShowArticle extends Component {
- constructor(props) {
- super(props);
-
- this.state = {
- fetchState: {
- pending: false,
- complete: false,
- errored: false,
- error: null
- },
- currentArticle: {},
- requiredArticle: ''
- };
-
- this.fetchArticle = this.fetchArticle.bind(this);
- }
-
- componentDidMount() {
- window.scrollTo(0, 0);
- const {
- history,
- match: {
- params: { username, slug }
- },
- location: { state: { article } = {} }
- } = this.props;
-
- if (username && !slug) {
- return history.push('/');
- }
- const [, shortId] = slug.split('--');
- postPopularityEvent({
- event: 'view',
- timestamp: Date.now(),
- shortId
- });
- if (article) {
- /* eslint-disable react/no-did-mount-set-state */
- return this.setState(
- {
- fetchState: {
- complete: true
- },
- currentArticle: article,
- requiredArticle: shortId
- },
- () => {
- window.location.state = null;
- }
- );
- }
- return this.fetchArticle();
- }
-
- fetchArticle() {
- const {
- match: {
- params: { slug }
- }
- } = this.props;
-
- const [, shortId] = slug.split('--');
- return this.setState(
- {
- requiredArticle: shortId,
- fetchState: { pending: true, complete: false, errored: false }
- },
- () =>
- getArticleById(shortId)
- .then(({ data }) =>
- this.setState({
- currentArticle: data,
- fetchState: {
- pending: false,
- complete: true,
- errored: false,
- error: null
- }
- })
- )
- .catch(console.error)
- );
- }
-
- youtubeReady(event) {
- event.target.pauseVideo();
- }
-
- render() {
- const {
- fetchState: { pending, complete, errored },
- currentArticle: { title, renderableContent, youtubeId, featureImage },
- currentArticle
- } = this.state;
- if (pending || !complete) {
- return (
-
-
-
- );
- }
-
- if (complete && errored) {
- return Oh noes!! Something went wrong!
;
- }
-
- // RegEx finds the first paragraph and groups the content
- const description = renderableContent.match(/(.*?)<\/p>/)[1];
- const slug = this.props.location.pathname;
- return (
-
-
-
- {`${title} | freeCodeCamp News`}
-
-
-
-
-
-
-
-
- {title}
-
-
-
- {featureImage.caption ? (
-
- ) : null}
-
-
-
-
-
- {youtubeId ? (
-
- ) : null}
-
-
-
- );
- }
-}
-
-ShowArticle.displayName = 'ShowArticle';
-ShowArticle.propTypes = propTypes;
-
-export default withRouter(ShowArticle);
diff --git a/news/routes/Show/index.js b/news/routes/Show/index.js
deleted file mode 100644
index b11c5cd2b4..0000000000
--- a/news/routes/Show/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './Show';
diff --git a/news/routes/index.js b/news/routes/index.js
deleted file mode 100644
index cfa856166f..0000000000
--- a/news/routes/index.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from 'react';
-import { Route, Redirect } from 'react-router-dom';
-
-import Show from './Show';
-import Featured from './Featured';
-
-export const routes = [
- ,
- } />,
-
-].map(el => ({ ...el, key: el.props.path }));
-
-//
diff --git a/news/utils/ajax.js b/news/utils/ajax.js
deleted file mode 100644
index 05cca79ee9..0000000000
--- a/news/utils/ajax.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import axios from 'axios';
-import qs from 'query-string';
-
-export function getArticleById(shortId) {
- return axios.get(
- `/api/articles/findOne?${qs.stringify({
- filter: JSON.stringify({ where: { shortId } })
- })}`
- );
-}
-
-export function getFeaturedList(skip = 0) {
- return axios.get(
- `/api/articles?${qs.stringify({
- filter: JSON.stringify({
- where: { featured: true, published: true },
- order: 'firstPublishedDate DESC',
- limit: 10,
- skip
- })
- })}`
- );
-}
-
-export function postPopularityEvent(event) {
- return axios.post('/p', event);
-}
diff --git a/sample.env b/sample.env
index 3945ba7a21..4edd3a5dd7 100644
--- a/sample.env
+++ b/sample.env
@@ -2,6 +2,7 @@ COMPOSE_PROJECT_NAME=freecodecamp
MONGOHQ_URL='mongodb://localhost:27017/freecodecamp'
ROLLBAR_APP_ID='my-rollbar-app-id'
+ROLLBAR_CLIENT_ID='post_client_id from rollbar dashboard'
AUTH0_CLIENT_ID=stuff
AUTH0_CLIENT_SECRET=stuff
diff --git a/tools/scripts/seed/seedNewsArticles.js b/tools/scripts/seed/seedNewsArticles.js
index 0733574b1c..8d8760ae38 100644
--- a/tools/scripts/seed/seedNewsArticles.js
+++ b/tools/scripts/seed/seedNewsArticles.js
@@ -2,13 +2,19 @@ const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../../../.env') });
const MongoClient = require('mongodb').MongoClient;
const faker = require('faker');
-const shortId = require('shortid');
+const shortid = require('shortid');
const slugg = require('slugg');
const { homeLocation } = require('../../../config/env.json');
const debug = require('debug');
const log = debug('fcc:tools:seedNewsArticles');
+shortid.characters(
+ '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$+'
+);
+
+const shortId = () => shortid.generate();
+
const { MONGOHQ_URL, NODE_ENV: env } = process.env;
function handleError(err, client) {
@@ -61,7 +67,7 @@ const sixMonths = 15780000000;
function generateArticle() {
const now = Date.now();
- const id = shortId.generate();
+ const id = shortId();
const title = faker.lorem.sentence();
const paragraphs = faker.random.number(10) || 1;
const arrayToLoopOver = new Array(paragraphs).fill('');
@@ -79,7 +85,7 @@ function generateArticle() {
username: faker.internet.userName()
},
featureImage: {
- src: faker.image.image(2000, 1300),
+ src: 'https://picsum.photos/2000/1300?random',
alt: faker.lorem.sentence(),
caption: paragraphs >= 5 ? faker.lorem.sentence() : ''
},
@@ -92,7 +98,7 @@ function generateArticle() {
() => `${faker.lorem.paragraph()}
`
),
published: true,
- featured: true,
+ featured: Math.random() < 0.6,
underReview: false,
viewCount: faker.random.number(90000),
firstPublishedDate: fakeDate,
diff --git a/tools/scripts/start-develop.js b/tools/scripts/start-develop.js
index 754aacd2a0..aeb58dcbc1 100644
--- a/tools/scripts/start-develop.js
+++ b/tools/scripts/start-develop.js
@@ -1,4 +1,5 @@
-require('dotenv').config();
+const path = require('path');
+require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
const { spawn } = require('child_process');
const kill = require('tree-kill');