.eslintrc.json
{
"extends": ["react-app", "plugin:prettier/recommended"],
"plugins": ["cypress"],
"env": {
"cypress/globals": true
}
}
.gitignore
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.vscode
/cypress/videos/
/cypress/screenshots/
.prettierrc.json
{
"printWidth": 80,
"trailingComma": "all",
"singleQuote": true,
"arrowParens": "always"
}
README.md
# ![RealWorld Example App](logo.png)
> ### React Hooks + Typescript codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API.
### [Demo](https://github.com/gothinkster/realworld) [RealWorld](https://github.com/gothinkster/realworld)
This codebase was created to demonstrate a fully fledged fullstack application built with **React Hooks** and **Typescript** including CRUD operations, authentication, routing, pagination, and more.
We've gone to great lengths to adhere to the **React Hooks** and **Typescript** community styleguides & best practices.
For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo.
# How it works
> Describe the general architecture of your app here
# Todos
- [ ] Testing
- [ ] Improve Accessibility
# Getting started
```bash
# Clone the reposiory
git clone https://github.com/haythemchagwey/react-hooks-typescript-realworld.git
# Cd into the folder
cd react-hooks-typescript-realworld
# Install the dependencies
npm install
# Run the development server
npm start
```
cypress
+---- fixtures
| +---- example.json
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}
+---- integration
| +---- login-spec.js
// Copyright (c) 2019 Applitools
// https://github.com/cypress-io/cypress-example-realworld
describe('Conduit Login', () => {
before(() => cy.registerUserIfNeeded());
beforeEach(() => {
cy.visit('/');
// we are not logged in
});
it('does not work with wrong credentials', () => {
cy.contains('a.nav-link', 'Sign in').click();
cy.get('input[type="email"]').type('wrong@email.com');
cy.get('input[type="password"]').type('no-such-user');
cy.get('button[type="submit"]').click();
// error message is shown and we remain on the login page
cy.contains('.error-messages li', 'email or password is invalid');
cy.url().should('contain', '/login');
});
it('logs in', () => {
cy.contains('a.nav-link', 'Sign in').click();
const user = Cypress.env('user');
cy.get('input[type="email"]').type(user.email);
cy.get('input[type="password"]').type(user.password);
cy.get('button[type="submit"]').click();
// when we are logged in, there should be two feeds
cy.contains('button.nav-link', 'Your Feed').should(
'not.have.class',
'active',
);
cy.contains('button.nav-link', 'Global Feed').should(
'have.class',
'active',
);
// url is /
cy.url().should('not.contain', '/login');
});
});
+---- plugins
| +---- index.js
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
};
+---- support
| +---- index.js
// import '@cypress/code-coverage/support';
const apiUrl = Cypress.env('apiUrl');
// a custom Cypress command to login using XHR call
// and then set the received token in the local storage
// can log in with default user or with a given one
Cypress.Commands.add('login', (user = Cypress.env('user')) => {
cy.request('POST', `${apiUrl}/users/login`, {
user: Cypress._.pick(user, ['email', 'password']),
})
.its('body.user.token')
.should('exist')
.then((token) => {
localStorage.setItem('token', token);
// with this token set, when we visit the page
// the web application will have the user logged in
});
cy.visit('/');
});
// creates a user with email and password
// defined in cypress.json environment variables
// if the user already exists, ignores the error
// or given user info parameters
Cypress.Commands.add('registerUserIfNeeded', (options = {}) => {
const defaults = {
username: 'reactuser',
// email, password
...Cypress.env('user'),
};
const user = Cypress._.defaults({}, options, defaults);
cy.request({
method: 'POST',
url: `${apiUrl}/users`,
body: {
user,
},
failOnStatusCode: false,
});
});
cypress.json
{
"baseUrl": "http://localhost:3000",
"env": {
"apiUrl": "https://conduit.productionready.io/api",
"user": {
"email": "reactuser@test.com",
"password": "react123456"
}
},
"viewportHeight": 1000,
"viewportWidth": 1000
}
package.json
{
"name": "react-hooks-typescript-realworld",
"version": "0.1.0",
"description": "React and Typescript codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API.",
"private": true,
"repository": "https://github.com/haythemchagwey/react-hooks-typescript-realworld",
"author": "Haythem Chagwey",
"license": "MIT",
"dependencies": {
"@reach/router": "^1.3.4",
"@types/jest": "^26.0.15",
"@types/marked": "^1.2.0",
"@types/node": "^14.14.9",
"@types/reach__router": "^1.3.6",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"axios": "^0.21.0",
"jwt-decode": "^3.1.2",
"marked": "^1.2.5",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-scripts": "^4.0.0",
"typescript": "^4.1.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"husky": {
"hooks": {
"pre-commit": "pretty-quick --staged"
}
},
"eslintConfig": {
"extends": [
"react-app",
"plugin:prettier/recommended"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"cypress": "^5.6.0",
"eslint-config-prettier": "^6.15.0",
"eslint-plugin-cypress": "^2.11.2",
"eslint-plugin-prettier": "^3.1.4",
"husky": "^4.3.0",
"prettier": "^2.2.0",
"pretty-quick": "^3.1.0"
}
}
public
+---- index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!-- Import Ionicon icons & Google Fonts our Bootstrap theme relies on -->
<link
href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css"
rel="stylesheet"
type="text/css"
/>
<link
href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic"
rel="stylesheet"
type="text/css"
/>
<!-- Import the custom Bootstrap 4 theme from our hosted CDN -->
<link rel="stylesheet" href="//demo.productionready.io/main.css" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Conduit</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
+---- manifest.json
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
src
+---- api
| +---- APIUtils.ts
import { navigate } from '@reach/router';
import axios from 'axios';
import jwtDecode from 'jwt-decode';
export const TOKEN_KEY = 'token';
axios.defaults.baseURL = 'https://conduit.productionready.io/api';
axios.interceptors.response.use(
(response) => {
return response;
},
(error) => {
switch (error.response.status) {
case 401:
navigate('/register');
break;
case 404:
case 403:
navigate('/');
break;
}
return Promise.reject(error.response);
},
);
export function setToken(token: string | null) {
if (token) {
axios.defaults.headers.common['Authorization'] = `Token ${token}`;
} else {
delete axios.defaults.headers.common['Authorization'];
}
}
type JWTPayload = {
id: string;
username: string;
exp: number;
};
export function isTokenValid(token: string) {
try {
const decoded_jwt: JWTPayload = jwtDecode(token);
const current_time = Date.now().valueOf() / 1000;
return decoded_jwt.exp > current_time;
} catch (error) {
return false;
}
}
export default axios;
| +---- ArticlesAPI.ts
import API from './APIUtils';
import { IArticle } from '../types';
const encode = encodeURIComponent;
type Articles = {
articles: Array<IArticle>;
} & {
articlesCount: number;
};
type Article = {
article: IArticle;
};
function limit(count: number, p: number) {
return `limit=${count}&offset=${p ? p * count : 0}`;
}
function omitSlug(article: {
slug: string;
title?: string;
description?: string;
body?: string;
}) {
return Object.assign({}, article, { slug: undefined });
}
export function getArticles(page: number) {
return API.get<Articles>(`/articles?${limit(10, page)}`);
}
export function getArticlesByAuthor(username: string, page: number) {
return API.get<Articles>(
`/articles?author=${encode(username)}&${limit(5, page)}`,
);
}
export function getArticlesByTag(tag: string, page: number) {
return API.get<Articles>(`/articles?tag=${encode(tag)}&${limit(10, page)}`);
}
export function deleteArticle(slug: string) {
return API.delete<null>(`/articles/${slug}`);
}
export function favoriteArticle(slug: string) {
return API.post<Article>(`/articles/${slug}/favorite`);
}
export function getArticlesFavoritedBy(username: string, page: number) {
return API.get<Articles>(
`/articles?favorited=${encode(username)}&${limit(5, page)}`,
);
}
export function getFeedArticles() {
return API.get<Articles>('/articles/feed?limit=10&offset=0');
}
export function getArticle(slug: string) {
return API.get<Article>(`/articles/${slug}`);
}
export function unfavoriteArticle(slug: string) {
return API.delete<Article>(`/articles/${slug}/favorite`);
}
export function updateArticle(article: {
slug: string;
title?: string;
description?: string;
body?: string;
tagList?: string[];
}) {
return API.put<Article>(`/articles/${article.slug}`, {
article: omitSlug(article),
});
}
export function createArticle(article: {
title: string;
description: string;
body: string;
tagList?: string[];
}) {
return API.post<Article>('/articles', { article });
}
| +---- AuthAPI.ts
import API, { TOKEN_KEY } from './APIUtils';
import { IUser } from '../types';
import { setLocalStorage } from '../utils';
import { setToken } from './APIUtils';
type User = {
user: IUser & { token: string };
};
function handleUserResponse({ user: { token, ...user } }: User) {
setLocalStorage(TOKEN_KEY, token);
setToken(token);
return user;
}
export function getCurrentUser() {
return API.get<User>('/user');
}
export function login(email: string, password: string) {
return API.post<User>('/users/login', {
user: { email, password },
}).then((user) => handleUserResponse(user.data));
}
export function register(user: {
username: string;
email: string;
password: string;
}) {
return API.post<User>('/users', { user }).then((user) =>
handleUserResponse(user.data),
);
}
export function updateUser(user: IUser & Partial<{ password: string }>) {
return API.put<User>('/user', { user });
}
export function logout() {
localStorage.removeItem(TOKEN_KEY);
setToken(null);
}
| +---- CommentsAPI.ts
import API from './APIUtils';
import { IComment } from '../types';
type Comment = {
comment: IComment;
};
type Comments = {
comments: Array<IComment>;
};
export function createComment(slug: string, comment: { body: string }) {
return API.post<Comment>(`/articles/${slug}/comments`, { comment });
}
export function deleteComment(slug: string, commentId: number) {
return API.delete<null>(`/articles/${slug}/comments/${commentId}`);
}
export function getArticleComments(slug: string) {
return API.get<Comments>(`/articles/${slug}/comments`);
}
| +---- ProfileAPI.ts
import API from './APIUtils';
import { IProfile } from '../types';
type Profile = {
profile: IProfile;
};
export function followProfile(username: string) {
return API.post<Profile>(`/profiles/${username}/follow`);
}
export function getProfile(username: string) {
return API.get<Profile>(`/profiles/${username}`);
}
export function unfollowProfile(username: string) {
return API.delete<Profile>(`/profiles/${username}/follow`);
}
| +---- TagsAPI.ts
import API from './APIUtils';
type Tags = {
tags: string[];
};
export function getTags() {
return API.get<Tags>('/tags');
}
+---- components
| +---- App.tsx
import React from 'react';
import { Router } from '@reach/router';
import Header from './Header';
import Home from './Home';
import Register from './Register';
import Login from './Login';
import Article from './Article';
import Profile from './Profile';
import Editor from './Editor';
import Settings from './Settings';
import PrivateRoute from './PrivateRoute';
import { getCurrentUser } from '../api/AuthAPI';
import useAuth, { AuthProvider } from '../context/auth';
function App() {
const {
state: { user, isAuthenticated },
dispatch,
} = useAuth();
React.useEffect(() => {
let ignore = false;
async function fetchUser() {
try {
const payload = await getCurrentUser();
const { token, ...user } = payload.data.user;
if (!ignore) {
dispatch({ type: 'LOAD_USER', user });
}
} catch (error) {
console.log(error);
}
}
if (!user && isAuthenticated) {
fetchUser();
}
return () => {
ignore = true;
};
}, [dispatch, isAuthenticated, user]);
if (!user && isAuthenticated) {
return null;
}
return (
<React.Fragment>
<Header />
<Router>
<Home default path="/" />
<Register path="register" />
<Login path="login" />
<Article path="article/:slug" />
<Profile path=":username" />
<PrivateRoute as={Settings} path="/settings" />
<PrivateRoute as={Editor} path="/editor" />
<PrivateRoute as={Editor} path="/editor/:slug" />
</Router>
</React.Fragment>
);
}
export default () => (
<AuthProvider>
<App />
</AuthProvider>
);
| +---- Article
| +---- ArticleActions.tsx
import React from 'react';
import { Link } from '@reach/router';
import { followProfile, unfollowProfile } from '../../api/ProfileAPI';
import { IArticle } from '../../types';
import { ArticleAction } from '../../reducers/article';
import DeleteButton from './DeleteButton';
import FollowUserButton from '../common/FollowUserButton';
import FavoriteButton from '../common/FavoriteButton';
import useAuth from '../../context/auth';
type ArticleActionsProps = {
article: IArticle;
dispatch: React.Dispatch<ArticleAction>;
};
export default function ArticleActions({
article,
dispatch,
}: ArticleActionsProps) {
const [loading, setLoading] = React.useState(false);
const {
state: { user },
} = useAuth();
const canModifyArticle = user && user.username === article.author.username;
const handleFollowButtonClick = async () => {
setLoading(true);
if (article.author.following) {
await followProfile(article.author.username);
dispatch({ type: 'UNFOLLOW_AUTHOR' });
} else {
await unfollowProfile(article.author.username);
dispatch({ type: 'FOLLOW_AUTHOR' });
}
setLoading(false);
};
return canModifyArticle ? (
<React.Fragment>
<Link
to={`/editor/${article.slug}`}
className="btn btn-outline-secondary btn-sm"
>
<i className="ion-edit" /> Edit Article
</Link>
<DeleteButton article={article} />
</React.Fragment>
) : (
<React.Fragment>
<FollowUserButton
onClick={handleFollowButtonClick}
profile={article.author}
loading={loading}
/>
<FavoriteButton article={article} dispatch={dispatch}>
{article.favorited ? 'Unfavorite Article' : 'Favorite Article'}
<span className="counter">({article.favoritesCount})</span>
</FavoriteButton>
</React.Fragment>
);
}
| +---- ArticleMeta.tsx
import React from 'react';
import ArticleActions from './ArticleActions';
import { IArticle } from '../../types';
import { ArticleAction } from '../../reducers/article';
import ArticleAvatar from '../common/ArticleAvatar';
type ArticleMetaProps = {
article: IArticle;
dispatch: React.Dispatch<ArticleAction>;
};
function ArticleMeta({ article, dispatch }: ArticleMetaProps) {
return (
<div className="article-meta">
<ArticleAvatar article={article} />
<ArticleActions article={article} dispatch={dispatch} />
</div>
);
}
export default React.memo(ArticleMeta);
| +---- Comment.tsx
import React from 'react';
import { Link } from '@reach/router';
import { IComment, IUser } from '../../types';
import { ArticleAction } from '../../reducers/article';
import { deleteComment } from '../../api/CommentsAPI';
type CommentProps = {
comment: IComment;
slug: string;
user: IUser | null;
dispatch: React.Dispatch<ArticleAction>;
};
function Comment({ comment, slug, user, dispatch }: CommentProps) {
const showDeleteButton = user && user.username === comment.author.username;
const handleDelete = async () => {
try {
await deleteComment(slug, comment.id);
dispatch({ type: 'DELETE_COMMENT', commentId: comment.id });
} catch (error) {
console.log(error);
}
};
return (
<div className="card">
<div className="card-block">
<p className="card-text">{comment.body}</p>
</div>
<div className="card-footer">
<Link to={`/@${comment.author.username}`} className="comment-author">
<img
src={comment.author.image}
className="comment-author-img"
alt={comment.author.username}
/>
{comment.author.username}
</Link>
<span className="date-posted">
{new Date(comment.createdAt).toDateString()}
</span>
{showDeleteButton && (
<span className="mod-options">
<i className="ion-trash-a" onClick={handleDelete} />
</span>
)}
</div>
</div>
);
}
export default Comment;
| +---- CommentContainer.tsx
import React from 'react';
import { Link } from '@reach/router';
import Comment from './Comment';
import CommentInput from './CommentInput';
import { IComment } from '../../types';
import { ArticleAction } from '../../reducers/article';
import useAuth from '../../context/auth';
type CommentContainerProps = {
comments: Array<IComment>;
slug: string;
dispatch: React.Dispatch<ArticleAction>;
};
function CommentContainer({ comments, slug, dispatch }: CommentContainerProps) {
const {
state: { user },
} = useAuth();
return (
<div className="row">
<div className="col-xs-12 col-md-8 offset-md-2">
{user ? (
<CommentInput user={user} slug={slug} dispatch={dispatch} />
) : (
<p>
<Link to="/login">Sign in</Link>
or
<Link to="/register">Sign up</Link>
to add comments on this article.
</p>
)}
{comments.map((comment) => (
<Comment
key={comment.id}
comment={comment}
slug={slug}
user={user}
dispatch={dispatch}
/>
))}
</div>
</div>
);
}
export default React.memo(CommentContainer);
| +---- CommentInput.tsx
import React from 'react';
import { IUser } from '../../types';
import { ArticleAction } from '../../reducers/article';
import { createComment } from '../../api/CommentsAPI';
type CommentInputProps = {
user: IUser;
slug: string;
dispatch: React.Dispatch<ArticleAction>;
};
export default function CommentInput({
user,
slug,
dispatch,
}: CommentInputProps) {
const [body, setBody] = React.useState('');
const [loading, setLoading] = React.useState(false);
const handleSubmit = async (event: React.SyntheticEvent) => {
event.preventDefault();
setLoading(true);
try {
const payload = await createComment(slug, { body });
dispatch({ type: 'ADD_COMMENT', payload: payload.data });
} catch (error) {
console.log(error);
}
setLoading(false);
setBody('');
};
return (
<form className="card comment-form" onSubmit={handleSubmit}>
<div className="card-block">
<textarea
className="form-control"
placeholder="Write a comment..."
value={body}
onChange={(event) => setBody(event.target.value)}
rows={3}
/>
</div>
<div className="card-footer">
<img
src={user.image}
className="comment-author-img"
alt={user.username}
/>
<button
className="btn btn-sm btn-primary"
type="submit"
disabled={loading}
>
Post Comment
</button>
</div>
</form>
);
}
| +---- DeleteButton.tsx
import React from 'react';
import { navigate } from '@reach/router';
import { IArticle } from '../../types';
import { deleteArticle } from '../../api/ArticlesAPI';
export default function DeleteButton({ article }: { article: IArticle }) {
const handleDelete = async () => {
try {
await deleteArticle(article.slug);
navigate('/');
} catch (error) {
console.log(error);
}
};
return (
<button className="btn btn-outline-danger btn-sm" onClick={handleDelete}>
<i className="ion-trash-a" /> Delete Article
</button>
);
}
| +---- index.tsx
import React from 'react';
import marked from 'marked';
import ArticleMeta from './ArticleMeta';
import ArticleTags from '../common/ArticleTags';
import CommentContainer from './CommentContainer';
import { RouteComponentProps } from '@reach/router';
import { articleReducer, initialState } from '../../reducers/article';
import { getArticleComments } from '../../api/CommentsAPI';
import { getArticle } from '../../api/ArticlesAPI';
export default function Article({
slug = '',
}: RouteComponentProps<{ slug: string }>) {
const [{ article, comments, loading, error }, dispatch] = React.useReducer(
articleReducer,
initialState,
);
React.useEffect(() => {
dispatch({ type: 'FETCH_ARTICLE_BEGIN' });
let ignore = false;
const fetchArticle = async () => {
try {
const [articlePayload, commentsPayload] = await Promise.all([
getArticle(slug),
getArticleComments(slug),
]);
if (!ignore) {
dispatch({
type: 'FETCH_ARTICLE_SUCCESS',
payload: {
article: articlePayload.data.article,
comments: commentsPayload.data.comments,
},
});
}
} catch (error) {
console.log(error);
dispatch({
type: 'FETCH_ARTICLE_ERROR',
error,
});
}
};
fetchArticle();
return () => {
ignore = true;
};
}, [dispatch, slug]);
const convertToMarkdown = (text: string) => ({
__html: marked(text, { sanitize: true }),
});
return (
article && (
<div className="article-page">
<div className="banner">
<div className="container">
<h1>{article.title}</h1>
<ArticleMeta article={article} dispatch={dispatch} />
</div>
</div>
<div className="container page">
<div className="row article-content">
<div className="col-md-12">
<p dangerouslySetInnerHTML={convertToMarkdown(article.body)} />
<ArticleTags tagList={article.tagList} />
</div>
</div>
<hr />
<div className="article-actions">
<ArticleMeta article={article} dispatch={dispatch} />
</div>
<CommentContainer
comments={comments}
slug={slug}
dispatch={dispatch}
/>
</div>
</div>
)
);
}
| +---- ArticleList.tsx
import React from 'react';
import ArticlePreview from './ArticlePreview';
import ListPagination from './ListPagination';
import {
getArticles,
getFeedArticles,
getArticlesByTag,
getArticlesByAuthor,
getArticlesFavoritedBy,
} from '../api/ArticlesAPI';
import useArticles from '../context/articles';
import { ITab } from '../reducers/articleList';
const loadArticles = (tab: ITab, page = 0) => {
switch (tab.type) {
case 'FEED':
return getFeedArticles();
case 'ALL':
return getArticles(page);
case 'TAG':
return getArticlesByTag(tab.label, page);
case 'AUTHORED':
return getArticlesByAuthor(tab.username, page);
case 'FAVORITES':
return getArticlesFavoritedBy(tab.username, page);
default:
// return getArticles(page);
throw new Error('type does not exist');
}
};
export default function ArticleList() {
const {
state: { articles, loading, error, articlesCount, selectedTab, page },
dispatch,
} = useArticles();
React.useEffect(() => {
let ignore = false;
async function fetchArticles() {
dispatch({ type: 'FETCH_ARTICLES_BEGIN' });
try {
const payload = await loadArticles(selectedTab, page);
if (!ignore) {
dispatch({ type: 'FETCH_ARTICLES_SUCCESS', payload: payload.data });
}
} catch (error) {
if (!ignore) {
dispatch({ type: 'FETCH_ARTICLES_ERROR', error });
}
}
}
fetchArticles();
return () => {
ignore = true;
};
}, [dispatch, page, selectedTab]);
if (loading) {
return <div className="article-preview">Loading...</div>;
}
if (articles.length === 0) {
return <div className="article-preview">No articles are here... yet.</div>;
}
return (
<React.Fragment>
{articles.map((article) => (
<ArticlePreview
key={article.slug}
article={article}
dispatch={dispatch}
/>
))}
<ListPagination
page={page}
articlesCount={articlesCount}
dispatch={dispatch}
/>
</React.Fragment>
);
}
| +---- ArticlePreview.tsx
import React from 'react';
import { Link } from '@reach/router';
import ArticleAvatar from './common/ArticleAvatar';
import ArticleTags from './common/ArticleTags';
import FavoriteButton from './common/FavoriteButton';
import { IArticle } from '../types';
import { ArticleListAction } from '../reducers/articleList';
type ArticlePreviewProps = {
article: IArticle;
dispatch: React.Dispatch<ArticleListAction>;
};
export default function ArticlePreview({
article,
dispatch,
}: ArticlePreviewProps) {
return (
<div className="article-preview">
<div className="article-meta">
<ArticleAvatar article={article} />
<div className="pull-xs-right">
<FavoriteButton article={article} dispatch={dispatch}>
{article.favoritesCount}
</FavoriteButton>
</div>
</div>
<Link to={`/article/${article.slug}`} className="preview-link">
<h1>{article.title}</h1>
<p>{article.description}</p>
<span>Read more...</span>
<ArticleTags tagList={article.tagList} />
</Link>
</div>
);
}
| +---- Editor.tsx
import React from 'react';
import { editorReducer, initalState } from '../reducers/editor';
import { RouteComponentProps, navigate } from '@reach/router';
import { getArticle, updateArticle, createArticle } from '../api/ArticlesAPI';
import ListErrors from './common/ListErrors';
export default function Editor({
slug = '',
}: RouteComponentProps<{ slug: string }>) {
const [state, dispatch] = React.useReducer(editorReducer, initalState);
React.useEffect(() => {
let ignore = false;
const fetchArticle = async () => {
try {
const payload = await getArticle(slug);
const { title, description, body, tagList } = payload.data.article;
if (!ignore) {
dispatch({
type: 'SET_FORM',
form: { title, description, body, tag: '' },
});
dispatch({ type: 'SET_TAGS', tagList });
}
} catch (error) {
console.log(error);
}
};
if (slug) {
fetchArticle();
}
return () => {
ignore = true;
};
}, [slug]);
const handleChange = (
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
dispatch({
type: 'UPDATE_FORM',
field: {
key: event.currentTarget.name,
value: event.currentTarget.value,
},
});
};
const handelKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.keyCode === 13) {
dispatch({ type: 'ADD_TAG', tag: event.currentTarget.value });
dispatch({ type: 'UPDATE_FORM', field: { key: 'tag', value: '' } });
}
};
const handleSubmit = async (event: React.SyntheticEvent) => {
event.preventDefault();
try {
const { title, description, body } = state.form;
const article = { title, description, body, tagList: state.tagList };
let payload;
if (slug) {
payload = await updateArticle({ slug, ...article });
} else {
payload = await createArticle(article);
}
navigate(`/article/${payload.data.article.slug}`);
} catch (error) {
console.log(error);
if (error.status === 422) {
dispatch({ type: 'SET_ERRORS', errors: error.data.errors });
}
}
};
return (
<div className="editor-page">
<div className="container page">
<div className="row">
<div className="col-md-10 offset-md-1 col-xs-12">
<ListErrors errors={state.errors} />
<form onSubmit={handleSubmit}>
<div className="form-group">
<input
name="title"
className="form-control form-control-lg"
type="text"
placeholder="Article Title"
value={state.form.title}
onChange={handleChange}
/>
</div>
<div className="form-group">
<input
name="description"
className="form-control"
type="text"
placeholder="What's this article about?"
value={state.form.description}
onChange={handleChange}
/>
</div>
<div className="form-group">
<textarea
name="body"
className="form-control"
rows={8}
placeholder="Write your article (in markdown)"
value={state.form.body}
onChange={handleChange}
></textarea>
</div>
<div className="form-group">
<input
name="tag"
className="form-control"
type="text"
placeholder="Enter tags"
value={state.form.tag}
onChange={handleChange}
onKeyUp={handelKeyUp}
/>
<div className="tag-list">
{state.tagList.map((tag) => {
return (
<span className="tag-default tag-pill" key={tag}>
<i
className="ion-close-round"
onClick={() => dispatch({ type: 'REMOVE_TAG', tag })}
></i>
{tag}
</span>
);
})}
</div>
</div>
<button
className="btn btn-lg pull-xs-right btn-primary"
type="submit"
>
Publish Article
</button>
</form>
</div>
</div>
</div>
</div>
);
}
| +---- Header.tsx
import React from 'react';
import { Link, LinkGetProps, LinkProps } from '@reach/router';
import useAuth from '../context/auth';
import { IUser } from '../types';
import { APP_NAME } from '../utils';
export default function Header() {
const {
state: { user },
} = useAuth();
return (
<nav className="navbar navbar-light">
<div className="container">
<Link to="/" className="navbar-brand">
{APP_NAME}
</Link>
{user ? <LoggedInView user={user} /> : <LoggedOutView />}
</div>
</nav>
);
}
const LoggedInView = ({ user: { username, image } }: { user: IUser }) => (
<ul className="nav navbar-nav pull-xs-right">
<li className="nav-item">
<NavLink to="/">Home</NavLink>
</li>
<li className="nav-item">
<NavLink to="/editor">
<i className="ion-compose" />
New Post
</NavLink>
</li>
<li className="nav-item">
<NavLink to="/settings">
<i className="ion-gear-a" />
Settings
</NavLink>
</li>
<li className="nav-item">
<NavLink to={`/${username}`}>
{image && <img src={image} className="user-pic" alt={username} />}
{username}
</NavLink>
</li>
</ul>
);
const LoggedOutView = () => (
<ul className="nav navbar-nav pull-xs-right">
<li className="nav-item">
<NavLink to="/">Home</NavLink>
</li>
<li className="nav-item">
<NavLink to="/login">Sign in</NavLink>
</li>
<li className="nav-item">
<NavLink to="/register">Sign up</NavLink>
</li>
</ul>
);
const NavLink = (props: LinkProps<{}>) => (
// @ts-ignore
<Link getProps={isActive} {...props} />
);
const isActive = ({ isCurrent }: LinkGetProps) => {
return isCurrent
? { className: 'nav-link active' }
: { className: 'nav-link' };
};
| +---- Home
| +---- Banner.tsx
import React from 'react';
import { APP_NAME } from '../../utils';
export default function Banner() {
return (
<div className="banner">
<div className="container">
<h1 className="logo-font">{APP_NAME}</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
);
}
| +---- MainView.tsx
import React from 'react';
import ArticleList from '../ArticleList';
import TabList from '../common/TabList';
import { ITab } from '../../reducers/articleList';
export default function MainView() {
const tabsData: Array<ITab> = [
{ type: 'FEED', label: 'Your Feed' },
{ type: 'ALL', label: 'Global Feed' },
];
return (
<div className="col-md-9">
<div className="feed-toggle">
<TabList data={tabsData} />
</div>
<ArticleList />
</div>
);
}
| +---- Tags.tsx
import React from 'react';
import { getTags } from '../../api/TagsAPI';
import useArticles from '../../context/articles';
function Tags() {
const [tags, setTags] = React.useState<string[]>([]);
const [loading, setLoading] = React.useState(false);
const { dispatch } = useArticles();
React.useEffect(() => {
let ignore = false;
async function fetchTags() {
setLoading(true);
try {
const payload = await getTags();
if (!ignore) {
setTags(payload.data.tags);
}
} catch (error) {
console.log(error);
}
if (!ignore) {
setLoading(false);
}
}
fetchTags();
return () => {
ignore = true;
};
}, []);
return (
<div className="col-md-3">
<div className="sidebar">
<p>Popular Tags</p>
{loading ? (
<div>Loading Tags...</div>
) : (
<div className="tag-list">
{tags.map((tag) => (
<button
key={tag}
className="tag-pill tag-default"
onClick={() =>
dispatch({
type: 'SET_TAB',
tab: { type: 'TAG', label: tag },
})
}
>
{tag}
</button>
))}
</div>
)}
</div>
</div>
);
}
export default Tags;
| +---- index.tsx
import React from 'react';
import Banner from './Banner';
import MainView from './MainView';
import Tags from './Tags';
import { ArticlesProvider } from '../../context/articles';
import { RouteComponentProps } from '@reach/router';
export default function Home(_: RouteComponentProps) {
return (
<div className="home-page">
<Banner />
<div className="container page">
<div className="row">
<ArticlesProvider>
<MainView />
<Tags />
</ArticlesProvider>
</div>
</div>
</div>
);
}
| +---- ListPagination.tsx
import React from 'react';
import { ArticleListAction } from '../reducers/articleList';
type ListPaginationProps = {
page: number;
articlesCount: number;
dispatch: React.Dispatch<ArticleListAction>;
};
export default function ListPagination({
page,
articlesCount,
dispatch,
}: ListPaginationProps) {
const pageNumbers = [];
for (let i = 0; i < Math.ceil(articlesCount / 10); ++i) {
pageNumbers.push(i);
}
if (articlesCount <= 10) {
return null;
}
return (
<nav>
<div className="pagination">
{pageNumbers.map((number) => {
const isCurrent = number === page;
return (
<li
className={isCurrent ? 'page-item active' : 'page-item'}
onClick={() => dispatch({ type: 'SET_PAGE', page: number })}
key={number}
>
<button className="page-link">{number + 1}</button>
</li>
);
})}
</div>
</nav>
);
}
| +---- Login.tsx
import React from 'react';
import { login } from '../api/AuthAPI';
import ListErrors from './common/ListErrors';
import useAuth from '../context/auth';
import { navigate, Link, RouteComponentProps, Redirect } from '@reach/router';
import { IErrors } from '../types';
export default function Login(_: RouteComponentProps) {
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [errors, setErrors] = React.useState<IErrors | null>();
const {
state: { user },
dispatch,
} = useAuth();
const handleSubmit = async (event: React.SyntheticEvent) => {
event.preventDefault();
setLoading(true);
try {
const user = await login(email, password);
dispatch({ type: 'LOAD_USER', user });
navigate('/');
} catch (error) {
console.log(error);
setLoading(false);
if (error.status === 422) {
setErrors(error.data.errors);
}
}
};
if (user) {
return <Redirect to="/" noThrow />;
}
return (
<div className="auth-page">
<div className="container page">
<div className="row">
<div className="col-md-6 offset-md-3 col-xs-12">
<h1 className="text-xs-center">Sign in</h1>
<p className="text-xs-center">
<Link to="/register">Need an account?</Link>
</p>
{errors && <ListErrors errors={errors} />}
<form onSubmit={handleSubmit}>
<fieldset className="form-group">
<input
name="email"
className="form-control form-control-lg"
type="email"
value={email}
placeholder="Email"
onChange={(event) => setEmail(event.target.value)}
/>
</fieldset>
<fieldset className="form-group">
<input
name="password"
className="form-control form-control-lg"
type="password"
value={password}
placeholder="Password"
onChange={(event) => setPassword(event.target.value)}
/>
</fieldset>
<button
className="btn btn-lg btn-primary pull-xs-right"
type="submit"
disabled={loading}
>
Sign In
</button>
</form>
</div>
</div>
</div>
</div>
);
}
| +---- PrivateRoute.tsx
import React from 'react';
import useAuth from '../context/auth';
import Home from './Home';
import { RouteComponentProps } from '@reach/router';
interface PrivateRouteProps extends RouteComponentProps {
as: React.ElementType<any>;
}
export default function PrivateRoute({
as: Comp,
...props
}: PrivateRouteProps) {
const {
state: { user },
} = useAuth();
return user ? <Comp {...props} /> : <Home />;
}
| +---- Profile.tsx
import React from 'react';
import { Link, RouteComponentProps } from '@reach/router';
import ProfileArticles from './ProfileArticles';
import FollowUserButton from './common/FollowUserButton';
import { IProfile } from '../types';
import useAuth from '../context/auth';
import { unfollowProfile, followProfile, getProfile } from '../api/ProfileAPI';
import { ALT_IMAGE_URL } from '../utils';
export default function Profile({
username = '',
}: RouteComponentProps<{ username: string }>) {
const [profile, setProfile] = React.useState<IProfile | null>(null);
const [loading, setLoading] = React.useState(false);
const {
state: { user },
} = useAuth();
React.useEffect(() => {
let ignore = false;
async function fetchProfile() {
try {
const payload = await getProfile(username);
if (!ignore) {
setProfile(payload.data.profile);
}
} catch (error) {
console.log(error);
}
}
fetchProfile();
return () => {
ignore = true;
};
}, [username]);
const handleClick = async () => {
if (!profile) return;
let payload;
setLoading(true);
try {
if (profile.following) {
payload = await unfollowProfile(profile.username);
} else {
payload = await followProfile(profile.username);
}
setProfile(payload.data.profile);
} catch (error) {
console.log(error);
}
setLoading(false);
};
const isUser = profile && user && profile.username === user.username;
return (
profile && (
<div className="profile-page">
<div className="user-info">
<div className="container">
<div className="row">
<div className="col-xs-12 col-md-10 offset-md-1">
<img
src={profile.image || ALT_IMAGE_URL}
className="user-img"
alt={profile.username}
/>
<h4>{profile.username}</h4>
<p>{profile.bio}</p>
{isUser ? (
<EditProfileSettings />
) : (
<FollowUserButton
profile={profile}
onClick={handleClick}
loading={loading}
/>
)}
</div>
</div>
</div>
</div>
<ProfileArticles username={username} />
</div>
)
);
}
function EditProfileSettings() {
return (
<Link
to="/settings"
className="btn btn-sm btn-outline-secondary action-btn"
>
<i className="ion-gear-a"></i> Edit Profile Settings
</Link>
);
}
| +---- ProfileArticles.tsx
import React from 'react';
import { ITab } from '../reducers/articleList';
import { ArticlesProvider } from '../context/articles';
import TabList from './common/TabList';
import ArticleList from './ArticleList';
function ProfileArticles({ username }: { username: string }) {
const tabsData: Array<ITab> = [
{ type: 'AUTHORED', label: 'My Articles', username },
{
type: 'FAVORITES',
label: 'Favorited Articles',
username,
},
{ type: 'ALL', label: 'Global Feed' },
];
return (
<ArticlesProvider>
<div className="container">
<div className="row">
<div className="col-xs-12 col-md-10 offset-md-1">
<div className="articles-toggle">
<TabList data={tabsData} />
</div>
<ArticleList />
</div>
</div>
</div>
</ArticlesProvider>
);
}
export default React.memo(ProfileArticles);
| +---- Register.tsx
import React from 'react';
import { Link, navigate, RouteComponentProps, Redirect } from '@reach/router';
import { register } from '../api/AuthAPI';
import useAuth from '../context/auth';
import ListErrors from './common/ListErrors';
import { IErrors } from '../types';
export default function Register(_: RouteComponentProps) {
const [form, setForm] = React.useState({
username: '',
email: '',
password: '',
});
const [loading, setLoading] = React.useState(false);
const [errors, setErrors] = React.useState<IErrors | null>(null);
const {
state: { user },
dispatch,
} = useAuth();
const handleChange = (event: React.FormEvent<HTMLInputElement>) => {
setForm({
...form,
[event.currentTarget.name]: event.currentTarget.value,
});
};
const handleSubmit = async (event: React.SyntheticEvent) => {
event.preventDefault();
setLoading(true);
const { username, email, password } = form;
try {
const user = await register({ username, email, password });
dispatch({ type: 'LOAD_USER', user });
navigate('/');
} catch (error) {
console.log(error);
setLoading(false);
if (error.status === 422) {
setErrors(error.data.errors);
}
}
};
if (user) {
return <Redirect to="/" noThrow />;
}
return (
<div className="auth-page">
<div className="container page">
<div className="row">
<div className="col-md-6 offset-md-3 col-xs-12">
<h1 className="text-xs-center">Sign up</h1>
<p className="text-xs-center">
<Link to="/login">Have an account?</Link>
</p>
{errors && <ListErrors errors={errors} />}
<form onSubmit={handleSubmit}>
<fieldset className="form-group">
<input
name="username"
className="form-control form-control-lg"
type="text"
value={form.username}
placeholder="Your Name"
onChange={handleChange}
/>
</fieldset>
<fieldset className="form-group">
<input
name="email"
className="form-control form-control-lg"
type="email"
value={form.email}
placeholder="Email"
onChange={handleChange}
/>
</fieldset>
<fieldset className="form-group">
<input
name="password"
className="form-control form-control-lg"
type="password"
value={form.password}
placeholder="Password"
onChange={handleChange}
/>
</fieldset>
<button
className="btn btn-lg btn-primary pull-xs-right"
disabled={loading}
>
Sign Up
</button>
</form>
</div>
</div>
</div>
</div>
);
}
| +---- Settings.tsx
import React from 'react';
import { navigate, RouteComponentProps } from '@reach/router';
import ListErrors from './common/ListErrors';
import useAuth from '../context/auth';
import { updateUser, logout } from '../api/AuthAPI';
import { IErrors } from '../types';
type Form = {
username: string;
email: string;
image: string;
bio: string;
password?: string;
};
export default function Settings(_: RouteComponentProps) {
const {
state: { user },
dispatch,
} = useAuth();
const [loading, setLoading] = React.useState(false);
const [errors, setErrors] = React.useState<IErrors | null>(null);
const [form, setForm] = React.useState<Form>({
username: '',
email: '',
image: '',
bio: '',
password: '',
});
React.useEffect(() => {
if (user) {
const { username, email, image, bio } = user;
console.log(username, email, image, bio);
setForm({
username,
email,
image: image || '',
bio: bio || '',
password: '',
});
}
}, [user]);
const handleChange = (
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
setForm({
...form,
[event.currentTarget.name]: event.currentTarget.value,
});
};
const handleSubmit = async (event: React.SyntheticEvent) => {
event.preventDefault();
setLoading(true);
if (!form.password) {
delete form.password;
}
try {
const payload = await updateUser(form);
dispatch({ type: 'LOAD_USER', user: payload.data.user });
} catch (error) {
console.log(error);
if (error.status === 422) {
setErrors(error.data.errors);
}
}
setLoading(false);
};
const handleLogout = () => {
dispatch({ type: 'LOGOUT' });
logout();
navigate('/');
};
return (
<div className="settings-page">
<div className="container page">
<div className="row">
<div className="col-md-6 offset-md-3 col-xs-12">
<h1 className="text-xs-center">Your Settings</h1>
{errors && <ListErrors errors={errors} />}
<form onSubmit={handleSubmit}>
<fieldset>
<div className="form-group">
<input
name="image"
className="form-control"
type="text"
placeholder="URL of profile picture"
value={form.image}
onChange={handleChange}
/>
</div>
<div className="form-group">
<input
name="username"
className="form-control form-control-lg"
type="text"
placeholder="Username"
value={form.username}
onChange={handleChange}
/>
</div>
<div className="form-group">
<textarea
name="bio"
className="form-control form-control-lg"
rows={8}
placeholder="Short bio about you"
value={form.bio}
onChange={handleChange}
/>
</div>
<div className="form-group">
<input
name="email"
className="form-control form-control-lg"
type="email"
placeholder="Email"
value={form.email}
onChange={handleChange}
/>
</div>
<div className="form-group">
<input
name="password"
className="form-control form-control-lg"
type="password"
placeholder="New Password"
value={form.password}
onChange={handleChange}
/>
</div>
<button
className="btn btn-lg btn-primary pull-xs-right"
type="submit"
disabled={loading}
>
Update Settings
</button>
</fieldset>
</form>
<hr />
<button className="btn btn-outline-danger" onClick={handleLogout}>
Or click here to logout.
</button>
</div>
</div>
</div>
</div>
);
}
| +---- common
| +---- ArticleAvatar.tsx
import React from 'react';
import { Link } from '@reach/router';
import { IArticle } from '../../types';
import { ALT_IMAGE_URL } from '../../utils';
type ArticleAvatarProps = {
article: IArticle;
};
export default function ArticleAvatar({
article: { author, createdAt },
}: ArticleAvatarProps) {
return (
<React.Fragment>
<Link to={`/${author.username}`}>
<img src={author.image || ALT_IMAGE_URL} alt={author.username} />
</Link>
<div className="info">
<Link className="author" to={`/${author.username}`}>
{author.username}
</Link>
<span className="date">{new Date(createdAt).toDateString()}</span>
</div>
</React.Fragment>
);
}
| +---- ArticleTags.tsx
import React from 'react';
export default function ArticleTags({ tagList }: { tagList: string[] }) {
return (
<ul className="tag-list">
{tagList.map((tag) => (
<li className="tag-default tag-pill tag-outline" key={tag}>
{tag}
</li>
))}
</ul>
);
}
| +---- FavoriteButton.tsx
import React from 'react';
import { IArticle } from '../../types';
import { ArticleAction } from '../../reducers/article';
import { ArticleListAction } from '../../reducers/articleList';
import { favoriteArticle, unfavoriteArticle } from '../../api/ArticlesAPI';
type FavoriteButtonProps = {
article: IArticle;
dispatch: React.Dispatch<ArticleAction & ArticleListAction>;
children: React.ReactNode;
};
export default function FavoriteButton({
article,
dispatch,
children,
}: FavoriteButtonProps) {
const [loading, setLoading] = React.useState(false);
const handleClick = async () => {
setLoading(true);
if (article.favorited) {
const payload = await unfavoriteArticle(article.slug);
dispatch({
type: 'ARTICLE_UNFAVORITED',
payload: payload.data,
});
} else {
const payload = await favoriteArticle(article.slug);
dispatch({
type: 'ARTICLE_FAVORITED',
payload: payload.data,
});
}
setLoading(false);
};
const classNames = ['btn', 'btn-sm'];
if (article.favorited) {
classNames.push('btn-primary');
} else {
classNames.push('btn-outline-primary');
}
return (
<button
className={classNames.join(' ')}
onClick={handleClick}
disabled={loading}
>
<i className="ion-heart" />
{children}
</button>
);
}
| +---- FollowUserButton.tsx
import React from 'react';
import { IProfile } from '../../types';
type FollowUserButtonProps = {
profile: IProfile;
onClick: () => void;
loading: boolean;
};
export default function FollowUserButton({
profile,
onClick,
loading,
}: FollowUserButtonProps) {
const classNames = ['btn', 'btn-sm', 'action-btn'];
let text = '';
if (profile.following) {
classNames.push('btn-secondary');
text += `Unfollow ${profile.username}`;
} else {
classNames.push('btn-outline-secondary');
text += `Follow ${profile.username}`;
}
return (
<button
className={classNames.join(' ')}
onClick={onClick}
disabled={loading}
>
<i className="ion-plus-round" />
{text}
</button>
);
}
| +---- ListErrors.tsx
import React from 'react';
import { IErrors } from '../../types';
export default function ListErrors({ errors }: { errors: IErrors }) {
return (
<ul className="error-messages">
{Object.entries(errors).map(([key, keyErrors], index) =>
keyErrors.map((error) => (
<li key={index}>
{key} {error}
</li>
)),
)}
</ul>
);
}
| +---- TabList.tsx
import React from 'react';
import useArticles from '../../context/articles';
import { ITab } from '../../reducers/articleList';
type TabsListProps = {
data: ITab[];
};
export default function TabList({ data }: TabsListProps) {
const {
state: { selectedTab },
dispatch,
} = useArticles();
const tabs = data.map((tab) => (
<Tab
key={tab.type}
isSelected={selectedTab.type === tab.type}
onClick={() => dispatch({ type: 'SET_TAB', tab })}
>
{tab.label}
</Tab>
));
if (selectedTab.type === 'TAG') {
tabs.push(
<Tab key={selectedTab.type} isSelected={true} onClick={() => {}}>
#{selectedTab.label}
</Tab>,
);
}
return <ul className="nav nav-pills outline-active">{tabs}</ul>;
}
type TabProps = {
isSelected: boolean;
onClick: () => void;
children: React.ReactNode;
};
function Tab({ isSelected, onClick, children }: TabProps) {
const classNames = ['nav-link'];
if (isSelected) {
classNames.push('active');
}
return (
<li className="nav-item">
<button className={classNames.join(' ')} onClick={onClick}>
{children}
</button>
</li>
);
}
+---- context
| +---- articles.tsx
import React from 'react';
import {
articlesReducer,
initialState,
ArticleListAction,
ArticleListState,
} from '../reducers/articleList';
type ArticleListContextProps = {
state: ArticleListState;
dispatch: React.Dispatch<ArticleListAction>;
};
const ArticlesContext = React.createContext<ArticleListContextProps>({
state: initialState,
dispatch: () => initialState,
});
export function ArticlesProvider(props: React.PropsWithChildren<{}>) {
const [state, dispatch] = React.useReducer(articlesReducer, initialState);
return <ArticlesContext.Provider value={{ state, dispatch }} {...props} />;
}
export default function useArticles() {
const context = React.useContext(ArticlesContext);
if (!context) {
throw new Error(`useArticles must be used within an ArticlesProvider`);
}
return context;
}
| +---- auth.tsx
import React from 'react';
import {
authReducer,
initialState,
AuthAction,
AuthState,
} from '../reducers/auth';
import { getLocalStorageValue } from '../utils';
import { TOKEN_KEY, setToken, isTokenValid } from '../api/APIUtils';
import { logout } from '../api/AuthAPI';
type AuthContextProps = {
state: AuthState;
dispatch: React.Dispatch<AuthAction>;
};
const AuthContext = React.createContext<AuthContextProps>({
state: initialState,
dispatch: () => initialState,
});
export function AuthProvider(props: React.PropsWithChildren<{}>) {
const [state, dispatch] = React.useReducer(authReducer, initialState);
React.useEffect(() => {
const token = getLocalStorageValue(TOKEN_KEY);
if (!token) return;
if (isTokenValid(token)) {
setToken(token);
dispatch({ type: 'LOGIN' });
} else {
dispatch({ type: 'LOGOUT' });
logout();
}
}, []);
return <AuthContext.Provider value={{ state, dispatch }} {...props} />;
}
export default function useAuth() {
return React.useContext(AuthContext);
}
+---- index.css
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
button.tag-pill.tag-default {
border: none;
}
button.tag-pill.tag-default:hover {
background-color: #687077;
}
+---- index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './components/App';
ReactDOM.render(<App />, document.getElementById('root'));
+---- react-app-env.d.ts
/// <reference types="react-scripts" />
+---- reducers
| +---- article.tsx
import { IArticle, IComment } from '../types';
export type ArticleAction =
| { type: 'FETCH_ARTICLE_BEGIN' }
| {
type: 'FETCH_ARTICLE_SUCCESS';
payload: { article: IArticle; comments: IComment[] };
}
| { type: 'FETCH_ARTICLE_ERROR'; error: string }
| { type: 'ARTICLE_FAVORITED'; payload: { article: IArticle } }
| { type: 'ARTICLE_UNFAVORITED'; payload: { article: IArticle } }
| { type: 'ADD_COMMENT'; payload: { comment: IComment } }
| { type: 'DELETE_COMMENT'; commentId: number }
| { type: 'FOLLOW_AUTHOR' }
| { type: 'UNFOLLOW_AUTHOR' };
export interface ArticleState {
article: IArticle | null;
comments: Array<IComment>;
loading: boolean;
error: string | null;
}
export const initialState: ArticleState = {
article: null,
comments: [],
loading: false,
error: null,
};
export function articleReducer(
state: ArticleState,
action: ArticleAction,
): ArticleState {
switch (action.type) {
case 'FETCH_ARTICLE_BEGIN':
return {
...state,
loading: true,
error: null,
};
case 'FETCH_ARTICLE_SUCCESS':
return {
...state,
loading: false,
article: action.payload.article,
comments: action.payload.comments,
};
case 'FETCH_ARTICLE_ERROR':
return {
...state,
loading: false,
error: action.error,
article: null,
};
case 'ARTICLE_FAVORITED':
case 'ARTICLE_UNFAVORITED':
return {
...state,
article: state.article && {
...state.article,
favorited: action.payload.article.favorited,
favoritesCount: action.payload.article.favoritesCount,
},
};
case 'ADD_COMMENT':
return {
...state,
comments: [action.payload.comment, ...state.comments],
};
case 'DELETE_COMMENT':
return {
...state,
comments: state.comments.filter(
(comment) => comment.id !== action.commentId,
),
};
case 'FOLLOW_AUTHOR':
case 'UNFOLLOW_AUTHOR':
return {
...state,
article: state.article && {
...state.article,
author: {
...state.article.author,
following: !state.article.author.following,
},
},
};
default:
return state;
}
}
| +---- articleList.tsx
import { IArticle } from '../types';
export type ArticleListAction =
| { type: 'FETCH_ARTICLES_BEGIN' }
| {
type: 'FETCH_ARTICLES_SUCCESS';
payload: { articles: Array<IArticle>; articlesCount: number };
}
| { type: 'FETCH_ARTICLES_ERROR'; error: string }
| { type: 'ARTICLE_FAVORITED'; payload: { article: IArticle } }
| { type: 'ARTICLE_UNFAVORITED'; payload: { article: IArticle } }
| { type: 'SET_TAB'; tab: ITab }
| { type: 'SET_PAGE'; page: number };
export type ITab =
| { type: 'ALL'; label: string }
| { type: 'FEED'; label: string }
| { type: 'TAG'; label: string }
| { type: 'AUTHORED'; label: string; username: string }
| { type: 'FAVORITES'; label: string; username: string };
export interface ArticleListState {
articles: Array<IArticle>;
loading: boolean;
error: string | null;
articlesCount: number;
selectedTab: ITab;
page: number;
}
export const initialState: ArticleListState = {
articles: [],
loading: false,
error: null,
articlesCount: 0,
selectedTab: { type: 'ALL', label: 'Global Feed' },
page: 0,
};
export function articlesReducer(
state: ArticleListState,
action: ArticleListAction,
): ArticleListState {
switch (action.type) {
case 'FETCH_ARTICLES_BEGIN':
return {
...state,
loading: true,
error: null,
};
case 'FETCH_ARTICLES_SUCCESS':
return {
...state,
loading: false,
articles: action.payload.articles,
articlesCount: action.payload.articlesCount,
};
case 'FETCH_ARTICLES_ERROR':
return {
...state,
loading: false,
error: action.error,
articles: [],
};
case 'ARTICLE_FAVORITED':
case 'ARTICLE_UNFAVORITED':
return {
...state,
articles: state.articles.map((article) =>
article.slug === action.payload.article.slug
? {
...article,
favorited: action.payload.article.favorited,
favoritesCount: action.payload.article.favoritesCount,
}
: article,
),
};
case 'SET_TAB':
return {
...state,
selectedTab: action.tab,
};
case 'SET_PAGE':
return {
...state,
page: action.page,
};
default:
return state;
}
}
| +---- auth.tsx
import { IUser } from '../types';
export type AuthAction =
| {
type: 'LOGIN';
}
| {
type: 'LOAD_USER';
user: IUser;
}
| { type: 'LOGOUT' };
export interface AuthState {
isAuthenticated: boolean;
user: IUser | null;
}
export const initialState: AuthState = {
isAuthenticated: false,
user: null,
};
export function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case 'LOGIN': {
return { ...state, isAuthenticated: true };
}
case 'LOAD_USER': {
return { ...state, user: action.user };
}
case 'LOGOUT': {
return { isAuthenticated: false, user: null };
}
default:
return state;
}
}
| +---- editor.tsx
import { IErrors } from '../types';
type EditorAction =
| { type: 'ADD_TAG'; tag: string }
| { type: 'REMOVE_TAG'; tag: string }
| { type: 'SET_TAGS'; tagList: string[] }
| {
type: 'SET_FORM';
form: IForm;
}
| {
type: 'UPDATE_FORM';
field: { key: string; value: string };
}
| { type: 'SET_ERRORS'; errors: IErrors };
interface IForm {
title: string;
description: string;
body: string;
tag: string;
}
interface EditorState {
tagList: string[];
form: IForm;
errors: IErrors;
loading: boolean;
}
export const initalState: EditorState = {
tagList: [],
form: {
title: '',
description: '',
body: '',
tag: '',
},
errors: {},
loading: false,
};
export function editorReducer(
state: EditorState,
action: EditorAction,
): EditorState {
switch (action.type) {
case 'ADD_TAG':
return {
...state,
tagList: [...state.tagList, action.tag],
};
case 'REMOVE_TAG':
return {
...state,
tagList: state.tagList.filter((tag) => tag !== action.tag),
};
case 'SET_TAGS':
return {
...state,
tagList: action.tagList,
};
case 'SET_FORM':
return {
...state,
form: action.form,
};
case 'UPDATE_FORM':
return {
...state,
form: {
...state.form,
[action.field.key]: action.field.value,
},
};
case 'SET_ERRORS':
return {
...state,
errors: action.errors,
};
default:
return state;
}
}
+---- types
| +---- index.ts
export interface IProfile {
username: string;
bio: string;
image: string;
following: boolean;
}
export interface IArticle {
slug: string;
title: string;
description: string;
body: string;
tagList: string[];
createdAt: Date;
updatedAt: Date;
favorited: boolean;
favoritesCount: number;
author: IProfile;
}
export interface IComment {
id: number;
createdAt: Date;
updatedAt: Date;
body: string;
author: IProfile;
}
export interface IUser {
email: string;
username: string;
bio: string;
image: string;
}
export interface IErrors {
[key: string]: string[];
}
+---- utils.ts
export const APP_NAME = 'conduit';
export const ALT_IMAGE_URL =
'https://static.productionready.io/images/smiley-cyrus.jpg';
export function getLocalStorageValue(key: string) {
const value = localStorage.getItem(key);
if (!value) return null;
try {
return JSON.parse(value);
} catch (error) {
return null;
}
}
export function setLocalStorage(key: string, value: string) {
localStorage.setItem(key, JSON.stringify(value));
}
tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
Back to Main Page