feat: remove news from platform
This commit is contained in:
committed by
Stuart Taylor
parent
219abdc2ce
commit
fdc2219f81
@ -1,34 +1,28 @@
|
|||||||
import { has, pick, isEmpty } from 'lodash';
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { reportError } from '../middlewares/error-reporter';
|
|
||||||
|
|
||||||
const log = debug('fcc:boot:news');
|
const log = debug('fcc:boot:news');
|
||||||
|
|
||||||
export default function newsBoot(app) {
|
export default function newsBoot(app) {
|
||||||
const api = app.loopback.Router();
|
const router = app.loopback.Router();
|
||||||
|
|
||||||
api.get('/n/:shortId', createShortLinkHandler(app));
|
router.get('/n', (req, res) => res.redirect('/news'));
|
||||||
|
router.get('/n/:shortId', createShortLinkHandler(app));
|
||||||
api.post('/p', createPopularityHandler(app));
|
|
||||||
|
|
||||||
app.use('/internal', api);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createShortLinkHandler(app) {
|
function createShortLinkHandler(app) {
|
||||||
const { Article } = app.models;
|
const { Article } = app.models;
|
||||||
|
|
||||||
const referralHandler = createReferralHandler(app);
|
|
||||||
|
|
||||||
return function shortLinkHandler(req, res, next) {
|
return function shortLinkHandler(req, res, next) {
|
||||||
const { query, user } = req;
|
const { query } = req;
|
||||||
const { shortId } = req.params;
|
const { shortId } = req.params;
|
||||||
|
|
||||||
// We manually report the error here as it should not affect this request
|
log(req.origin);
|
||||||
referralHandler(query, shortId, !!user).catch(err => reportError(err));
|
log(query.refsource);
|
||||||
|
|
||||||
if (!shortId) {
|
if (!shortId) {
|
||||||
return res.sendStatus(400);
|
return res.redirect('/news');
|
||||||
}
|
}
|
||||||
|
log('shortId', shortId);
|
||||||
return Article.findOne(
|
return Article.findOne(
|
||||||
{
|
{
|
||||||
where: {
|
where: {
|
||||||
@ -40,220 +34,14 @@ function createShortLinkHandler(app) {
|
|||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
if (!article) {
|
if (!article) {
|
||||||
return res.status(404).send('Could not find article by shortId');
|
return res.redirect('/news');
|
||||||
}
|
}
|
||||||
const {
|
const {
|
||||||
slugPart,
|
slugPart
|
||||||
shortId,
|
|
||||||
author: { username }
|
|
||||||
} = article;
|
} = article;
|
||||||
const slug = `/news/${username}/${slugPart}--${shortId}`;
|
const slug = `/news/${slugPart}`;
|
||||||
const articleData = {
|
return res.redirect(slug);
|
||||||
...pick(article, [
|
|
||||||
'author',
|
|
||||||
'renderableContent',
|
|
||||||
'firstPublishedDate',
|
|
||||||
'viewCount',
|
|
||||||
'title',
|
|
||||||
'featureImage',
|
|
||||||
'slugPart',
|
|
||||||
'shortId',
|
|
||||||
'meta'
|
|
||||||
]),
|
|
||||||
slug
|
|
||||||
};
|
|
||||||
return res.json(articleData);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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();
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
@ -12,10 +12,8 @@ const pathsOfNoReturn = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const pathsWhiteList = [
|
const pathsWhiteList = [
|
||||||
'news',
|
|
||||||
'challenges',
|
'challenges',
|
||||||
'map',
|
'map',
|
||||||
'news',
|
|
||||||
'commit'
|
'commit'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -46,12 +46,6 @@ module.exports = {
|
|||||||
curriculumPath: localeChallengesRootDir
|
curriculumPath: localeChallengesRootDir
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
resolve: 'fcc-source-news',
|
|
||||||
options: {
|
|
||||||
maximumStaticRenderCount: 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
resolve: 'gatsby-source-filesystem',
|
resolve: 'gatsby-source-filesystem',
|
||||||
options: {
|
options: {
|
||||||
|
@ -8,10 +8,8 @@ const {
|
|||||||
createChallengePages,
|
createChallengePages,
|
||||||
createBlockIntroPages,
|
createBlockIntroPages,
|
||||||
createSuperBlockIntroPages,
|
createSuperBlockIntroPages,
|
||||||
createGuideArticlePages,
|
createGuideArticlePages
|
||||||
createNewsArticle
|
|
||||||
} = require('./utils/gatsby');
|
} = require('./utils/gatsby');
|
||||||
const { createArticleSlug } = require('./utils/news');
|
|
||||||
|
|
||||||
const createByIdentityMap = {
|
const createByIdentityMap = {
|
||||||
guideMarkdown: createGuideArticlePages,
|
guideMarkdown: createGuideArticlePages,
|
||||||
@ -37,15 +35,7 @@ exports.onCreateNode = function onCreateNode({ node, actions, getNode }) {
|
|||||||
createNodeField({ node, name: 'slug', value: slug });
|
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 }) {
|
exports.createPages = function createPages({ graphql, actions }) {
|
||||||
@ -97,19 +87,6 @@ exports.createPages = function createPages({ graphql, actions }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
allNewsArticleNode(
|
|
||||||
sort: { fields: firstPublishedDate, order: DESC }
|
|
||||||
) {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
shortId
|
|
||||||
fields {
|
|
||||||
slug
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`).then(result => {
|
`).then(result => {
|
||||||
if (result.errors) {
|
if (result.errors) {
|
||||||
@ -150,11 +127,6 @@ exports.createPages = function createPages({ graphql, actions }) {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create news article pages
|
|
||||||
result.data.allNewsArticleNode.edges.forEach(
|
|
||||||
createNewsArticle(createPage)
|
|
||||||
);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
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;
|
|
@ -1,55 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
80
client/plugins/fcc-source-news/package-lock.json
generated
80
client/plugins/fcc-source-news/package-lock.json
generated
@ -1,80 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "fcc-source-news",
|
|
||||||
"dependencies": {
|
|
||||||
"mongodb": "^3.1.9"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
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'
|
|
||||||
};
|
|
@ -1,33 +0,0 @@
|
|||||||
/* 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,89 +0,0 @@
|
|||||||
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 (
|
|
||||||
<Layout>
|
|
||||||
<div className='loader-wrapper'>
|
|
||||||
<Loader />
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return isEmpty(article) ? null : (
|
|
||||||
<ShowArticle {...this.getArticleAsGatsbyProps(article)} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DynamicNewsArticle.displayName = 'DynamicNewsArticle';
|
|
||||||
|
|
||||||
export default connect(
|
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(DynamicNewsArticle);
|
|
@ -22,7 +22,7 @@ function Header({ disableSettings }) {
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href='https://forum.freecodecamp.org'
|
href='https://www.freecodecamp.org/forum'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
>
|
>
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
import createRedirect from './createRedirect';
|
|
||||||
|
|
||||||
export default createRedirect('/news');
|
|
@ -2,18 +2,14 @@ import React from 'react';
|
|||||||
import { Router } from '@reach/router';
|
import { Router } from '@reach/router';
|
||||||
|
|
||||||
import NotFoundPage from '../components/FourOhFour';
|
import NotFoundPage from '../components/FourOhFour';
|
||||||
import RedirectNews from '../components/RedirectNews';
|
|
||||||
/* eslint-disable max-len */
|
/* eslint-disable max-len */
|
||||||
import ShowProfileOrFourOhFour from '../client-only-routes/ShowProfileOrFourOhFour';
|
import ShowProfileOrFourOhFour from '../client-only-routes/ShowProfileOrFourOhFour';
|
||||||
import ShowDynamicNewsOrFourOhFour from '../client-only-routes/ShowDynamicNewsOrFourOhFour';
|
|
||||||
/* eslint-enable max-len */
|
/* eslint-enable max-len */
|
||||||
|
|
||||||
function FourOhFourPage() {
|
function FourOhFourPage() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<ShowProfileOrFourOhFour path='/:maybeUser' />
|
<ShowProfileOrFourOhFour path='/:maybeUser' />
|
||||||
<ShowDynamicNewsOrFourOhFour path='/news/:author/:articleSlug' />
|
|
||||||
<RedirectNews path='/news/:author' />
|
|
||||||
<NotFoundPage default={true} />
|
<NotFoundPage default={true} />
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
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 (
|
|
||||||
<Router>
|
|
||||||
<NewsReferalLinkHandler path='/n/:shortId' />
|
|
||||||
<RedirectNews path='/n/:shortId/:splat' />
|
|
||||||
<RedirectNews path='/n' />
|
|
||||||
</Router>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,96 +0,0 @@
|
|||||||
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 (
|
|
||||||
<Layout>
|
|
||||||
<Grid>
|
|
||||||
<FullWidthRow>
|
|
||||||
<h1>News - freeCodeCamp.org</h1>
|
|
||||||
</FullWidthRow>
|
|
||||||
<FullWidthRow>
|
|
||||||
<Featured featuredList={articles} />
|
|
||||||
</FullWidthRow>
|
|
||||||
</Grid>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
@ -15,7 +15,6 @@ import {
|
|||||||
reducer as challenge,
|
reducer as challenge,
|
||||||
ns as challengeNameSpace
|
ns as challengeNameSpace
|
||||||
} from '../templates/Challenges/redux';
|
} from '../templates/Challenges/redux';
|
||||||
import { reducer as news, ns as newsNameSpace } from '../templates/News/redux';
|
|
||||||
|
|
||||||
export default combineReducers({
|
export default combineReducers({
|
||||||
[appNameSpace]: app,
|
[appNameSpace]: app,
|
||||||
@ -23,6 +22,5 @@ export default combineReducers({
|
|||||||
[curriculumMapNameSpace]: curriculumMap,
|
[curriculumMapNameSpace]: curriculumMap,
|
||||||
[flashNameSpace]: flash,
|
[flashNameSpace]: flash,
|
||||||
form: formReducer,
|
form: formReducer,
|
||||||
[newsNameSpace]: news,
|
|
||||||
[settingsNameSpace]: settings
|
[settingsNameSpace]: settings
|
||||||
});
|
});
|
||||||
|
@ -3,7 +3,6 @@ import { all } from 'redux-saga/effects';
|
|||||||
import errorSagas from './error-saga';
|
import errorSagas from './error-saga';
|
||||||
import { sagas as appSagas } from './';
|
import { sagas as appSagas } from './';
|
||||||
import { sagas as challengeSagas } from '../templates/Challenges/redux';
|
import { sagas as challengeSagas } from '../templates/Challenges/redux';
|
||||||
import { sagas as newsSagas } from '../templates/News/redux';
|
|
||||||
import { sagas as settingsSagas } from './settings';
|
import { sagas as settingsSagas } from './settings';
|
||||||
|
|
||||||
export default function* rootSaga() {
|
export default function* rootSaga() {
|
||||||
@ -11,7 +10,6 @@ export default function* rootSaga() {
|
|||||||
...errorSagas,
|
...errorSagas,
|
||||||
...appSagas,
|
...appSagas,
|
||||||
...challengeSagas,
|
...challengeSagas,
|
||||||
...newsSagas,
|
|
||||||
...settingsSagas
|
...settingsSagas
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
@ -1,62 +0,0 @@
|
|||||||
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 (
|
|
||||||
<li className='featured-list-item' key={shortId}>
|
|
||||||
<a
|
|
||||||
href={'/news' + slug}
|
|
||||||
onClick={this.createHandleArticleClick(slug)}
|
|
||||||
>
|
|
||||||
<h3 className='title'>{title}</h3>
|
|
||||||
{featureImage && featureImage.src ? (
|
|
||||||
<Image
|
|
||||||
className='featured-list-image'
|
|
||||||
responsive={true}
|
|
||||||
src={featureImage.src}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<BannerWide />
|
|
||||||
)}
|
|
||||||
<ArticleMeta article={article} />
|
|
||||||
</a>
|
|
||||||
<Spacer />
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { featuredList } = this.props;
|
|
||||||
return (
|
|
||||||
<ul className='featured-list'>{this.renderFeatured(featuredList)}</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Featured.displayName = 'Featured';
|
|
||||||
Featured.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default Featured;
|
|
@ -1,33 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
export { default } from './Featured';
|
|
@ -1,62 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Grid } from 'react-bootstrap';
|
|
||||||
import Helmet from 'react-helmet';
|
|
||||||
|
|
||||||
import { SlimWidthRow } from '../common/app/helperComponents';
|
|
||||||
import Nav from './components/Nav';
|
|
||||||
import { routes } from './routes';
|
|
||||||
|
|
||||||
const propTypes = {};
|
|
||||||
/* eslint-disable max-len */
|
|
||||||
const styles = `
|
|
||||||
.app-layout p,
|
|
||||||
.app-layout li,
|
|
||||||
.app-layout a,
|
|
||||||
.app-layout span {
|
|
||||||
font-size: 21.5px;
|
|
||||||
}
|
|
||||||
.app-layout hr {
|
|
||||||
background-image: linear-gradient(to right, rgba(0, 100, 0, 0), rgba(0, 100, 0, 0.75), rgba(0, 100, 0, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-layout p {
|
|
||||||
paddin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-layout h1, .app-layout h2, .app-layout h3, .app-layout h4, .app-layout h5, .app-layout h6
|
|
||||||
{
|
|
||||||
padding-top: 35px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-layout h1 {
|
|
||||||
font-size: 42px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-layout h2 {
|
|
||||||
font-size: 34px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-layout h3 {
|
|
||||||
font-size: 32px;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
/* eslint-enable max-len */
|
|
||||||
function NewsApp() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Helmet>
|
|
||||||
<style>{styles}</style>
|
|
||||||
</Helmet>
|
|
||||||
<Nav />
|
|
||||||
<Grid fluid={true}>
|
|
||||||
<SlimWidthRow className='app-layout'>{routes}</SlimWidthRow>
|
|
||||||
</Grid>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
NewsApp.displayName = 'NewsApp';
|
|
||||||
NewsApp.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default NewsApp;
|
|
@ -1,61 +0,0 @@
|
|||||||
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 (
|
|
||||||
<Layout>
|
|
||||||
<div className='loader-wrapper'>
|
|
||||||
<Loader />
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NewsReferalLinkHandler.displayName = 'NewsReferalLinkHandler';
|
|
||||||
NewsReferalLinkHandler.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default connect(
|
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(NewsReferalLinkHandler);
|
|
@ -1,60 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import Helmet from 'react-helmet';
|
|
||||||
|
|
||||||
import ArticleMeta from '../../components/ArticleMeta';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
article: PropTypes.shape({
|
|
||||||
author: PropTypes.objectOf(PropTypes.string)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = `
|
|
||||||
.author-block {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.author-block img {
|
|
||||||
border-radius: 20%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.author-bio {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-left: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.author-bio span {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.author-block {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function Author({ article }) {
|
|
||||||
const {
|
|
||||||
author: { avatar }
|
|
||||||
} = article;
|
|
||||||
return (
|
|
||||||
<div className='author-block'>
|
|
||||||
<Helmet>
|
|
||||||
<style>{styles}</style>
|
|
||||||
</Helmet>
|
|
||||||
<img alt='' height='50px' src={avatar} />
|
|
||||||
<div className='author-bio'>
|
|
||||||
<ArticleMeta article={article} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Author.displayName = 'Author';
|
|
||||||
Author.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default Author;
|
|
@ -1,187 +0,0 @@
|
|||||||
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>(.*?)<\/p>/)[1];
|
|
||||||
const pageTitle = `${title} | freeCodeCamp.org`;
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<Helmet>
|
|
||||||
<title>{pageTitle}</title>
|
|
||||||
<link
|
|
||||||
href={`https://www.freecodecamp.org/news${slug}`}
|
|
||||||
rel='canonical'
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
content={`https://www.freecodecamp.org/news${slug}`}
|
|
||||||
property='og:url'
|
|
||||||
/>
|
|
||||||
<meta content={pageTitle} property='og:title' />
|
|
||||||
<meta content={description} property='og:description' />
|
|
||||||
<meta content={description} name='description' />
|
|
||||||
<meta content={featureImage.src} property='og:image' />
|
|
||||||
</Helmet>
|
|
||||||
<Layout>
|
|
||||||
<article className='show-article'>
|
|
||||||
<Spacer />
|
|
||||||
<Author article={newsArticleNode} />
|
|
||||||
<Grid>
|
|
||||||
<FullWidthRow>
|
|
||||||
<h2>{title}</h2>
|
|
||||||
</FullWidthRow>
|
|
||||||
{featureImage ? (
|
|
||||||
<FullWidthRow>
|
|
||||||
<div className='feature-image-wrapper'>
|
|
||||||
<figure>
|
|
||||||
<Image
|
|
||||||
alt={featureImage.alt}
|
|
||||||
responsive={true}
|
|
||||||
src={featureImage.src}
|
|
||||||
/>
|
|
||||||
{featureImage.caption ? (
|
|
||||||
<figcaption
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: featureImage.caption
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
</FullWidthRow>
|
|
||||||
) : null}
|
|
||||||
<FullWidthRow>
|
|
||||||
<Spacer />
|
|
||||||
<div
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: renderableContent.join('')
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FullWidthRow>
|
|
||||||
{youtubeId ? (
|
|
||||||
<Fragment>
|
|
||||||
<div className='youtube-wrapper'>
|
|
||||||
<Youtube
|
|
||||||
onReady={this.youtubeReady}
|
|
||||||
opts={youtubeOpts}
|
|
||||||
videoId={youtubeId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Spacer />
|
|
||||||
</Fragment>
|
|
||||||
) : null}
|
|
||||||
</Grid>
|
|
||||||
</article>
|
|
||||||
</Layout>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
@ -1,26 +0,0 @@
|
|||||||
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
|
|
||||||
});
|
|
@ -1,34 +0,0 @@
|
|||||||
.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%;
|
|
||||||
}
|
|
@ -1,90 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import Helmet from 'react-helmet';
|
|
||||||
import differenceInMinutes from 'date-fns/difference_in_minutes';
|
|
||||||
import differenceInHours from 'date-fns/difference_in_hours';
|
|
||||||
import differenceInDays from 'date-fns/difference_in_calendar_days';
|
|
||||||
import format from 'date-fns/format';
|
|
||||||
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
|
|
||||||
import { faCalendarAlt, faClock } from '@fortawesome/free-regular-svg-icons';
|
|
||||||
import { faFreeCodeCamp } from '@fortawesome/free-brands-svg-icons';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
article: PropTypes.object
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = `
|
|
||||||
|
|
||||||
.meta-wrapper {
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-wrapper span,
|
|
||||||
.meta-wrapper a {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-item {
|
|
||||||
margin-right: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
`;
|
|
||||||
|
|
||||||
function pluralise(singular, count) {
|
|
||||||
return `${singular}${count === 1 ? '' : 's'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTimeString(pubDate) {
|
|
||||||
const now = new Date(Date.now());
|
|
||||||
const minuteDiff = differenceInMinutes(now, pubDate);
|
|
||||||
|
|
||||||
if (minuteDiff < 60) {
|
|
||||||
return `${minuteDiff} ${pluralise('minute', minuteDiff)} ago`;
|
|
||||||
}
|
|
||||||
const hourDiff = differenceInHours(now, pubDate);
|
|
||||||
if (hourDiff < 24) {
|
|
||||||
return `${hourDiff} ${pluralise('hour', hourDiff)} ago`;
|
|
||||||
}
|
|
||||||
const dayDiff = differenceInDays(now, pubDate);
|
|
||||||
if (dayDiff < 8) {
|
|
||||||
return `${dayDiff} ${pluralise('day', dayDiff)} ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dayDiff < 365) {
|
|
||||||
return format(pubDate, 'MMM D');
|
|
||||||
}
|
|
||||||
|
|
||||||
return format(pubDate, 'MMM D YYYY');
|
|
||||||
}
|
|
||||||
|
|
||||||
function ArticleMeta({
|
|
||||||
article: { viewCount, author, meta, firstPublishedDate }
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className='meta-wrapper'>
|
|
||||||
<Helmet>
|
|
||||||
<style>{styles}</style>
|
|
||||||
</Helmet>
|
|
||||||
<div className='meta-item-wrapper'>
|
|
||||||
<span className='meta-item'>By {author.name}</span>
|
|
||||||
<span className='meta-item'>
|
|
||||||
<FontAwesomeIcon icon={faCalendarAlt} />{' '}
|
|
||||||
{getTimeString(firstPublishedDate)}
|
|
||||||
</span>
|
|
||||||
<span className='meta-item'>
|
|
||||||
<FontAwesomeIcon icon={faClock} /> {`${meta.readTime} minute read`}
|
|
||||||
</span>
|
|
||||||
{viewCount >= 100 && (
|
|
||||||
<span className='meta-item'>
|
|
||||||
<FontAwesomeIcon icon={faFreeCodeCamp} /> {`${viewCount} views`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ArticleMeta.displayName = 'ArticleMeta';
|
|
||||||
ArticleMeta.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default ArticleMeta;
|
|
@ -1,52 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const propTypes = {};
|
|
||||||
|
|
||||||
function BannerWide() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
version='1.0'
|
|
||||||
viewBox='0 0 1024 555'
|
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d={
|
|
||||||
'M0 277.5V555h1024V0H0v277.5zm368.5-144.9c1.3.8 2.8 2.9 3.4 4.6 1.' +
|
|
||||||
'6 4.6-.6 8.3-14.2 22.6-14.3 15.2-22.8 26.8-30.1 41.1C315.4 225 31' +
|
|
||||||
'0 248 310 277c0 31.9 5.4 56.7 18 82.5 7.5 15.4 15.7 26.9 29.9 41.' +
|
|
||||||
'8 11.8 12.5 15.7 18.6 14.5 23.2-.8 3.4-5.9 7.5-9.3 7.5-8.3 0-21.5' +
|
|
||||||
'-11.3-36.7-31.6-19.9-26.4-30.5-51.1-36.6-85.1-2-11.2-2.3-16.1-2.2' +
|
|
||||||
'-38.3 0-23.9.1-26.3 2.6-38.1 6.6-30.6 18.8-55.7 39.6-81.6 17.6-21' +
|
|
||||||
'.7 30.5-30.1 38.7-24.7zm284 .3c6.4 2.9 14.5 10.2 23.6 21.1 28.7 3' +
|
|
||||||
'4.7 42.1 68.4 46 115.8 4.1 50.3-9.8 95.9-41.4 135.2-13.7 17.1-26.' +
|
|
||||||
'9 28-33.7 28-2.9 0-7.7-2.7-9-4.9-2.9-5.4-.7-9.2 13.7-24.3 16.8-17' +
|
|
||||||
'.6 26.7-31.9 34.4-49.7 19.4-45.2 17.8-104.8-4-149.6-8.5-17.4-16.8' +
|
|
||||||
'-28.8-34.3-47.2-4.9-5.1-9.3-10.4-9.8-11.8-2.2-5.7-.5-10.6 4.5-13.' +
|
|
||||||
'3 2.8-1.6 5.4-1.4 10 .7zm-172.9 16.5c16.2 4.3 31.4 16.9 38.3 31.7' +
|
|
||||||
' 3.8 8.2 4.8 11.8 9.1 34.4 2.5 12.8 4.8 18.7 8.4 21 4.5 3 11.4-.3' +
|
|
||||||
' 12.9-6.2.7-3.3-1-10-4.4-16.7-1.6-3.3-2.9-6.7-2.9-7.8 0-5.6 11.5 ' +
|
|
||||||
'2.1 23 15.3 16.1 18.7 21.7 36.1 20.8 64.4-.6 16.2-2.9 25-10 36.9-' +
|
|
||||||
'7.3 12.3-29.1 31.6-35.7 31.6-1.8 0-5.1-2.4-5.1-3.7 0-.3 2.8-3.5 6' +
|
|
||||||
'.1-7.1 12.2-13.3 16.8-22.2 17.6-34 .8-12.1-3.1-24.7-10-32-1.7-1.7' +
|
|
||||||
'-3.9-3.2-4.9-3.2-1.6 0-1.6.3-.4 3.8.7 2 1.7 5.6 2.1 8 .6 3.9.4 4.' +
|
|
||||||
'5-2.8 7.7-3.2 3.2-4 3.5-9.2 3.5-11.2 0-13-3.2-12.1-21 .6-11.8.5-1' +
|
|
||||||
'2.8-1.7-17.5-3.9-8.1-13.3-16.5-18.4-16.5-2.6 0-3.2 3.3-1 4.7 3.2 ' +
|
|
||||||
'2 4.7 5.8 4.7 12.3 0 10.1-3.2 15.5-15.2 26-15.3 13.4-18.8 19.4-18' +
|
|
||||||
'.8 32.4 0 17.4 8.4 32.8 20.8 38.3 3.9 1.7 5.2 2.8 5 4.1-.6 3-7 1.' +
|
|
||||||
'9-17.1-3-16.1-7.8-28-19-35.6-33.8-5.3-10.1-7.4-18.5-7.5-30-.1-15.' +
|
|
||||||
'9 3.8-25.3 24-57.6 17.8-28.6 22.1-39.2 21.2-53.1-.7-10.9-8.1-23.8' +
|
|
||||||
'-15.9-27.9-3-1.5-3.9-4.8-1.6-5.7 2.4-1 11.3-.6 16.3.7zm143.5 239.' +
|
|
||||||
'5c3.2 3.2 3.9 4.6 3.9 7.7 0 5.1-1.8 8.3-5.9 11.1l-3.4 2.3H512.8c-' +
|
|
||||||
'114.3 0-108 .3-112.7-5.6-1.4-1.8-2.1-4.1-2.1-7.2 0-3.8.5-5 3.9-8.' +
|
|
||||||
'3l3.9-3.9h213.4l3.9 3.9z'
|
|
||||||
}
|
|
||||||
fill='#006400'
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
BannerWide.displayName = 'BannerWide';
|
|
||||||
BannerWide.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default BannerWide;
|
|
@ -1,67 +0,0 @@
|
|||||||
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
|
|
||||||
);
|
|
@ -1,27 +0,0 @@
|
|||||||
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)];
|
|
||||||
}
|
|
@ -1,22 +1,6 @@
|
|||||||
import { findIndex } from 'lodash';
|
|
||||||
|
|
||||||
// These regex are not for validation, it is purely to see
|
// These regex are not for validation, it is purely to see
|
||||||
// if we are looking at something like what we want to validate
|
// if we are looking at something like what we want to validate
|
||||||
// before we try to validate
|
// before we try to validate
|
||||||
export const maybeEmailRE = /.*@.*\.\w\w/;
|
export const maybeEmailRE = /.*@.*\.\w\w/;
|
||||||
export const maybeUrlRE = /https?:\/\/.*\..*/;
|
export const maybeUrlRE = /https?:\/\/.*\..*/;
|
||||||
export const hasProtocolRE = /^http/;
|
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(/\/*$/, '');
|
|
||||||
};
|
|
||||||
|
@ -1,45 +1,9 @@
|
|||||||
/* global describe it expect */
|
/* global describe it expect */
|
||||||
import {
|
|
||||||
slugWithId,
|
|
||||||
slugWithIdAndHash,
|
|
||||||
slugWithIdAndQuery,
|
|
||||||
slugWithIdAndTrailingSlash,
|
|
||||||
slugWithoutId,
|
|
||||||
mockId
|
|
||||||
} from '../__mocks__/news-article';
|
|
||||||
|
|
||||||
import { getShortIdFromSlug } from './';
|
|
||||||
|
|
||||||
describe('client/src utilities', () => {
|
describe('client/src utilities', () => {
|
||||||
describe('getShortIdFromSlug', () => {
|
describe('No tests for utils', () => {
|
||||||
const emptyString = '';
|
it('No tests for utils', () => {
|
||||||
it('returns a string', () => {
|
expect(true);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
const challengePageCreators = require('./challengePageCreator');
|
const challengePageCreators = require('./challengePageCreator');
|
||||||
const guidePageCreators = require('./guidePageCreator');
|
const guidePageCreators = require('./guidePageCreator');
|
||||||
const newsPageCreators = require('./newsPageCreator');
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...challengePageCreators,
|
...challengePageCreators,
|
||||||
...guidePageCreators,
|
...guidePageCreators
|
||||||
...newsPageCreators
|
|
||||||
};
|
};
|
||||||
|
@ -1,22 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
});
|
|
@ -1,18 +0,0 @@
|
|||||||
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)}`;
|
|
||||||
};
|
|
@ -1,32 +0,0 @@
|
|||||||
/* 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -11,7 +11,6 @@
|
|||||||
"lint": "echo 'Warning: TODO - Define Linting with fixing.'",
|
"lint": "echo 'Warning: TODO - Define Linting with fixing.'",
|
||||||
"seed": "npm-run-all -p seed:*",
|
"seed": "npm-run-all -p seed:*",
|
||||||
"seed:challenges": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seedChallenges",
|
"seed:challenges": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seedChallenges",
|
||||||
"seed:news": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seedNewsArticles",
|
|
||||||
"seed:auth-user": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seedAuthUser",
|
"seed:auth-user": "cross-env DEBUG=fcc:* node ./tools/scripts/seed/seedAuthUser",
|
||||||
"start-develop": "node ./tools/scripts/start-develop.js",
|
"start-develop": "node ./tools/scripts/start-develop.js",
|
||||||
"pretest": "npm-run-all -s test:lint",
|
"pretest": "npm-run-all -s test:lint",
|
||||||
|
@ -1,126 +0,0 @@
|
|||||||
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 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) {
|
|
||||||
if (err) {
|
|
||||||
console.error('Oh noes!!');
|
|
||||||
console.error(err);
|
|
||||||
try {
|
|
||||||
client.close();
|
|
||||||
} catch (e) {
|
|
||||||
// no-op
|
|
||||||
} finally {
|
|
||||||
/* eslint-disable-next-line no-process-exit */
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (env !== 'production') {
|
|
||||||
MongoClient.connect(
|
|
||||||
MONGOHQ_URL,
|
|
||||||
{ useNewUrlParser: true },
|
|
||||||
async function(err, client) {
|
|
||||||
handleError(err, client);
|
|
||||||
|
|
||||||
log('Connected successfully to mongo');
|
|
||||||
const db = client.db('freecodecamp');
|
|
||||||
const articleCollection = db.collection('article');
|
|
||||||
|
|
||||||
const articles = stubArticles(200);
|
|
||||||
|
|
||||||
await articleCollection
|
|
||||||
.deleteMany({})
|
|
||||||
.catch(err => handleError(err, client));
|
|
||||||
return articleCollection
|
|
||||||
.insertMany(articles)
|
|
||||||
.then(({ insertedCount }) => {
|
|
||||||
log('inserted %d new articles', insertedCount);
|
|
||||||
client.close();
|
|
||||||
})
|
|
||||||
.catch(err => handleError(err, client));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stubArticles(numberOfArticles = 1) {
|
|
||||||
return new Array(numberOfArticles).fill('').map(() => generateArticle());
|
|
||||||
}
|
|
||||||
|
|
||||||
const sixMonths = 15780000000;
|
|
||||||
|
|
||||||
function generateArticle() {
|
|
||||||
const now = Date.now();
|
|
||||||
const id = shortId();
|
|
||||||
const title = faker.lorem.sentence();
|
|
||||||
const paragraphs = faker.random.number(10) || 1;
|
|
||||||
const arrayToLoopOver = new Array(paragraphs).fill('');
|
|
||||||
const fakeDate = faker.date.between(new Date(now - sixMonths), new Date(now));
|
|
||||||
const fakeDateMs = new Date(fakeDate).getTime();
|
|
||||||
return {
|
|
||||||
shortId: id,
|
|
||||||
slugPart: slugg(title),
|
|
||||||
title,
|
|
||||||
author: {
|
|
||||||
name: faker.name.findName(),
|
|
||||||
avatar: faker.internet.avatar(),
|
|
||||||
twitter: 'https://twitter.com/camperbot',
|
|
||||||
bio: faker.lorem.sentence(),
|
|
||||||
username: faker.internet.userName()
|
|
||||||
},
|
|
||||||
featureImage: {
|
|
||||||
src: 'https://picsum.photos/2000/1300?random',
|
|
||||||
alt: faker.lorem.sentence(),
|
|
||||||
caption: paragraphs >= 5 ? faker.lorem.sentence() : ''
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
readTime: paragraphs,
|
|
||||||
refLink: `${homeLocation}/n/${id}`
|
|
||||||
},
|
|
||||||
draft: 'this needs to be fixed',
|
|
||||||
renderableContent: arrayToLoopOver.map(
|
|
||||||
() => `<p>${faker.lorem.paragraph()}</p>`
|
|
||||||
),
|
|
||||||
published: true,
|
|
||||||
featured: Math.random() < 0.6,
|
|
||||||
underReview: false,
|
|
||||||
viewCount: faker.random.number(90000),
|
|
||||||
firstPublishedDate: fakeDate,
|
|
||||||
createdDate: fakeDate,
|
|
||||||
lastEditedDate: fakeDate,
|
|
||||||
history: [
|
|
||||||
{
|
|
||||||
event: 'created',
|
|
||||||
timestamp: fakeDateMs
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: 'edited',
|
|
||||||
timestamp: fakeDateMs
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: 'publish',
|
|
||||||
timestamp: fakeDateMs
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: 'featured',
|
|
||||||
timestamp: fakeDateMs
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
Reference in New Issue
Block a user