25 | ,
26 | document.getElementById('root'),
27 | );
28 | };
29 |
30 | renderApp();
31 |
32 | registerServiceWorker();
33 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/),
17 | );
18 |
19 | export default function register() {
20 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
21 | // The URL constructor is available in all browsers that support SW.
22 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
23 | if (publicUrl.origin !== window.location.origin) {
24 | // Our service worker won't work if PUBLIC_URL is on a different origin
25 | // from what our page is served on. This might happen if a CDN is used to
26 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
27 | return;
28 | }
29 |
30 | window.addEventListener('load', () => {
31 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
32 |
33 | if (isLocalhost) {
34 | // This is running on localhost. Lets check if a service worker still exists or not.
35 | checkValidServiceWorker(swUrl);
36 |
37 | // Add some additional logging to localhost, pointing developers to the
38 | // service worker/PWA documentation.
39 | navigator.serviceWorker.ready.then(() => {
40 | console.log(
41 | 'This web app is being served cache-first by a service ' +
42 | 'worker. To learn more, visit https://goo.gl/SC7cgQ',
43 | );
44 | });
45 | } else {
46 | // Is not local host. Just register service worker
47 | registerValidSW(swUrl);
48 | }
49 | });
50 | }
51 | }
52 |
53 | function registerValidSW(swUrl) {
54 | navigator.serviceWorker
55 | .register(swUrl)
56 | .then(registration => {
57 | registration.onupdatefound = () => {
58 | const installingWorker = registration.installing;
59 | installingWorker.onstatechange = () => {
60 | if (installingWorker.state === 'installed') {
61 | if (navigator.serviceWorker.controller) {
62 | // At this point, the old content will have been purged and
63 | // the fresh content will have been added to the cache.
64 | // It's the perfect time to display a "New content is
65 | // available; please refresh." message in your web app.
66 | console.log('New content is available; please refresh.');
67 | } else {
68 | // At this point, everything has been precached.
69 | // It's the perfect time to display a
70 | // "Content is cached for offline use." message.
71 | console.log('Content is cached for offline use.');
72 | }
73 | }
74 | };
75 | };
76 | })
77 | .catch(error => {
78 | console.error('Error during service worker registration:', error);
79 | });
80 | }
81 |
82 | function checkValidServiceWorker(swUrl) {
83 | // Check if the service worker can be found. If it can't reload the page.
84 | fetch(swUrl)
85 | .then(response => {
86 | // Ensure service worker exists, and that we really are getting a JS file.
87 | if (
88 | response.status === 404 ||
89 | response.headers.get('content-type').indexOf('javascript') === -1
90 | ) {
91 | // No service worker found. Probably a different app. Reload the page.
92 | navigator.serviceWorker.ready.then(registration => {
93 | registration.unregister().then(() => {
94 | window.location.reload();
95 | });
96 | });
97 | } else {
98 | // Service worker found. Proceed as normal.
99 | registerValidSW(swUrl);
100 | }
101 | })
102 | .catch(() => {
103 | console.log('No internet connection found. App is running in offline mode.');
104 | });
105 | }
106 |
107 | export function unregister() {
108 | if ('serviceWorker' in navigator) {
109 | navigator.serviceWorker.ready.then(registration => {
110 | registration.unregister();
111 | });
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/services/Api.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | // Default API will be your root
4 | const API_ROOT = process.env.URL || 'http://localhost:3000/';
5 | const TIMEOUT = 20000;
6 | const HEADERS = {
7 | 'Content-Type': 'application/json',
8 | Accept: 'application/json',
9 | };
10 |
11 | class ApiService {
12 | constructor({ baseURL = API_ROOT, timeout = TIMEOUT, headers = HEADERS, auth }) {
13 | const client = axios.create({
14 | baseURL,
15 | timeout,
16 | headers,
17 | auth,
18 | });
19 |
20 | client.interceptors.response.use(this.handleSuccess, this.handleError);
21 | this.client = client;
22 | }
23 |
24 | handleSuccess(response) {
25 | return response;
26 | }
27 |
28 | handleError(error) {
29 | return Promise.reject(error);
30 | }
31 |
32 | get(path) {
33 | return this.client.get(path).then(response => response.data);
34 | }
35 |
36 | post(path, payload) {
37 | return this.client.post(path, payload).then(response => response.data);
38 | }
39 |
40 | put(path, payload) {
41 | return this.client.put(path, payload).then(response => response.data);
42 | }
43 |
44 | patch(path, payload) {
45 | return this.client.patch(path, payload).then(response => response.data);
46 | }
47 |
48 | delete(path) {
49 | return this.client.delete(path).then(response => response.data);
50 | }
51 | }
52 |
53 | export default ApiService;
54 |
--------------------------------------------------------------------------------
/src/services/hackerNewsApi.js:
--------------------------------------------------------------------------------
1 | import ApiService from './Api';
2 |
3 | const JSON_QUERY = '.json?print=pretty';
4 | const BASE_URL = 'https://hacker-news.firebaseio.com/v0';
5 | const client = new ApiService({ baseURL: BASE_URL });
6 |
7 | const hackerNewsApi = {};
8 |
9 | const PAGE_LIMIT = 20;
10 | const getPageSlice = (limit, page = 0) => ({ begin: page * limit, end: (page + 1) * limit });
11 | const getPageValues = ({ begin, end, items }) => items.slice(begin, end);
12 |
13 | hackerNewsApi.getTopStoryIds = () => client.get(`/topstories${JSON_QUERY}`);
14 | hackerNewsApi.getStory = id => client.get(`/item/${id}${JSON_QUERY}`);
15 | hackerNewsApi.getStoriesByPage = (ids, page) => {
16 | const { begin, end } = getPageSlice(PAGE_LIMIT, page);
17 | const activeIds = getPageValues({ begin, end, items: ids });
18 | const storyPromises = activeIds.map(id => hackerNewsApi.getStory(id));
19 | return Promise.all(storyPromises);
20 | };
21 |
22 | export default hackerNewsApi;
23 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | import { configure } from 'enzyme';
2 | import Adapter from 'enzyme-adapter-react-16';
3 |
4 | configure({ adapter: new Adapter() });
5 |
--------------------------------------------------------------------------------
/src/store/app/actions.js:
--------------------------------------------------------------------------------
1 | import { buildActionCreator } from 'store/utils';
2 |
3 | const NS = '@hackerNewsReader/app';
4 |
5 | export const actionTypes = {
6 | SET_THEME: `${NS}/SET_THEME`,
7 | SET_LAYOUT: `${NS}/SET_LAYOUT`,
8 | };
9 |
10 | const actions = {
11 | setTheme: buildActionCreator(actionTypes.SET_THEME),
12 | setLayout: buildActionCreator(actionTypes.SET_LAYOUT),
13 | };
14 |
15 | export default actions;
16 |
--------------------------------------------------------------------------------
/src/store/app/actions.test.js:
--------------------------------------------------------------------------------
1 | import actions from './actions';
2 | import { layouts, themes } from 'store/app/utils';
3 |
4 | describe('actions', () => {
5 | it('should create an action to add change theme', () => {
6 | const payload = themes.light;
7 | const expectedAction = {
8 | type: '@hackerNewsReader/app/SET_THEME',
9 | payload,
10 | };
11 | expect(actions.setTheme(payload)).toEqual(expectedAction);
12 | });
13 |
14 | it('should create an action to add change layout', () => {
15 | const payload = layouts.grid;
16 | const expectedAction = {
17 | type: '@hackerNewsReader/app/SET_LAYOUT',
18 | payload,
19 | };
20 | expect(actions.setLayout(payload)).toEqual(expectedAction);
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/store/app/reducer.js:
--------------------------------------------------------------------------------
1 | import { actionTypes } from './actions';
2 | import { layouts, themes } from './utils';
3 |
4 | const getInitialState = () => ({
5 | theme: themes.dark,
6 | layout: layouts.list,
7 | });
8 |
9 | const app = (state = getInitialState(), { type, payload }) => {
10 | console.log('payload: ', payload);
11 | switch (type) {
12 | case actionTypes.SET_THEME:
13 | return {
14 | ...state,
15 | ...payload,
16 | };
17 | case actionTypes.SET_LAYOUT:
18 | return {
19 | ...state,
20 | ...payload,
21 | };
22 | default:
23 | return state;
24 | }
25 | };
26 |
27 | export default app;
28 |
--------------------------------------------------------------------------------
/src/store/app/reducer.test.js:
--------------------------------------------------------------------------------
1 | import reducer from './reducer';
2 | import { layouts, themes, AppReducerData } from './utils';
3 | import { actionTypes } from './actions';
4 |
5 | describe('app reducer', () => {
6 | it('should return the initial state', () => {
7 | expect(reducer(undefined, {})).toEqual({
8 | theme: themes.dark,
9 | layout: layouts.list,
10 | });
11 | });
12 |
13 | it('should handle change', () => {
14 | expect(
15 | reducer(AppReducerData.initialState, {
16 | type: actionTypes.SET_THEME,
17 | payload: AppReducerData.payload,
18 | }),
19 | ).toEqual({
20 | theme: themes.light,
21 | layout: layouts.list,
22 | });
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/store/app/utils.js:
--------------------------------------------------------------------------------
1 | export const layouts = {
2 | grid: 'grid',
3 | list: 'list',
4 | };
5 |
6 | export const themes = {
7 | dark: 'dark',
8 | light: 'light',
9 | };
10 |
11 | export const AppReducerData = {
12 | initialState: {
13 | theme: themes.dark,
14 | layout: layouts.list,
15 | },
16 | payload: { theme: themes.light },
17 | };
18 |
--------------------------------------------------------------------------------
/src/store/index.js:
--------------------------------------------------------------------------------
1 | import { createStore } from 'redux';
2 | import reducer from './reducer';
3 | import middleware from './middleware';
4 |
5 | const configureStore = initialState => {
6 | const store = createStore(reducer, initialState, middleware);
7 | return store;
8 | };
9 |
10 | export default configureStore;
11 |
--------------------------------------------------------------------------------
/src/store/middleware/index.js:
--------------------------------------------------------------------------------
1 | import { applyMiddleware, compose } from 'redux';
2 | import thunk from 'redux-thunk';
3 | import { createLogger } from 'redux-logger';
4 | import localStorageMiddleware from 'store/middleware/localStorageMiddleware';
5 | import storageDefinitions from 'store/middleware/localStorageMiddleware/storageDefinitions';
6 |
7 | const isProd = process.env.NODE_ENV === 'production';
8 | const middlewareList = [];
9 | let devTool = f => f;
10 |
11 | middlewareList.push(thunk);
12 | middlewareList.push(localStorageMiddleware(storageDefinitions));
13 |
14 | if (!isProd) {
15 | middlewareList.push(createLogger());
16 | }
17 |
18 | const middleware = compose(
19 | applyMiddleware(...middlewareList),
20 | devTool,
21 | );
22 |
23 | export default middleware;
24 |
--------------------------------------------------------------------------------
/src/store/middleware/localStorageMiddleware/hasLocalStorage.js:
--------------------------------------------------------------------------------
1 | const STORAGE_TYPE = 'localStorage';
2 |
3 | const hasLocalStorage = (storageType = STORAGE_TYPE) => {
4 | if (typeof window === 'undefined' || !(storageType in window)) {
5 | return false;
6 | }
7 |
8 | try {
9 | let storage = window[storageType];
10 | const testKey = `storage ${storageType} test`;
11 | storage.setItem(testKey, 'test');
12 | storage.getItem(testKey);
13 | storage.removeItem(testKey);
14 | } catch (e) {
15 | if (process.env.NODE_ENV !== 'production') {
16 | console.warn(`redux-persist ${storageType} test failed, persistence will be disabled.`);
17 | }
18 |
19 | return false;
20 | }
21 |
22 | return true;
23 | };
24 |
25 | export default hasLocalStorage;
26 |
--------------------------------------------------------------------------------
/src/store/middleware/localStorageMiddleware/index.js:
--------------------------------------------------------------------------------
1 | import hasLocalStorage from './hasLocalStorage';
2 | import loadState from './loadState';
3 | import saveState from './saveState';
4 |
5 | const emptyMiddleware = storageDefinitions => store => next => action => next(action);
6 |
7 | const localCacheMiddleware = storageDefinitions => store => next => action => {
8 | const prevState = store.getState();
9 | const localCacheFunction = storageDefinitions[action.type];
10 | const result = next(action);
11 | const nextState = store.getState();
12 |
13 | if (localCacheFunction) {
14 | if (typeof localCacheFunction === 'function') {
15 | localCacheFunction({
16 | action,
17 | loadState,
18 | saveState,
19 | prevState,
20 | nextState,
21 | dispatch: store.dispatch,
22 | });
23 | } else if (Array.isArray(localCacheFunction)) {
24 | localCacheFunction.forEach(f =>
25 | f({
26 | action,
27 | loadState,
28 | saveState,
29 | prevState,
30 | nextState,
31 | dispatch: store.dispatch,
32 | }),
33 | );
34 | }
35 | }
36 |
37 | return result;
38 | };
39 |
40 | const localCache = hasLocalStorage() ? localCacheMiddleware : emptyMiddleware;
41 |
42 | export default localCache;
43 |
--------------------------------------------------------------------------------
/src/store/middleware/localStorageMiddleware/loadInitialState.js:
--------------------------------------------------------------------------------
1 | import { THEME_KEY, LAYOUT_KEY } from './storageDefinitions';
2 | import loadState from './loadState';
3 |
4 | const loadInitialState = () => {
5 | const initialState = {};
6 | const layout = loadState({ storageKey: LAYOUT_KEY });
7 | const theme = loadState({ storageKey: THEME_KEY });
8 |
9 | if (layout || theme) {
10 | initialState.app = {};
11 | initialState.app.layout = layout;
12 | initialState.app.theme = theme;
13 | }
14 |
15 | return initialState;
16 | };
17 |
18 | export default loadInitialState;
19 |
--------------------------------------------------------------------------------
/src/store/middleware/localStorageMiddleware/loadState.js:
--------------------------------------------------------------------------------
1 | function loadState({ storageKey }) {
2 | try {
3 | const serializedState = localStorage.getItem(storageKey);
4 |
5 | if (serializedState === null) {
6 | return undefined;
7 | }
8 |
9 | const parsedState = JSON.parse(serializedState);
10 |
11 | return parsedState;
12 | } catch (err) {
13 | return undefined;
14 | }
15 | }
16 |
17 | export default loadState;
18 |
--------------------------------------------------------------------------------
/src/store/middleware/localStorageMiddleware/saveState.js:
--------------------------------------------------------------------------------
1 | function saveState({ storageKey, state }) {
2 | try {
3 | const serializedState = JSON.stringify(state);
4 |
5 | localStorage.setItem(storageKey, serializedState);
6 | } catch (err) {
7 | // Ignore write errors.
8 | }
9 | }
10 |
11 | export default saveState;
12 |
--------------------------------------------------------------------------------
/src/store/middleware/localStorageMiddleware/storageDefinitions.js:
--------------------------------------------------------------------------------
1 | import { actionTypes } from 'store/app/actions';
2 |
3 | const BASE_STORAGE_KEY = '@@hackerNewsReader/storage';
4 | export const THEME_KEY = `${BASE_STORAGE_KEY}/theme`;
5 | export const LAYOUT_KEY = `${BASE_STORAGE_KEY}/layout`;
6 |
7 | const storageDefinitions = {
8 | [actionTypes.SET_THEME]: [
9 | ({ action, saveState }) => saveState({ state: action.payload.theme, storageKey: THEME_KEY }),
10 | ],
11 | [actionTypes.SET_LAYOUT]: [
12 | ({ action, saveState }) => saveState({ state: action.payload.layout, storageKey: LAYOUT_KEY }),
13 | ],
14 | };
15 |
16 | export default storageDefinitions;
17 |
--------------------------------------------------------------------------------
/src/store/reducer.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 |
3 | import app from './app/reducer';
4 | import story from './story/reducer';
5 |
6 | const rootReducer = combineReducers({
7 | story,
8 | app,
9 | });
10 |
11 | export default rootReducer;
12 |
--------------------------------------------------------------------------------
/src/store/story/actions.js:
--------------------------------------------------------------------------------
1 | import hackerNewsApi from 'services/hackerNewsApi';
2 | import { buildRequestCreator } from 'store/utils';
3 |
4 | const NS = '@hackerNewsReader/story';
5 |
6 | export const actionTypes = {
7 | FETCH_STORY_IDS: `${NS}/FETCH_STORY_IDS`,
8 | FETCH_STORIES: `${NS}/FETCH_STORIES`,
9 | };
10 |
11 | const actions = {
12 | fetchStoryIds: buildRequestCreator(
13 | actionTypes.FETCH_STORY_IDS,
14 | ({ request, payload, dispatch }) => {
15 | dispatch(request.request(payload));
16 | return hackerNewsApi
17 | .getTopStoryIds()
18 | .then(storyIds => {
19 | dispatch(request.success({ storyIds }));
20 | dispatch(actions.fetchStories({ storyIds, page: 0 }));
21 | return storyIds;
22 | })
23 | .catch(err => dispatch(request.failure(err)));
24 | },
25 | ),
26 | fetchStories: buildRequestCreator(actionTypes.FETCH_STORIES, ({ request, payload, dispatch }) => {
27 | const { storyIds, page } = payload;
28 | dispatch(request.request(payload));
29 | return hackerNewsApi
30 | .getStoriesByPage(storyIds, page)
31 | .then(stories => dispatch(request.success({ stories })))
32 | .catch(err => dispatch(request.failure(err)));
33 | }),
34 | };
35 |
36 | export default actions;
37 |
--------------------------------------------------------------------------------
/src/store/story/reducer.js:
--------------------------------------------------------------------------------
1 | import { actionTypes } from './actions';
2 |
3 | const getInitialState = () => ({
4 | storyIds: [],
5 | stories: [],
6 | page: 0,
7 | isFetching: false,
8 | error: '',
9 | });
10 |
11 | const story = (state = getInitialState(), { type, payload }) => {
12 | switch (type) {
13 | case `${actionTypes.FETCH_STORY_IDS}_REQUEST`:
14 | case `${actionTypes.FETCH_STORIES}_REQUEST`:
15 | return {
16 | ...state,
17 | isFetching: true,
18 | };
19 | case `${actionTypes.FETCH_STORY_IDS}_SUCCESS`:
20 | return {
21 | ...state,
22 | ...payload,
23 | };
24 | case `${actionTypes.FETCH_STORIES}_SUCCESS`:
25 | return {
26 | ...state,
27 | stories: [...state.stories, ...payload.stories],
28 | page: state.page + 1,
29 | isFetching: false,
30 | };
31 | default:
32 | return state;
33 | }
34 | };
35 |
36 | export default story;
37 |
--------------------------------------------------------------------------------
/src/store/story/selectors.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect';
2 |
3 | const storyIdsSelector = state => state.story.storyIds;
4 | const storiesSelector = state => state.story.stories;
5 |
6 | export const hasMoreStoriesSelector = createSelector(
7 | storyIdsSelector,
8 | storiesSelector,
9 | (storyIds, stories) => storyIds.length > stories.length,
10 | );
11 |
--------------------------------------------------------------------------------
/src/store/utils/index.js:
--------------------------------------------------------------------------------
1 | export const buildActionCreator = type => {
2 | return (payload = {}) => ({
3 | type,
4 | payload,
5 | });
6 | };
7 |
8 | export const buildRequestActionTypes = (type, namespace) => ({
9 | [`${type}_REQUEST`]: `${namespace}/${type}_REQUEST`,
10 | [`${type}_SUCCESS`]: `${namespace}/${type}_SUCCESS`,
11 | [`${type}_FAILURE`]: `${namespace}/${type}_FAILURE`,
12 | });
13 |
14 | export const buildEventActionCreator = type => {
15 | return (name = '', data = {}) => ({
16 | type,
17 | payload: {},
18 | event: {
19 | name,
20 | data,
21 | },
22 | });
23 | };
24 |
25 | const mapTypeToRequest = type => ({
26 | request: buildActionCreator(`${type}_REQUEST`),
27 | success: buildActionCreator(`${type}_SUCCESS`),
28 | failure: buildActionCreator(`${type}_FAILURE`),
29 | });
30 |
31 | export const buildRequestCreator = (type, requestCallback) => {
32 | const request = mapTypeToRequest(type);
33 | return (payload = {}) => dispatch => requestCallback({ request, payload, dispatch });
34 | };
35 |
--------------------------------------------------------------------------------
/src/styles/globals.js:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from 'styled-components';
2 |
3 | const GlobalStyles = createGlobalStyle`
4 | * {
5 | box-sizing: border-box;
6 | }
7 |
8 | html, body {
9 | font-family: Lato,Helvetica-Neue,Helvetica,Arial,sans-serif;
10 | width: 100vw;
11 | overflow-x: hidden;
12 | margin: 0;
13 | padding: 0;
14 | }
15 |
16 | ul {
17 | list-style: none;
18 | padding: 0;
19 | }
20 |
21 | a {
22 | text-decoration: none;
23 |
24 | &:visited {
25 | color: inherit;
26 | }
27 | }
28 | `;
29 |
30 | export default GlobalStyles;
31 |
--------------------------------------------------------------------------------
/src/styles/mediaQueries.js:
--------------------------------------------------------------------------------
1 | export const mobile = '@media only screen and (max-width: 480px)';
2 | export const tablet = '@media only screen and (max-width: 768px)';
3 | export const monitor = '@media only screen and (min-width: 1400px)';
4 |
--------------------------------------------------------------------------------
/src/styles/palette.js:
--------------------------------------------------------------------------------
1 | export const colorsDark = {
2 | background: '#272727',
3 | backgroundSecondary: '#393C3E',
4 | text: '#bfbebe',
5 | textSecondary: '#848886',
6 | border: '#272727',
7 | };
8 |
9 | export const colorsLight = {
10 | background: '#EAEAEA',
11 | backgroundSecondary: '#F8F8F8',
12 | text: '#848886',
13 | textSecondary: '#aaaaaa',
14 | border: '#EAEAEA',
15 | };
16 |
--------------------------------------------------------------------------------
/src/utils/getArticleLink.js:
--------------------------------------------------------------------------------
1 | const HN_ROOT = 'https://news.ycombinator.com';
2 | export const HN_ITEM = `${HN_ROOT}/item?id=`;
3 | export const HN_USER = `${HN_ROOT}/user?id=`;
4 |
5 | const getArticleLink = ({ url, id }) => {
6 | const commentUrl = `${HN_ITEM}${id}`;
7 | const link = !!url ? url : commentUrl;
8 | return link;
9 | };
10 |
11 | export default getArticleLink;
12 |
--------------------------------------------------------------------------------
/src/utils/getArticleLink.test.js:
--------------------------------------------------------------------------------
1 | import getArticleLink from './getArticleLink';
2 |
3 | it('build article link', () => {
4 | expect(getArticleLink({ url: '', id: '18267468' })).toEqual(
5 | 'https://news.ycombinator.com/item?id=18267468',
6 | );
7 | expect(
8 | getArticleLink({ url: 'https://avc.com/2018/10/who-are-my-investors/', id: '18267468' }),
9 | ).toEqual('https://avc.com/2018/10/who-are-my-investors/');
10 | });
11 |
--------------------------------------------------------------------------------
/src/utils/getSiteHostname.js:
--------------------------------------------------------------------------------
1 | import url from 'url';
2 |
3 | const getSiteHostname = siteUrl => {
4 | let hostname = '';
5 |
6 | if (siteUrl) {
7 | if (!siteUrl.includes('//')) {
8 | siteUrl = `http://${siteUrl}`;
9 | }
10 |
11 | hostname = url.parse(siteUrl).hostname;
12 | }
13 |
14 | if (hostname.includes('www.')) {
15 | hostname = hostname.split('www.')[1];
16 | }
17 |
18 | return hostname;
19 | };
20 |
21 | export default getSiteHostname;
22 |
--------------------------------------------------------------------------------
/src/utils/getSiteHostname.test.js:
--------------------------------------------------------------------------------
1 | import getSiteHostname from './getSiteHostname';
2 |
3 | it('get hostname from the site url', () => {
4 | expect(getSiteHostname('')).toEqual('');
5 | expect(getSiteHostname('https://avc.com/2018/10/who-are-my-investors/')).toEqual('avc.com');
6 | expect(
7 | getSiteHostname(
8 | 'https://www.theguardian.com/money/2018/oct/20/facebook-fake-amazon-review-factories-uncovered-which-investigation',
9 | ),
10 | ).toEqual('theguardian.com');
11 | });
12 |
--------------------------------------------------------------------------------
/tutorial/TUTORIAL.md:
--------------------------------------------------------------------------------
1 | # React & Redux Tutorial - Build a Hacker News Clone
2 |
3 | ## *Build a production React project using Redux and Styled Components. Deploy the app using GitHub pages.*
4 |
5 | In this tutorial, we are going to build a production-quality Hacker News clone. We will walk through the steps of initializing the application, adding [Redux](https://redux.js.org/) to manage state, building the UI in React, and deploying the solution to GitHub pages. We will style the application using [styled-components](https://www.styled-components.com/) and call the public [Hacker News API](https://github.com/HackerNews/API) using the [axios](https://github.com/axios/axios) library.
6 |
7 | [**GitHub — View Source**](https://github.com/gitconnected/hacker-news-reader)
8 |
9 | [**Download Hacker News Clone as a Chrome Extension (it’s awesome!)**](https://chrome.google.com/webstore/detail/hacker-news/hknoigmfpgfdkccnkbfbjfnocoegoefe?pli=1&authuser=1)
10 |
11 | 
12 |
13 | If you prefer video, you can also follow along with this tutorial on our YouTube. http://www.youtube.com/watch?v=oGB_VPrld0U&index=2&list=PLTTC1K14KAxHj6AftnRUD28SQaoVauvl3
14 |
15 | [](http://www.youtube.com/watch?v=oGB_VPrld0U&index=2&list=PLTTC1K14KAxHj6AftnRUD28SQaoVauvl3)
16 |
17 | ### Initialize the Project
18 |
19 | We will use [create-react-app](https://github.com/facebook/create-react-app) to start the project. This allows us to build React applications without worrying about the configuration. First, make sure you have `create-react-app` installed.
20 |
21 | ```
22 | npm i -g create-react-app
23 | ```
24 |
25 | Initialize your project by running the command below. `create-react-app` installs all the essential packages to build a React application and it has default scripts to manage development and building for production.
26 |
27 | ```
28 | create-react-app hn-clone
29 | # Wait for everything to finish...
30 | cd hn-clone
31 | ```
32 |
33 | Now let’s install the core packages that we will need. I’m using `yarn` but if you have `npm` just replace the `yarn add` with `npm install`.
34 |
35 | ```
36 | yarn add redux styled-components react-redux redux-logger redux-thunk axios
37 | ```
38 |
39 | `create-react-app` uses the `NODE_PATH` environment variable to create absolute paths. We can declare environment variables in the `.env` file and `create-react-app` [will recognize it](https://github.com/facebook/create-react-app/blob/master/packages/react-scripts/template/README.md#adding-development-environment-variables-in-env) and apply it using the [dotenv library](https://www.npmjs.com/package/dotenv).
40 |
41 | ```
42 | # Create a .env file using the touch command
43 | touch .env
44 |
45 | # Inside the .env file add:
46 | # NODE_PATH=src
47 | ```
48 |
49 | If you are unfamiliar with this pattern, it will make more sense when we start building the application. What it allows you to do is directly import files without needing trace your folder paths. Something like this `../../components/List` becomes `components/List` — much more convenient.
50 |
51 | ### Folder Structure
52 |
53 | Inside `src`, let’s update our folder structure to be more scalable and usable for a production application.
54 |
55 | 
56 |
57 | - `components`: This folder will hold all of our React components (both container and presentational).
58 | - `services`: Services allow you to connect to APIs (ex. using axios to call the HN API) or provide extended functionality to the application (ex. adding Markdown support).
59 | - `store`: The store holds all of our logic for Redux and managing state.
60 | - `styles`: Inside the styles folder, we declare variables, templates, and reusable style patterns that can be shared in components.
61 | - `utils`: Helper functions that can be reused throughout the application.
62 |
63 | > There are 2 aspects about this folder structure worth noting:
64 |
65 | > 1. Our application only has 1 route which is the root `/`. If we had multiple routes, I would also use `react-router` and create a `pages` folder for page-level components.
66 |
67 | > 2. I don’t use a separate `containers` folder for connecting to Redux. I’ve found this adds unnecessary complexity and confusion because developers will import from the incorrect location (`containers` when they wanted `components` or vice versa). Having a single source of truth for imports works better in a practical context in my experience.
68 |
69 | Since we are using `styled-components`, we can delete the `index.css` file and the `App.css` files. Now we can add some boilerplate base styling.Inside the `src/styles` folder create files named `globals.js` and `palette.js`.
70 |
71 | Palette will contain the groupings of colors we will use in the application and create the themes for our UI. Add the following code to `src/styles/palette.js`.
72 |
73 | 
74 |
75 | The `globals.js` is used to generate our default base styling shared across the app. The `createGlobalStyle` method from `styled-components` should be used sparingly, but it is useful for app-level styles. It generates a new component with globally applied styling.
76 |
77 | 
78 |
79 | Inside the `components` folder create an `App` folder. Move the default CRA files to this location and rename `App.js` to `index.js`. This allows us to import `components/App`.
80 |
81 | 
82 |
83 | Now, open `src/index.js` (the root file of your project) and update the content to use our new folder structure.
84 |
85 | 
86 |
87 | Notice that since we set the `NODE_PATH` previously, we can import `App` using `components/App` and `GlobalStyles` using `styles/globals`. In styled-components v4, this is now a component which we will include in parallel to our `` component to apply styles globally.
88 |
89 | Now we’re ready to start our development environment with our core structure in place. Run the following command to start the app, and you should see it on `http://localhost:3000.`. Not much to look at yet, but we’ll get there :)
90 |
91 | ```
92 | yarn start
93 | ```
94 |
95 | 
96 |
97 | ### Adding Redux to Your React App
98 |
99 | Inside our `src/store` folder, create an `index.js` file a `reducer.js` file, and a `middleware.js` file. Let’s also initialize an `app` feature to manage state for the app.
100 |
101 | 
102 |
103 | > From my experience, in production Redux is more manageable if you group by feature as opposed to functionality, similar to the [Ducks approach](https://medium.freecodecamp.org/scaling-your-redux-app-with-ducks-6115955638be). In the “grouping by functionality” approach where all actions, reducers, etc live in a separate folder, it can be increasingly difficult to navigate between files when the number grows in production. When you group by feature, you always have the files you need in a single, compact location.
104 |
105 | Inside the `index.js` we’ll create a `configureStore` function which is how we will initialize Redux in the application.
106 |
107 | 
108 |
109 | We use `createStore` from Redux which builds the initial `store`. We import `reducer` from our root reducer file, and we import the `middleware` from our middleware configuration file. The `initialState` will be supplied at runtime and passed to our function. In production, we could be managing complex functionality such as SSR or passing data from the server on the initial load, and this allows us to handle that gracefully and abstract it away from the store creation.
110 |
111 | Inside the `reducer.js` file, create the root reducer using `combineReducers`. This function combines all your reducer functions to build a single state tree.
112 |
113 | 
114 |
115 | Next we can create our middleware in the `middleware.js` file. A middleware is a function that the dispatched action must pass through every time. It is used to extend the functionality of Redux. Add the following code to the file.
116 |
117 | 
118 |
119 | We will also build our first reducer. Inside `src/store/app`, create `reducer.js` and `actions.js` files. We’ll add functionality to toggle between day mode and night mode, so let’s create an action to manage this feature. Inside `src/stre/app/actions.js`, add the following code.
120 |
121 | 
122 |
123 | We create an `actionTypes` object to hold our action-type constants. These will be used in the reducer to match the type with the state change. We also create an `actions` object which holds the functions we will `dispatch` from our application to create state changes. Every action will have a `type` and a `payload`.
124 |
125 | Finally, we can create our reducer.
126 |
127 | 
128 |
129 | When we `dispatch` a `SET_THEME` action, it will update the `theme` value of the state to the value inside the payload. The `payload` will be an object that has the form `{ theme: 'value' }`. When we spread `…` the `payload` object, the keys of the `state` will be replace the keys in `...state` that match — in this case `theme`.
130 |
131 | For the brevity of this article, if you need a further understanding of the fundamentals of Redux, [check out this free video](https://egghead.io/courses/getting-started-with-redux) by the creator of Redux, [Dan Abramov](https://medium.com/u/a3a8af6addc1).
132 |
133 | Return to the `src/index.js`, and now we can update it to connect our app to Redux. Add an import for `Provider` and update your render method to look like the following.
134 |
135 | 
136 |
137 | And that should be all you need to get Redux integrated with the app! Return to `http://localhost:3000`, and you should see the following when you open your Chrome console.
138 |
139 | 
140 |
141 | ### Build the UI with React and Styled Components
142 |
143 | Now that Redux is initialized, we can begin working on our UI. First, let’s declare some more style constants that we’ll use inside our components. In this case, we’ll create a `mediaQueries` file to hold constants to make it easy to add mobile responsiveness to our app. Create a `src/styles/mediaQueries.js` file, and the following code to each.
144 |
145 | 
146 |
147 | Return to our `src/components/App` folder. Inside `index.js`, we update the content to be the following.
148 |
149 | 
150 |
151 | We use the `ThemeProvider` component from `styled-components`. This provides functionality enables us to pass a “theme” as a `prop` to all styled components that we build. We’ll initialize it here as the `colorsDark` object.
152 |
153 | `App` contains components that we have not built yet, so let’s do that now. First, let’s build our styled components. Create a file `styles.js` inside the`App` folder and add the following code.
154 |
155 | 
156 |
157 | This creates `div` for the page which we call `Wrapper` and an `h1` for the page as the component `Title`. The `styled-components` syntax creates a component using the HTML element that you specify after after the `styled` object. You use a string to define the CSS properties of that component.
158 |
159 | Notice on line 20, we use our `theme` prop. A function containing `props` as an argument is injected by `styled-components` into the styling string allowing us to extract properties or add logic to construct styles, abstracting this away from the component that uses them.
160 |
161 | Next we create our `List` component which will contain our Hacker News stories. Create a `src/components/List` folder and add an `index.js` and `styles.js` files. Inside `index.js` add the following.
162 |
163 | 
164 |
165 | And inside the `styles.js` we create the `ListWrapper`. We set the `background-color` using the `theme` prop which we get from the `ThemeProvider` component.
166 |
167 | 
168 |
169 | Finally, we create our `ListItem` component which will display the individual stories. Create a `src/components/ListItem` folder and an `index.js` and `style.js` files.
170 |
171 | We want our UI to mimic that of Hacker News. For now, we will use fake data inside our `ListItem` to mock this. Add the following code to the `index.js` file.
172 |
173 | 
174 |
175 | Each story has a title, author, score, time of post, source URL, and comment count. We initialize these to test values so we can see how it looks in our UI. The `rel="nofollow noreferrer noopener"` is added for [security reasons](https://support.performancefoundry.com/article/186-noopener-noreferrer-on-my-links).
176 |
177 | In the `styles.js` file, add the following code.
178 |
179 | 
180 |
181 | And that should be the basic UI components that we need! Return to your browser and you should have a single item feed with fake data.
182 |
183 | 
184 |
185 | ### Making API Calls with Redux and Axios
186 |
187 | It’s time to add real data to our app. We will call the Hacker News API using the`axios` request library. Calling an API will introduce a “[side effect](https://en.wikipedia.org/wiki/Side_effect_%28computer_science%29)” to our application which means that it will modify the state from a source outside of our local environment.
188 |
189 | API calls are considered side effects because they will introduce oustide data to our state. Other examples of side effects are interacting with `localStorage` in the browser, tracking user analytics, connecting to a web socket, and many more. There multiple libraries to manage side effects in Redux apps, from the simple [redux-thunk](https://github.com/reduxjs/redux-thunk) to the more complex [redux-saga](https://github.com/redux-saga/redux-saga). However, they all serve the same purpose — allow Redux to interact with the outside world. `redux-thunk` is one of the simplest libraries to use in that it allows you `dispatch` a JavaScript `function` in addition to action `objects`. This is the exact functionality we need to use `axios` by utilizing a function that manages the returned promise from the API call.
190 |
191 | Inside our `src/services` folder, create an `Api.js` file and `hackerNewsApi.js`. The `axios` library is incredibly powerful and extensible. The `Api.js` will contain the configuration to make `axios` requests easy. We won’t copy the entire file here, but you can get the content from [source code](https://github.com/gitconnected/hacker-news-reader/blob/master/src/services/Api.js) which uses sensible defaults for basic API requests that we need in this project.
192 |
193 | Inside the `src/services/hackerNewsApi.js` file, we will define the functions to make requests to the Hacker News API. The [documenation](https://github.com/HackerNews/API) shows that will use the `/v0/topstories` endpoint to get a list of IDs, and the `/v0/items/` endpoint to get the data for each individual story.
194 |
195 | 
196 |
197 | The `/v0/topstories` endpoint returns all top story IDs which is ~400–500 items in the list. Since we fetch the data for each story individually, it would kill performance to then fetch all 500 individual items immediately. To solve this, we only fetch 20 stories at a time. We `.slice()` the story ID array based on the current page and return that section of story items. Since we call the the `/v0/item/` for each story ID, we use a `Promise.all` to condense the response promises into a single array resolving to one `.then()` and preserving the ranking form the order of the story IDs.
198 |
199 | To manage the state of the stories inside our application, we will create a `story` reducer. Create a `src/store/story` folder and inside it add a `reducer.js` file and an `actions.js` file. Inside the `actions.js` file, add the following code.
200 |
201 | 
202 |
203 | We create `actionTypes` for the request, success, and failure states for our story ID and story items API calls.
204 |
205 | Our `actions` object will contain `thunk` functions which manages the request. By dispatching functions instead an action object, we are able to `dispatch` actions at different points during the request lifecycle.
206 |
207 | The function `getTopStoryIds` will make the API call to get the full list of stories. In the success callback of `getTopStoryIds`, we `dispatch` the `fetchStories` action to retrieve the first page of results for story items.
208 |
209 | When our API calls successfully return, we `dispatch` the success `action`, allowing us to update our Redux store with the new data.
210 |
211 | > A basic implementation of the thunk package only uses a few lines of code. It requires knowledge of Redux middleware to understand it fully, but from the code we can see that if our `action` is a `function` instead of an `object`, we execute that function and pass `dispatch` as the argument.
212 |
213 | 
214 |
215 | Now we need to create the reducer to store the data in our Redux state. Inside the `src/store/story/reducer.js` file, add the following.
216 |
217 | 
218 |
219 | For the `FETCH_STORY_IDS_SUCCESS` action type, we spread the current state and payload. The only key/value inside the payload is `storyIds`, which will then update the state to the new value.
220 |
221 | For the `FETCH_STORIES_SUCCESS` action type, we add the new stories to the previously created list of stories which will keep them in order as we fetch more pages. In addition, we increment the page and set the `isFetching` state to false.
222 |
223 | Now that we are managing the state of our stories in Redux, we can display this data using our components.
224 |
225 | ### Connect the React App to the Redux Store
226 |
227 | By using the `react-redux` bindings, we are able to `connect` our components to the store and receive Redux state as `props`. Then any time there is an update to the store, the props will also change causing a re-render of our components which will update the UI.
228 |
229 | We also pass functions as props to our components that `dispatch` actions. When we call these functions inside our component, it can trigger state changes in our Redux store.
230 |
231 | Let’s see how we manage this in our application. Return to the `src/components/App` folder create an `App.js` file and copy and paste the content from the `src/components/App/index.js` to the new `App.js` file. Inside the `index.js` we will connect the `App` component to Redux. Add the following code to the `index.js` file.
232 |
233 | 
234 |
235 | The `mapStateToProps` is a function that takes the Redux `state` as an argument and returns an object that is passed as props to the connected component. For `App`, we need the array of `stories`, the current `page`, the array of `storyIds`, and the `isFetching` indicator.
236 |
237 | The `mapDispatchToProps` is a function that takes the `dispatch` function as an argument and returns an object of functions passed as props to our Component. We create a function `fetchStoriesFirstPage` that will `disptach` the action to fetch story IDs (and then fetches the first page of story items).
238 |
239 | We utilize these props inside our `App.js` file. First we add a `componentDidMount` so that the stories are fetched once the component is in the DOM. This pass the `stories` prop to our `List` component
240 |
241 | 
242 |
243 | Inside `src/components/List/index.js` we map over the stories array and create an array of `ListItem` components. We set the key to the story ID and spread the story object `…story` — this pass all the values of the object as individual props to the component. The `key` prop is required for components mounted as an array so that React can be faster when updating them during a render.
244 |
245 | 
246 |
247 | If we look at the screen now, we should have 20 list items but still using the hard-coded data.
248 |
249 | 
250 |
251 | We need to update our `ListItem` to use the values from the stories. Also in Hacker News, it displays the time since the story was published and the domain of the source. We will install the `[timeago.js](https://www.npmjs.com/package/timeago.js)` and `[url](https://www.npmjs.com/package/url)` packages to help calculate these values since they are not passed directly from the API. Install them using the following command.
252 |
253 | ```
254 | yarn add timeago.js url
255 | ```
256 |
257 | We will also write helper functions to build these values. Copy and the files from the `src/utils` folder in the [source code](https://github.com/gitconnected/hacker-news-reader/tree/master/src/utils).
258 |
259 | Now we can update our the `src/components/ListItem/index.js` file to the following.
260 |
261 | 
262 |
263 | And with that step, we are now displaying the first 20 top Hacker News items in our app — very cool!
264 |
265 | 
266 |
267 | ### Paginating Requests with Infinite Scroll
268 |
269 | Now we want to fetch an additional page as the user scrolls down the screen. Recall that every time we successfully fetch stories, we increment the page number in the store, and so after the first page is received, our Redux store should now read `page: 1`. We need a way to `dispatch` the `fetchStories` action on scroll.
270 |
271 | To implement infinite scrolling, we’ll use the `react-infinite-scroll-component`. We will also want a way to determine if we have more pages to load and we can do this in a selector using `[reselect](https://www.npmjs.com/package/reselect)`.
272 |
273 | ```
274 | yarn add react-infinite-scroll-component reselect
275 | ```
276 |
277 | First we will build our selector to calculate if more stories exist. Create a `src/store/story/selectors.js` file. To determine if more stories exist, we see if the array length of the `storyIds` in our Redux store has the same length as the `stories` array. If the `stories` array is shorter, we know that there are more pages.
278 |
279 | 
280 |
281 | Inside the `src/components/App/index.js` container, we import the `hasMoreStoriesSelector` and add a `hasMoreStories` key to our `mapStateToProps`. Also, add the `fetchStories` action to our `mapDispatchToProps` so we can load them as we scroll.
282 |
283 | 
284 |
285 | We will want a loading animation to show while we wait on our API request. Create a `src/components/Loader` folder and the `index.js` and `styles.js` files. We want our animation to be 3 fading dots.
286 |
287 | 
288 |
289 | Inside the `styles.js` file add the following code.
290 |
291 | 
292 |
293 | The [@keyframes](https://developer.mozilla.org/en-US/docs/Web/CSS/@keyframes) API is a CSS technique to define animations. The Above code shows the abstraction for it in Styled Components. We will have 3 dots on the screen that have their opacity start at 0.2, increase to 1, and then return to 0.2. We add an animation delay to the second and third dot which gives the offset bouncing appearance.
294 |
295 |
296 | Our `Loader` component will just be our `Animation` styled component with 3 spans containing periods.
297 |
298 | 
299 |
300 | Now we are ready to add the functionality to our list. Import the infinite scroll module and our `Loader` in the `App` component. We will also create a `fetchStories` callback that will call the `fetchStories` prop to dispatch the request for the next page. We only call the `fetchStories` dispatch prop if the `isFetching` is false. Otherwise we could fetch the same page multiple times. Your `src/components/App/App.js` should now look like the following.
301 |
302 | 
303 |
304 | As we scroll down the page, the `InfiniteScroll` component will call `this.fetchStories` as long as `hasMoreStories` is true. When the fetchStories API request returns, the new stories are appending to `stories` array, adding them to the page.
305 |
306 | With this functionality, you can now scroll through the entire list of top stories! *high fives*
307 |
308 | ### Your Final Challenge
309 |
310 | At the beginning of the tutorial, we initialized a `theme` property in our `App` reducer. Now I’ll leave it up to you implement the toggle functionality on your own. You will want to add a click event in some component that dispatches the `setTheme` action and toggles between `light` and `dark`. You will want to have a ternary condition on your `ThemeProvider` component that will pass `colorsDark` if `state.app.theme === 'dark'`, otherwise use `colorsLight`.
311 |
312 | If you get stuck, refer to the [source code](https://github.com/gitconnected/hacker-news-reader) to see our implementation, [join our Slack and as for help](https://community.gitconnected.com), and [try it out on our working solution](https://chrome.google.com/webstore/detail/hacker-news/hknoigmfpgfdkccnkbfbjfnocoegoefe?authuser=1).
313 |
314 | ### Deploying to GitHub Pages
315 |
316 | The final step to any production app is actually pushing it to production. Since all of our functionality is on the client, we can deploy it as a static site for free using [GitHub Pages](https://pages.github.com/).
317 |
318 | Commit all your code and push it to GitHub. I named my repo `hn-clone`. [Follow this guide](https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/) if you need help with creating the repo and uploading the code.
319 |
320 | Now use the following steps to host it on GitHub Pages:
321 |
322 | 1. Add `"homepage": "http://.github.io/"` to your `package.json`. Replace `` and `` with the values that you used — my values would be `treyhuffine` and `hn-clone`.
323 |
324 | 
325 |
326 | 2. Install `gh-pages` as a dev dependency
327 |
328 | ```
329 | yarn add -D gh-pages
330 | ```
331 |
332 | 3. Add 2 scripts to your `package.json`
333 |
334 | ```
335 | "predeploy": "npm run build","deploy": "gh-pages -d build"
336 | ```
337 |
338 | 
339 |
340 | 4. Finally, run `yarn deploy` and visit the URL that you specified in the homepage.
341 |
342 | 
343 |
344 | And now you have a Hacker News clone in production! *double high five*
345 |
346 | ### Conclusion
347 |
348 | This covers the essential functionality required to build the Hacker News clone. The [source code](https://github.com/gitconnected/hacker-news-reader) has a few additional features and is continuing to be updated, so check there for some inspiration to continue building out the app and learn more React.
349 |
350 | Don’t forget to [download the Chrome Extension](https://chrome.google.com/webstore/detail/hacker-news/hknoigmfpgfdkccnkbfbjfnocoegoefe?authuser=1) and visit [gitconnected.com to join the developer community](https://gitconnected.com).
351 |
--------------------------------------------------------------------------------
/tutorial/meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "React & Redux Tutorial—Build a Hacker News Clone",
3 | "description": "Learn React by building a production React project using Redux and Styled Components. Deploy the app using GitHub pages.",
4 | "createdAt": 1539091633902,
5 | "publishedAt": 1539091633902,
6 | "updatedAt": 1546004374840,
7 | "image": {
8 | "@type": "ImageObject",
9 | "width": 480,
10 | "height": 360,
11 | "url": "https://cdn-images-1.medium.com/max/960/0*3EzhXfHzHuNx7eML.jpg"
12 | },
13 | "socialImage": "https://cdn-images-1.medium.com/max/2000/1*Ufu70cs08PyXKaM2Wb6D0w.png",
14 | "url": "https://gitconnected.com/courses/learn-react-redux-tutorial-build-a-hacker-news-clone",
15 | "thumbnail": "https://cdn-images-1.medium.com/max/960/0*3EzhXfHzHuNx7eML.jpg",
16 | "keywords": [
17 | "Tag:React",
18 | "Tag:JavaScript",
19 | "Tag:Web Development",
20 | "Tag:Technology",
21 | "Tag:Startup",
22 | "Publication:gitconnected",
23 | "LockedPostSource:0",
24 | "Elevated:false",
25 | "LayerCake:0"
26 | ],
27 | "author": {
28 | "@type": "Person",
29 | "name": "Trey Huffine",
30 | "url": "https://gitconnected.com/treyhuffine"
31 | },
32 | "creator": [
33 | "Trey Huffine"
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------