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 { reportError } from '../middlewares/error-reporter';
|
||||
|
||||
const log = debug('fcc:boot:news');
|
||||
|
||||
export default function newsBoot(app) {
|
||||
const api = app.loopback.Router();
|
||||
const router = app.loopback.Router();
|
||||
|
||||
api.get('/n/:shortId', createShortLinkHandler(app));
|
||||
|
||||
api.post('/p', createPopularityHandler(app));
|
||||
|
||||
app.use('/internal', api);
|
||||
router.get('/n', (req, res) => res.redirect('/news'));
|
||||
router.get('/n/:shortId', createShortLinkHandler(app));
|
||||
}
|
||||
|
||||
function createShortLinkHandler(app) {
|
||||
const { Article } = app.models;
|
||||
|
||||
const referralHandler = createReferralHandler(app);
|
||||
|
||||
return function shortLinkHandler(req, res, next) {
|
||||
const { query, user } = req;
|
||||
const { query } = 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));
|
||||
log(req.origin);
|
||||
log(query.refsource);
|
||||
|
||||
if (!shortId) {
|
||||
return res.sendStatus(400);
|
||||
return res.redirect('/news');
|
||||
}
|
||||
log('shortId', shortId);
|
||||
return Article.findOne(
|
||||
{
|
||||
where: {
|
||||
@ -40,220 +34,14 @@ function createShortLinkHandler(app) {
|
||||
next(err);
|
||||
}
|
||||
if (!article) {
|
||||
return res.status(404).send('Could not find article by shortId');
|
||||
return res.redirect('/news');
|
||||
}
|
||||
const {
|
||||
slugPart,
|
||||
shortId,
|
||||
author: { username }
|
||||
slugPart
|
||||
} = 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);
|
||||
const slug = `/news/${slugPart}`;
|
||||
return res.redirect(slug);
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
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 = [
|
||||
'news',
|
||||
'challenges',
|
||||
'map',
|
||||
'news',
|
||||
'commit'
|
||||
];
|
||||
|
||||
|
@ -46,12 +46,6 @@ module.exports = {
|
||||
curriculumPath: localeChallengesRootDir
|
||||
}
|
||||
},
|
||||
{
|
||||
resolve: 'fcc-source-news',
|
||||
options: {
|
||||
maximumStaticRenderCount: 100
|
||||
}
|
||||
},
|
||||
{
|
||||
resolve: 'gatsby-source-filesystem',
|
||||
options: {
|
||||
|
@ -8,10 +8,8 @@ const {
|
||||
createChallengePages,
|
||||
createBlockIntroPages,
|
||||
createSuperBlockIntroPages,
|
||||
createGuideArticlePages,
|
||||
createNewsArticle
|
||||
createGuideArticlePages
|
||||
} = require('./utils/gatsby');
|
||||
const { createArticleSlug } = require('./utils/news');
|
||||
|
||||
const createByIdentityMap = {
|
||||
guideMarkdown: createGuideArticlePages,
|
||||
@ -37,15 +35,7 @@ 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 }) {
|
||||
@ -97,19 +87,6 @@ exports.createPages = function createPages({ graphql, actions }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
allNewsArticleNode(
|
||||
sort: { fields: firstPublishedDate, order: DESC }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
shortId
|
||||
fields {
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`).then(result => {
|
||||
if (result.errors) {
|
||||
@ -150,11 +127,6 @@ exports.createPages = function createPages({ graphql, actions }) {
|
||||
return null;
|
||||
});
|
||||
|
||||
// Create news article pages
|
||||
result.data.allNewsArticleNode.edges.forEach(
|
||||
createNewsArticle(createPage)
|
||||
);
|
||||
|
||||
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>
|
||||
<a
|
||||
href='https://forum.freecodecamp.org'
|
||||
href='https://www.freecodecamp.org/forum'
|
||||
rel='noopener noreferrer'
|
||||
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 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 (
|
||||
<Router>
|
||||
<ShowProfileOrFourOhFour path='/:maybeUser' />
|
||||
<ShowDynamicNewsOrFourOhFour path='/news/:author/:articleSlug' />
|
||||
<RedirectNews path='/news/:author' />
|
||||
<NotFoundPage default={true} />
|
||||
</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,
|
||||
ns as challengeNameSpace
|
||||
} from '../templates/Challenges/redux';
|
||||
import { reducer as news, ns as newsNameSpace } from '../templates/News/redux';
|
||||
|
||||
export default combineReducers({
|
||||
[appNameSpace]: app,
|
||||
@ -23,6 +22,5 @@ export default combineReducers({
|
||||
[curriculumMapNameSpace]: curriculumMap,
|
||||
[flashNameSpace]: flash,
|
||||
form: formReducer,
|
||||
[newsNameSpace]: news,
|
||||
[settingsNameSpace]: settings
|
||||
});
|
||||
|
@ -3,7 +3,6 @@ import { all } from 'redux-saga/effects';
|
||||
import errorSagas from './error-saga';
|
||||
import { sagas as appSagas } from './';
|
||||
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() {
|
||||
@ -11,7 +10,6 @@ export default function* rootSaga() {
|
||||
...errorSagas,
|
||||
...appSagas,
|
||||
...challengeSagas,
|
||||
...newsSagas,
|
||||
...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
|
||||
// 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(/\/*$/, '');
|
||||
};
|
||||
|
@ -1,45 +1,9 @@
|
||||
/* 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);
|
||||
describe('No tests for utils', () => {
|
||||
it('No tests for utils', () => {
|
||||
expect(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,9 +1,7 @@
|
||||
const challengePageCreators = require('./challengePageCreator');
|
||||
const guidePageCreators = require('./guidePageCreator');
|
||||
const newsPageCreators = require('./newsPageCreator');
|
||||
|
||||
module.exports = {
|
||||
...challengePageCreators,
|
||||
...guidePageCreators,
|
||||
...newsPageCreators
|
||||
...guidePageCreators
|
||||
};
|
||||
|
@ -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.'",
|
||||
"seed": "npm-run-all -p seed:*",
|
||||
"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",
|
||||
"start-develop": "node ./tools/scripts/start-develop.js",
|
||||
"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