├── public
├── _redirects
├── favicon.ico
└── index.html
├── src
├── react-app-env.d.ts
├── constants
│ ├── units.constants.ts
│ ├── token.contant.ts
│ └── query.constant.ts
├── repositories
│ ├── profiles
│ │ ├── profileRepository.param.ts
│ │ └── profileRepository.ts
│ ├── tags
│ │ └── tagsRepository.ts
│ ├── users
│ │ ├── usersRepository.param.ts
│ │ └── usersRepository.ts
│ ├── articles
│ │ ├── articlesRepository.param.ts
│ │ └── articlesRepository.ts
│ └── apiClient.ts
├── lib
│ ├── utils
│ │ ├── scrollToTop.ts
│ │ ├── generateOneToNArray.ts
│ │ └── convertToDate.ts
│ ├── token.ts
│ ├── hooks
│ │ ├── useIsLoginContext.tsx
│ │ └── useInputs.tsx
│ └── routerMeta.ts
├── pages
│ ├── NotFoundPage.tsx
│ ├── SettingPage.tsx
│ ├── ProfilePage.tsx
│ ├── HomePage.tsx
│ ├── SignInPage.tsx
│ ├── SignUpPage.tsx
│ ├── ArticlePage.tsx
│ ├── NewArticlePage.tsx
│ └── EditArticlePage.tsx
├── components
│ ├── LoadingFallback.tsx
│ ├── common
│ │ ├── Layout.tsx
│ │ └── Footer.tsx
│ ├── header
│ │ ├── NavItem.tsx
│ │ ├── ProfileItem.tsx
│ │ └── Header.tsx
│ ├── article
│ │ ├── ButtonSelector.tsx
│ │ ├── ButtonsWIthAccess.tsx
│ │ ├── ButtonsWIthoutAccess.tsx
│ │ └── Comment.tsx
│ ├── HOC
│ │ └── ProtectedRoute.tsx
│ ├── ErrorFallback.tsx
│ ├── Profile.tsx
│ ├── feed
│ │ ├── FeedList.tsx
│ │ └── Feed.tsx
│ ├── profile
│ │ └── FollowButton.tsx
│ └── SettingForm.tsx
├── queries
│ ├── queryClient.ts
│ ├── user.query.ts
│ ├── profiles.query.ts
│ └── articles.query.ts
├── App.tsx
├── contexts
│ └── UserContextProvider.tsx
├── interfaces
│ └── main.d.ts
├── index.tsx
└── Router.tsx
├── .eslintignore
├── tsconfig.paths.json
├── babel.config.js
├── .prettierrc
├── craco.config.js
├── .gitignore
├── __tests__
└── UnitsTest.ts
├── tsconfig.json
├── .eslintrc.json
├── package.json
└── README.md
/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/constants/units.constants.ts:
--------------------------------------------------------------------------------
1 | export const UNIT_PER_PAGE = 10;
2 |
--------------------------------------------------------------------------------
/src/constants/token.contant.ts:
--------------------------------------------------------------------------------
1 | export const ACCESS_TOKEN_KEY = 'jwtToken';
2 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /craco.config.js
3 | /__tests__
4 | /babel.config.js
5 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jiheon788/react-query-realworld/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/src/repositories/profiles/profileRepository.param.ts:
--------------------------------------------------------------------------------
1 | export interface profileParam {
2 | username: string;
3 | }
4 |
--------------------------------------------------------------------------------
/src/lib/utils/scrollToTop.ts:
--------------------------------------------------------------------------------
1 | const scrollToTop = () => {
2 | window.scrollTo(0, 0);
3 | };
4 |
5 | export default scrollToTop;
6 |
--------------------------------------------------------------------------------
/tsconfig.paths.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./src",
4 | "paths": {
5 | "@/*": ["*"]
6 | }
7 | }
8 | }
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ['@babel/preset-env', {targets: {node: 'current'}}],
4 | '@babel/preset-typescript',
5 | ],
6 | };
--------------------------------------------------------------------------------
/src/lib/utils/generateOneToNArray.ts:
--------------------------------------------------------------------------------
1 | const generateOneToNArray = (length: number) => {
2 | return Array.from({ length }, (_, i) => i + 1);
3 | };
4 |
5 | export default generateOneToNArray;
6 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "semi": true,
4 | "useTabs": false,
5 | "tabWidth": 2,
6 | "trailingComma": "all",
7 | "printWidth": 120,
8 | "parser": "typescript",
9 | "endOfLine": "auto"
10 | }
--------------------------------------------------------------------------------
/src/repositories/tags/tagsRepository.ts:
--------------------------------------------------------------------------------
1 | import apiClient from '@/repositories/apiClient';
2 |
3 | export const getTags = async () => {
4 | return await apiClient({
5 | method: 'get',
6 | url: `/tags`,
7 | });
8 | };
9 |
--------------------------------------------------------------------------------
/src/pages/NotFoundPage.tsx:
--------------------------------------------------------------------------------
1 | const NotFoundPage = () => {
2 | return (
3 |
4 |
Not Found Page
5 |
6 | );
7 | };
8 |
9 | export default NotFoundPage;
10 |
--------------------------------------------------------------------------------
/src/components/LoadingFallback.tsx:
--------------------------------------------------------------------------------
1 | const LoadingFallback = () => {
2 | return (
3 |
4 |
Loading...
5 |
6 | );
7 | };
8 |
9 | export default LoadingFallback;
10 |
--------------------------------------------------------------------------------
/src/queries/queryClient.ts:
--------------------------------------------------------------------------------
1 | import { QueryClient } from '@tanstack/react-query';
2 | const queryClient = new QueryClient({
3 | defaultOptions: {
4 | queries: {
5 | suspense: true,
6 | retry: false,
7 | },
8 | },
9 | });
10 |
11 | export default queryClient;
12 |
--------------------------------------------------------------------------------
/src/constants/query.constant.ts:
--------------------------------------------------------------------------------
1 | export const QUERY_USER_KEY = 'user';
2 | export const QUERY_ARTICLES_KEY = 'articles';
3 | export const QUERY_ARTICLE_KEY = 'article';
4 | export const QUERY_COMMENTS_KEY = 'comments';
5 | export const QUERY_PROFILE_KEY = 'profile';
6 | export const QUERY_TAG_KEY = 'tags';
7 |
--------------------------------------------------------------------------------
/src/lib/token.ts:
--------------------------------------------------------------------------------
1 | class Token {
2 | public getToken(key: string) {
3 | return localStorage.getItem(key);
4 | }
5 |
6 | public setToken(key: string, token: string) {
7 | localStorage.setItem(key, token);
8 | }
9 |
10 | public removeToken(key: string) {
11 | localStorage.removeItem(key);
12 | }
13 | }
14 |
15 | export default new Token();
16 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Router from './Router';
3 | import UserContextProvider from '@/contexts/UserContextProvider';
4 |
5 | function App() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 | );
13 | }
14 |
15 | export default App;
16 |
--------------------------------------------------------------------------------
/src/components/common/Layout.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from 'react-router-dom';
2 | import Header from '@/components/header/Header';
3 | import Footer from '@/components/common/Footer';
4 |
5 | const Layout = () => {
6 | return (
7 | <>
8 |
9 |
10 |
11 | >
12 | );
13 | };
14 |
15 | export default Layout;
16 |
--------------------------------------------------------------------------------
/src/lib/hooks/useIsLoginContext.tsx:
--------------------------------------------------------------------------------
1 | import { ACCESS_TOKEN_KEY } from '@/constants/token.contant';
2 | import token from '@/lib/token';
3 | import { useState } from 'react';
4 |
5 | const useIsLoginContext = () => {
6 | const [isLogin, setIsLogin] = useState(!!token.getToken(ACCESS_TOKEN_KEY));
7 |
8 | return {
9 | isLogin,
10 | setIsLogin,
11 | };
12 | };
13 |
14 | export default useIsLoginContext;
15 |
--------------------------------------------------------------------------------
/src/repositories/users/usersRepository.param.ts:
--------------------------------------------------------------------------------
1 | export interface postLoginParam {
2 | email: string;
3 | password: string;
4 | }
5 |
6 | export interface postRegisterParam {
7 | username: string;
8 | email: string;
9 | password: string;
10 | }
11 |
12 | export interface putUserParam {
13 | email: string;
14 | username: string;
15 | bio: string;
16 | image: string;
17 | password: string;
18 | }
19 |
--------------------------------------------------------------------------------
/craco.config.js:
--------------------------------------------------------------------------------
1 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
2 | const path = require('path');
3 |
4 | const tsConfigPath = path.resolve(__dirname, './tsconfig.json');
5 |
6 | module.exports = {
7 | reactScriptsVersion: 'react-scripts',
8 | webpack: {
9 | alias: {
10 | '@': path.resolve(__dirname, 'src'),
11 | },
12 | plugins: [new TsconfigPathsPlugin({ configFile: tsConfigPath })],
13 | },
14 | };
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | .vscode
26 | .env
27 |
--------------------------------------------------------------------------------
/src/lib/utils/convertToDate.ts:
--------------------------------------------------------------------------------
1 | const months = [
2 | 'January',
3 | 'February',
4 | 'March',
5 | 'April',
6 | 'May',
7 | 'June',
8 | 'July',
9 | 'August',
10 | 'September',
11 | 'October',
12 | 'November',
13 | 'December',
14 | ];
15 |
16 | const convertToDate = (datetime: string) => {
17 | const [year, month, day] = datetime.split('T')[0].split('-').map(Number);
18 | return `${months[month - 1]} ${day}, ${year}`;
19 | };
20 |
21 | export default convertToDate;
22 |
--------------------------------------------------------------------------------
/src/queries/user.query.ts:
--------------------------------------------------------------------------------
1 | import { QUERY_USER_KEY } from '@/constants/query.constant';
2 | import { getUser, putUser } from '@/repositories/users/usersRepository';
3 | import { useMutation, useQuery } from '@tanstack/react-query';
4 |
5 | export const useGetUserQuery = () =>
6 | useQuery({
7 | queryKey: [QUERY_USER_KEY],
8 | queryFn: () => getUser().then((res) => res.data.user),
9 | staleTime: 20000,
10 | });
11 |
12 | export const usePutUserMutation = () => useMutation(putUser);
13 |
--------------------------------------------------------------------------------
/src/components/header/NavItem.tsx:
--------------------------------------------------------------------------------
1 | import { IRouterMeta } from '@/lib/routerMeta';
2 | import { NavLink } from 'react-router-dom';
3 |
4 | interface INavItemProps {
5 | menu: IRouterMeta;
6 | }
7 |
8 | const NavItem = ({ menu }: INavItemProps) => {
9 | return (
10 |
11 | `nav-link ${isActive ? 'active' : ''}`}>
12 | {menu.name}
13 |
14 |
15 | );
16 | };
17 |
18 | export default NavItem;
19 |
--------------------------------------------------------------------------------
/src/contexts/UserContextProvider.tsx:
--------------------------------------------------------------------------------
1 | import useIsLoginContext from '@/lib/hooks/useIsLoginContext';
2 | import { createContext } from 'react';
3 |
4 | interface IUserContextProviderProps {
5 | children: JSX.Element[] | JSX.Element;
6 | }
7 |
8 | export const UserContext = createContext({} as ReturnType);
9 |
10 | const UserContextProvider = ({ children }: IUserContextProviderProps) => {
11 | return {children};
12 | };
13 |
14 | export default UserContextProvider;
15 |
--------------------------------------------------------------------------------
/src/components/common/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 |
3 | const Footer = () => {
4 | return (
5 |
17 | );
18 | };
19 |
20 | export default Footer;
21 |
--------------------------------------------------------------------------------
/src/components/header/ProfileItem.tsx:
--------------------------------------------------------------------------------
1 | import { NavLink } from 'react-router-dom';
2 | import { useGetUserQuery } from '@/queries/user.query';
3 |
4 | const ProfileItem = () => {
5 | const { data } = useGetUserQuery();
6 |
7 | return (
8 |
9 | `nav-link ${isActive ? 'active' : ''}`}
12 | state={data.username}
13 | >
14 |
15 | {data.username}
16 |
17 |
18 | );
19 | };
20 |
21 | export default ProfileItem;
22 |
--------------------------------------------------------------------------------
/src/interfaces/main.d.ts:
--------------------------------------------------------------------------------
1 | export interface IArticle {
2 | slug: string;
3 | title: string;
4 | description: string;
5 | body: string;
6 | tagList: string[];
7 | createdAt: string;
8 | updatedAt: string;
9 | favorited: true;
10 | favoritesCount: number;
11 | author: {
12 | username: string;
13 | bio: string;
14 | image: string;
15 | following: true;
16 | };
17 | }
18 |
19 | export interface IComment {
20 | id: number;
21 | createdAt: string;
22 | updatedAt: string;
23 | body: string;
24 | author: {
25 | username: string;
26 | bio: string;
27 | image: string;
28 | following: boolean;
29 | };
30 | }
31 |
--------------------------------------------------------------------------------
/__tests__/UnitsTest.ts:
--------------------------------------------------------------------------------
1 | import generateOneToNArray from '../src/lib/utils/generateOneToNArray';
2 | import convertToDate from '../src/lib/utils/convertToDate';
3 |
4 | describe('Utils Func Test', () => {
5 | test('Func generateOneToNAray(length) must return an array of one to N with the length of the input value.', () => {
6 | const arr: number[] = generateOneToNArray(3);
7 |
8 | expect(arr).toEqual([1, 2, 3]);
9 | });
10 |
11 | test('Func ConvertToDate(timestamp) changes timestamp to the form of Month Day, Year.', () => {
12 | const convertedDate = convertToDate('2023-02-15T16:38:09.644Z');
13 |
14 | expect(convertedDate).toEqual('February 15, 2023');
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.paths.json",
3 | "compilerOptions": {
4 | "target": "es5",
5 | "lib": [
6 | "dom",
7 | "dom.iterable",
8 | "esnext"
9 | ],
10 | "allowJs": true,
11 | "skipLibCheck": true,
12 | "esModuleInterop": true,
13 | "allowSyntheticDefaultImports": true,
14 | "strict": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "noFallthroughCasesInSwitch": true,
17 | "module": "esnext",
18 | "moduleResolution": "node",
19 | "resolveJsonModule": true,
20 | "isolatedModules": true,
21 | "noEmit": true,
22 | "jsx": "react-jsx"
23 | },
24 | "include": [
25 | "src"
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------
/src/repositories/profiles/profileRepository.ts:
--------------------------------------------------------------------------------
1 | import apiClient from '@/repositories/apiClient';
2 | import { profileParam } from './profileRepository.param';
3 |
4 | export const getProfile = async ({ username }: profileParam) => {
5 | return await apiClient({
6 | method: 'get',
7 | url: `/profiles/${username}`,
8 | });
9 | };
10 |
11 | export const followUser = async ({ username }: profileParam) => {
12 | return await apiClient({
13 | method: 'post',
14 | url: `/profiles/${username}/follow`,
15 | });
16 | };
17 |
18 | export const unfollowUser = async ({ username }: profileParam) => {
19 | return await apiClient({
20 | method: 'delete',
21 | url: `/profiles/${username}/follow`,
22 | });
23 | };
24 |
--------------------------------------------------------------------------------
/src/lib/hooks/useInputs.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | type DefaultType = {
4 | [key: string]: any;
5 | };
6 |
7 | type ReturnTypes = [
8 | any,
9 | (event: React.ChangeEvent | React.ChangeEvent) => void,
10 | (value: any) => void,
11 | ];
12 |
13 | const useInputs = (initialValue: DefaultType): ReturnTypes => {
14 | const [values, setValues] = useState(initialValue);
15 |
16 | const onChange = (event: React.ChangeEvent | React.ChangeEvent) => {
17 | setValues({
18 | ...values,
19 | [event.target.name]: event.target.value,
20 | });
21 | };
22 |
23 | return [values, onChange, setValues];
24 | };
25 |
26 | export default useInputs;
27 |
--------------------------------------------------------------------------------
/src/components/article/ButtonSelector.tsx:
--------------------------------------------------------------------------------
1 | import { useGetUserQuery } from '@/queries/user.query';
2 | import ButtonsWIthAccess from './ButtonsWIthAccess';
3 | import ButtonsWIthoutAccess from './ButtonsWIthoutAccess';
4 | import { IArticle } from '@/interfaces/main';
5 |
6 | interface IButtonSelectorProps {
7 | articleInfo: IArticle;
8 | }
9 |
10 | const ButtonSelector = ({ articleInfo }: IButtonSelectorProps) => {
11 | const { data } = useGetUserQuery();
12 |
13 | return (
14 | <>
15 | {data.username === articleInfo.author.username ? (
16 |
17 | ) : (
18 |
19 | )}
20 | >
21 | );
22 | };
23 |
24 | export default ButtonSelector;
25 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import App from './App';
4 | import { BrowserRouter } from 'react-router-dom';
5 | import { QueryClientProvider } from '@tanstack/react-query';
6 | import queryClient from '@/queries/queryClient';
7 |
8 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
9 | root.render(
10 |
11 |
12 |
13 |
14 |
15 |
16 | ,
17 | );
18 |
19 | // If you want to start measuring performance in your app, pass a function
20 | // to log results (for example: reportWebVitals(console.log))
21 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
22 |
--------------------------------------------------------------------------------
/src/components/HOC/ProtectedRoute.tsx:
--------------------------------------------------------------------------------
1 | import routerMeta from '@/lib/routerMeta';
2 | import { Navigate } from 'react-router-dom';
3 | import { UserContext } from '@/contexts/UserContextProvider';
4 | import { useContext } from 'react';
5 |
6 | interface IProtectedRoute {
7 | children: JSX.Element;
8 | path: string;
9 | }
10 |
11 | const ProtectedRoute = ({ children, path }: IProtectedRoute) => {
12 | const { isLogin } = useContext(UserContext);
13 |
14 | if (
15 | !isLogin &&
16 | (path === routerMeta.NewArticlePage.path ||
17 | path === routerMeta.EditArticlePage.path ||
18 | path === routerMeta.SettingPage.path)
19 | ) {
20 | return ;
21 | }
22 |
23 | if (isLogin && (path === routerMeta.SignUpPage.path || path === routerMeta.SignInPage.path)) {
24 | return ;
25 | }
26 |
27 | return children;
28 | };
29 | export default ProtectedRoute;
30 |
--------------------------------------------------------------------------------
/src/components/ErrorFallback.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from 'react-router-dom';
2 |
3 | interface IErrorFallbackProps {
4 | resetErrorBoundary: (...args: unknown[]) => void;
5 | }
6 |
7 | const ErrorFallback = ({ resetErrorBoundary }: IErrorFallbackProps) => {
8 | const navigate = useNavigate();
9 |
10 | return (
11 |
12 |
There was an error!
13 |
14 |
17 |
27 |
28 |
29 | );
30 | };
31 |
32 | export default ErrorFallback;
33 |
--------------------------------------------------------------------------------
/src/repositories/articles/articlesRepository.param.ts:
--------------------------------------------------------------------------------
1 | export interface getArticlesParam {
2 | isGlobal?: boolean;
3 | page: number;
4 | selectedTag?: string;
5 | username?: string;
6 | isFavorited?: boolean;
7 | }
8 |
9 | export interface getArticleParam {
10 | slug: string;
11 | }
12 |
13 | export interface createArticleParam {
14 | title: string;
15 | description: string;
16 | body: string;
17 | tagList: string[];
18 | }
19 |
20 | export interface updateArticleParam {
21 | slug: string;
22 | title: string;
23 | description: string;
24 | body: string;
25 | tagList: string[];
26 | }
27 |
28 | export interface deleteArticleParam {
29 | slug: string;
30 | }
31 |
32 | export interface getCommentsParam {
33 | slug: string;
34 | }
35 |
36 | export interface createCommentParam {
37 | slug: string;
38 | body: string;
39 | }
40 |
41 | export interface deleteCommentParam {
42 | slug: string;
43 | id: number;
44 | }
45 |
46 | export interface favoriteParam {
47 | slug: string;
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/Profile.tsx:
--------------------------------------------------------------------------------
1 | import { UserContext } from '@/contexts/UserContextProvider';
2 | import { useContext } from 'react';
3 | import FollowButton from './profile/FollowButton';
4 |
5 | interface IProfileProps {
6 | profile: {
7 | image: string;
8 | username: string;
9 | bio: string;
10 | following: boolean;
11 | };
12 | }
13 |
14 | const Profile = ({ profile }: IProfileProps) => {
15 | const { isLogin } = useContext(UserContext);
16 | return (
17 |
18 |
19 |
20 |
21 |

22 |
{profile.username}
23 |
{profile.bio}
24 | {isLogin ?
: <>>}
25 |
26 |
27 |
28 |
29 | );
30 | };
31 |
32 | export default Profile;
33 |
--------------------------------------------------------------------------------
/src/queries/profiles.query.ts:
--------------------------------------------------------------------------------
1 | import { QUERY_ARTICLES_KEY, QUERY_PROFILE_KEY } from '@/constants/query.constant';
2 | import { getArticles } from '@/repositories/articles/articlesRepository';
3 | import { followUser, getProfile, unfollowUser } from '@/repositories/profiles/profileRepository';
4 | import { useMutation, useQueries } from '@tanstack/react-query';
5 |
6 | export const useGetProfileQueries = (username: string, page: number, isFavorited: boolean) => {
7 | return useQueries({
8 | queries: [
9 | {
10 | queryKey: [QUERY_PROFILE_KEY, username],
11 | queryFn: () => getProfile({ username }).then((res) => res.data.profile),
12 | staleTime: 20000,
13 | },
14 | {
15 | queryKey: [QUERY_ARTICLES_KEY, username, page, isFavorited],
16 | queryFn: () => getArticles({ username, page, isFavorited }).then((res) => res.data),
17 | staleTime: 20000,
18 | },
19 | ],
20 | });
21 | };
22 |
23 | export const useFollowUserMutation = () => useMutation(followUser);
24 |
25 | export const useUnFollowUserMutation = () => useMutation(unfollowUser);
26 |
--------------------------------------------------------------------------------
/src/repositories/users/usersRepository.ts:
--------------------------------------------------------------------------------
1 | import apiClient from '@/repositories/apiClient';
2 | import { postLoginParam, postRegisterParam, putUserParam } from './usersRepository.param';
3 |
4 | export const postLogin = async ({ email, password }: postLoginParam) => {
5 | return await apiClient({
6 | method: 'post',
7 | url: `/users/login`,
8 | data: {
9 | user: {
10 | email,
11 | password,
12 | },
13 | },
14 | });
15 | };
16 |
17 | export const postRegister = async ({ username, email, password }: postRegisterParam) => {
18 | return await apiClient({
19 | method: 'post',
20 | url: `/users`,
21 | data: {
22 | user: {
23 | username,
24 | email,
25 | password,
26 | },
27 | },
28 | });
29 | };
30 |
31 | export const getUser = async () => {
32 | return await apiClient({
33 | method: 'get',
34 | url: `/user`,
35 | });
36 | };
37 |
38 | export const putUser = async (data: { user: putUserParam }) => {
39 | return await apiClient({
40 | method: 'put',
41 | url: '/user',
42 | data,
43 | });
44 | };
45 |
--------------------------------------------------------------------------------
/src/components/header/Header.tsx:
--------------------------------------------------------------------------------
1 | import routerMeta, { IRouterMeta } from '@/lib/routerMeta';
2 | import { Link } from 'react-router-dom';
3 | import ProfileItem from './ProfileItem';
4 | import { useContext } from 'react';
5 | import { UserContext } from '@/contexts/UserContextProvider';
6 | import NavItem from './NavItem';
7 |
8 | const Header = () => {
9 | const { isLogin } = useContext(UserContext);
10 |
11 | return (
12 |
34 | );
35 | };
36 |
37 | export default Header;
38 |
--------------------------------------------------------------------------------
/src/pages/SettingPage.tsx:
--------------------------------------------------------------------------------
1 | import SettingForm from '@/components/SettingForm';
2 | import { ACCESS_TOKEN_KEY } from '@/constants/token.contant';
3 | import token from '@/lib/token';
4 | import { useGetUserQuery } from '@/queries/user.query';
5 | import { useNavigate } from 'react-router-dom';
6 | import { useContext } from 'react';
7 | import { UserContext } from '@/contexts/UserContextProvider';
8 |
9 | const SettingPage = () => {
10 | const navigate = useNavigate();
11 | const { setIsLogin } = useContext(UserContext);
12 | const onLogout = () => {
13 | token.removeToken(ACCESS_TOKEN_KEY);
14 | setIsLogin(false);
15 | navigate('/');
16 | };
17 |
18 | const { data } = useGetUserQuery();
19 |
20 | return (
21 |
22 |
23 |
24 |
25 |
Your Settings
26 |
27 |
28 |
31 |
32 |
33 |
34 |
35 | );
36 | };
37 |
38 | export default SettingPage;
39 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Conduit
6 |
7 |
12 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/lib/routerMeta.ts:
--------------------------------------------------------------------------------
1 | export interface IRouterMeta {
2 | name?: string;
3 | path: string;
4 | isShow: boolean;
5 | isCommon?: boolean;
6 | isAuth?: boolean;
7 | icon?: string;
8 | }
9 |
10 | export type RouterMetaType = {
11 | [key: string]: IRouterMeta;
12 | };
13 |
14 | const routerMeta: RouterMetaType = {
15 | HomePage: {
16 | name: 'Home',
17 | path: '/',
18 | isShow: true,
19 | isCommon: true,
20 | },
21 | NewArticlePage: {
22 | name: 'New Article',
23 | path: '/editor',
24 | isShow: true,
25 | isAuth: true,
26 | icon: 'ion-compose',
27 | },
28 | EditArticlePage: {
29 | name: 'Edit Article',
30 | path: '/editor/:slug',
31 | isShow: false,
32 | },
33 | SettingPage: {
34 | name: 'Setting',
35 | path: '/settings',
36 | isShow: true,
37 | isAuth: true,
38 | icon: 'ion-gear-a',
39 | },
40 | ArticlePage: {
41 | name: 'Article',
42 | path: '/article/:slug',
43 | isShow: false,
44 | },
45 | ProfilePage: {
46 | name: 'Profile',
47 | path: '/profile/:username/*',
48 | isShow: false,
49 | },
50 | SignInPage: {
51 | name: 'Sign in',
52 | path: '/login',
53 | isShow: true,
54 | isAuth: false,
55 | },
56 | SignUpPage: {
57 | name: 'Sign up',
58 | path: '/register',
59 | isShow: true,
60 | isAuth: false,
61 | },
62 | NotFoundPage: {
63 | path: '/*',
64 | isShow: false,
65 | },
66 | };
67 |
68 | export default routerMeta;
69 |
--------------------------------------------------------------------------------
/src/components/article/ButtonsWIthAccess.tsx:
--------------------------------------------------------------------------------
1 | import { QUERY_ARTICLES_KEY } from '@/constants/query.constant';
2 | import queryClient from '@/queries/queryClient';
3 | import { useDeleteArticleMutation } from '@/queries/articles.query';
4 | import { useNavigate } from 'react-router-dom';
5 | import { IArticle } from '@/interfaces/main';
6 |
7 | interface IButtonsWIthAccessProps {
8 | articleInfo: IArticle;
9 | }
10 |
11 | const ButtonsWIthAccess = ({ articleInfo }: IButtonsWIthAccessProps) => {
12 | const navigate = useNavigate();
13 | const deleteArticleMutation = useDeleteArticleMutation();
14 |
15 | const onDelete = (slug: string) => {
16 | deleteArticleMutation.mutate(
17 | { slug },
18 | {
19 | onSuccess: (_) => {
20 | queryClient.invalidateQueries({ queryKey: [QUERY_ARTICLES_KEY] });
21 | navigate(`/`);
22 | },
23 | },
24 | );
25 | };
26 |
27 | return (
28 | <>
29 |
36 |
37 |
40 | >
41 | );
42 | };
43 |
44 | export default ButtonsWIthAccess;
45 |
--------------------------------------------------------------------------------
/src/components/feed/FeedList.tsx:
--------------------------------------------------------------------------------
1 | import { UNIT_PER_PAGE } from '@/constants/units.constants';
2 | import generateOneToNArray from '@/lib/utils/generateOneToNArray';
3 | import scrollToTop from '@/lib/utils/scrollToTop';
4 | import Feed from './Feed';
5 | import { IArticle } from '@/interfaces/main';
6 |
7 | interface IFeedListProps {
8 | articlesInfo: {
9 | articles: IArticle[];
10 | articlesCount: number;
11 | };
12 | page: number;
13 | setPage: (page: number) => void;
14 | }
15 |
16 | const FeedList = ({ articlesInfo, page, setPage }: IFeedListProps) => {
17 | const { articles, articlesCount } = articlesInfo;
18 |
19 | return (
20 | <>
21 | {articles.length !== 0 ? (
22 | <>
23 | {articles.map((article) => (
24 |
25 | ))}
26 | >
27 | ) : (
28 | No articles are here... yet.
29 | )}
30 |
48 | >
49 | );
50 | };
51 |
52 | export default FeedList;
53 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es6": true,
5 | "node": true
6 | },
7 | "extends": [
8 | "plugin:import/recommended",
9 | "plugin:import/errors",
10 | "plugin:import/warnings",
11 | "eslint:recommended",
12 | "plugin:@typescript-eslint/eslint-recommended",
13 | "plugin:react/recommended",
14 | "plugin:jsx-a11y/recommended",
15 | "plugin:@typescript-eslint/recommended",
16 | "plugin:react-hooks/recommended",
17 | "plugin:import/typescript",
18 | "plugin:prettier/recommended"
19 | ],
20 | "settings": {
21 | "import/resolver": {
22 | "node": {
23 | "extensions": [".js", ".jsx", ".ts", ".tsx"],
24 | "moduleDirectory": ["node_modules", "@types"]
25 | }
26 | }
27 | },
28 | "parser": "@typescript-eslint/parser",
29 | "parserOptions": {
30 | "project": "./tsconfig.json",
31 | "ecmaFeatures": {
32 | "jsx": true
33 | },
34 | "ecmaVersion": 2018,
35 | "sourceType": "module"
36 | },
37 | "plugins": ["react", "import", "@typescript-eslint", "react-hooks", "prettier"],
38 | "rules": {
39 | "react-hooks/rules-of-hooks": "error",
40 | "react-hooks/exhaustive-deps": "warn",
41 | "no-unused-vars": "off",
42 | "no-empty": "off",
43 | "linebreak-style": "off",
44 | "arrow-body-style": "off",
45 | "react/react-in-jsx-scope": "off",
46 | "import/no-unresolved": "off",
47 | "prettier/prettier": ["error", { "endOfLine": "auto" }],
48 | "react/no-children-prop": "off",
49 | "@typescript-eslint/no-empty-function": "off",
50 | "react/prop-types": "off",
51 | "react-hooks/rules-of-hooks": "warn"
52 | }
53 | }
--------------------------------------------------------------------------------
/src/repositories/apiClient.ts:
--------------------------------------------------------------------------------
1 | import { ACCESS_TOKEN_KEY } from '@/constants/token.contant';
2 | import token from '@/lib/token';
3 | import axios, { AxiosResponse, InternalAxiosRequestConfig, AxiosError } from 'axios';
4 |
5 | const host = 'https://api.realworld.io/api';
6 |
7 | const apiClient = axios.create({
8 | baseURL: host,
9 | });
10 |
11 | const logOnDev = (message: string, log?: AxiosResponse | InternalAxiosRequestConfig | AxiosError) => {
12 | if (process.env.NODE_ENV === 'development') {
13 | console.log(message, log);
14 | }
15 | };
16 |
17 | apiClient.interceptors.request.use((request) => {
18 | const jwtToken: string | null = token.getToken(ACCESS_TOKEN_KEY);
19 | const { method, url } = request;
20 |
21 | if (jwtToken) {
22 | request.headers['Authorization'] = `Token ${jwtToken}`;
23 | }
24 |
25 | logOnDev(`🚀 [${method?.toUpperCase()}] ${url} | Request`, request);
26 |
27 | return request;
28 | });
29 |
30 | apiClient.interceptors.response.use(
31 | (response) => {
32 | const { method, url } = response.config;
33 | const { status } = response;
34 |
35 | logOnDev(`✨ [${method?.toUpperCase()}] ${url} | Response ${status}`, response);
36 |
37 | return response;
38 | },
39 | (error) => {
40 | const { message } = error;
41 | const { status, data } = error.response;
42 | const { method, url } = error.config;
43 |
44 | if (status === 429) {
45 | token.removeToken('ACCESS_TOKEN_KEY');
46 | window.location.reload();
47 | }
48 |
49 | logOnDev(`🚨 [${method?.toUpperCase()}] ${url} | Error ${status} ${data?.message || ''} | ${message}`, error);
50 |
51 | return Promise.reject(error);
52 | },
53 | );
54 |
55 | export default apiClient;
56 |
--------------------------------------------------------------------------------
/src/Router.tsx:
--------------------------------------------------------------------------------
1 | import { lazy, Suspense } from 'react';
2 | import { Routes, Route } from 'react-router-dom';
3 | import routerMeta, { IRouterMeta } from '@/lib/routerMeta';
4 | import LoadingFallback from '@/components/LoadingFallback';
5 | import ProtectedRoute from '@/components/HOC/ProtectedRoute';
6 | import { useQueryErrorResetBoundary } from '@tanstack/react-query';
7 | import { ErrorBoundary } from 'react-error-boundary';
8 | import ErrorFallback from '@/components/ErrorFallback';
9 | import Layout from '@/components/common/Layout';
10 |
11 | const lazyImport = (pageName: string) => lazy(() => import(`@/pages/${pageName}`));
12 |
13 | const assignRouter = Object.keys(routerMeta).map((componentKey: string) => {
14 | const props: IRouterMeta = routerMeta[componentKey];
15 |
16 | return {
17 | Component: lazyImport(componentKey),
18 | props,
19 | };
20 | });
21 |
22 | const Router = () => {
23 | const { reset } = useQueryErrorResetBoundary();
24 |
25 | return (
26 |
27 | }>
28 | {assignRouter.map(({ Component, props }) => (
29 |
34 | }>
35 | (
38 |
39 | )}
40 | >
41 |
42 |
43 |
44 |
45 | }
46 | />
47 | ))}
48 |
49 |
50 | );
51 | };
52 |
53 | export default Router;
54 |
--------------------------------------------------------------------------------
/src/components/profile/FollowButton.tsx:
--------------------------------------------------------------------------------
1 | import { QUERY_PROFILE_KEY } from '@/constants/query.constant';
2 | import queryClient from '@/queries/queryClient';
3 | import routerMeta from '@/lib/routerMeta';
4 | import { useFollowUserMutation, useUnFollowUserMutation } from '@/queries/profiles.query';
5 | import { useGetUserQuery } from '@/queries/user.query';
6 | import { Link } from 'react-router-dom';
7 | interface IFollowButton {
8 | profileName: string;
9 | isFollow: boolean;
10 | }
11 | const FollowButton = ({ profileName, isFollow }: IFollowButton) => {
12 | const { data } = useGetUserQuery();
13 | const followUserMutation = useFollowUserMutation();
14 | const unfollowUserMutation = useUnFollowUserMutation();
15 |
16 | const onToggleFollow = () => {
17 | const username = profileName;
18 | if (isFollow) {
19 | unfollowUserMutation.mutate(
20 | { username },
21 | {
22 | onSuccess: () => {
23 | queryClient.invalidateQueries({ queryKey: [QUERY_PROFILE_KEY] });
24 | },
25 | },
26 | );
27 | return;
28 | }
29 |
30 | if (!isFollow) {
31 | followUserMutation.mutate(
32 | { username },
33 | {
34 | onSuccess: () => {
35 | queryClient.invalidateQueries({ queryKey: [QUERY_PROFILE_KEY] });
36 | },
37 | },
38 | );
39 | return;
40 | }
41 | };
42 |
43 | return (
44 | <>
45 | {data.username === profileName ? (
46 |
47 | Edit Profile Settings
48 |
49 | ) : (
50 |
58 | )}
59 | >
60 | );
61 | };
62 |
63 | export default FollowButton;
64 |
--------------------------------------------------------------------------------
/src/queries/articles.query.ts:
--------------------------------------------------------------------------------
1 | import { QUERY_ARTICLES_KEY, QUERY_ARTICLE_KEY, QUERY_COMMENTS_KEY, QUERY_TAG_KEY } from '@/constants/query.constant';
2 | import {
3 | getArticle,
4 | getArticles,
5 | createArticle,
6 | updateArticle,
7 | deleteArticle,
8 | getComments,
9 | createComment,
10 | deleteComment,
11 | favoriteArticle,
12 | unfavoriteArticle,
13 | } from '@/repositories/articles/articlesRepository';
14 | import { getTags } from '@/repositories/tags/tagsRepository';
15 | import { useMutation, useQueries } from '@tanstack/react-query';
16 |
17 | export const useGetArticlesQueries = (isGlobal: boolean, page: number, selectedTag: string) => {
18 | return useQueries({
19 | queries: [
20 | {
21 | queryKey: [QUERY_ARTICLES_KEY, isGlobal, selectedTag, page],
22 | queryFn: () => getArticles({ isGlobal, selectedTag, page }).then((res) => res.data),
23 | staleTime: 20000,
24 | },
25 | {
26 | queryKey: [QUERY_TAG_KEY],
27 | queryFn: () => getTags().then((res) => res.data.tags),
28 | staleTime: 20000,
29 | },
30 | ],
31 | });
32 | };
33 |
34 | export const useGetArticleQueries = (slug: string) => {
35 | return useQueries({
36 | queries: [
37 | {
38 | queryKey: [QUERY_ARTICLE_KEY, slug],
39 | queryFn: () => getArticle({ slug }).then((res) => res.data.article),
40 | staleTime: 20000,
41 | },
42 | {
43 | queryKey: [QUERY_COMMENTS_KEY, slug],
44 | queryFn: () => getComments({ slug }).then((res) => res.data.comments),
45 | staleTime: 20000,
46 | },
47 | ],
48 | });
49 | };
50 |
51 | export const useCreateArticleMutation = () => useMutation(createArticle);
52 |
53 | export const useUpdateArticleMutation = () => useMutation(updateArticle);
54 |
55 | export const useDeleteArticleMutation = () => useMutation(deleteArticle);
56 |
57 | export const useCreateCommentMutation = () => useMutation(createComment);
58 |
59 | export const useDeleteCommentMutation = () => useMutation(deleteComment);
60 |
61 | export const useFavoriteArticleMutation = () => useMutation(favoriteArticle);
62 |
63 | export const useUnfavoriteArticleMutation = () => useMutation(unfavoriteArticle);
64 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-query-realworld",
3 | "version": "0.1.0",
4 | "private": true,
5 | "author": {
6 | "name": "JiHeon Park",
7 | "email": "jiheon788@ajou.ac.kr"
8 | },
9 | "dependencies": {
10 | "@craco/craco": "^7.0.0",
11 | "@tanstack/react-query": "4.24.4",
12 | "@testing-library/jest-dom": "^5.16.5",
13 | "@testing-library/react": "^13.4.0",
14 | "@testing-library/user-event": "^13.5.0",
15 | "@types/jest": "^27.5.2",
16 | "@types/node": "^16.18.11",
17 | "@types/react": "^18.0.27",
18 | "@types/react-dom": "^18.0.10",
19 | "axios": "^1.2.4",
20 | "react": "^18.2.0",
21 | "react-dom": "^18.2.0",
22 | "react-error-boundary": "^3.1.4",
23 | "react-markdown": "^8.0.5",
24 | "react-router-dom": "^6.7.0",
25 | "react-scripts": "5.0.1",
26 | "remark-gfm": "^3.0.1",
27 | "typescript": "^4.9.4",
28 | "web-vitals": "^2.1.4"
29 | },
30 | "scripts": {
31 | "start": "craco start",
32 | "build": "craco build",
33 | "test": "jest",
34 | "eject": "craco eject"
35 | },
36 | "eslintConfig": {
37 | "extends": [
38 | "react-app",
39 | "react-app/jest",
40 | "prettier"
41 | ]
42 | },
43 | "browserslist": {
44 | "production": [
45 | ">0.2%",
46 | "not dead",
47 | "not op_mini all"
48 | ],
49 | "development": [
50 | "last 1 chrome version",
51 | "last 1 firefox version",
52 | "last 1 safari version"
53 | ]
54 | },
55 | "devDependencies": {
56 | "@babel/cli": "^7.20.7",
57 | "@babel/core": "^7.20.12",
58 | "@babel/preset-typescript": "^7.18.6",
59 | "@typescript-eslint/eslint-plugin": "^5.49.0",
60 | "@typescript-eslint/parser": "^5.49.0",
61 | "eslint": "^8.32.0",
62 | "eslint-config-airbnb": "^19.0.4",
63 | "eslint-config-prettier": "^8.6.0",
64 | "eslint-import-resolver-typescript": "^3.5.3",
65 | "eslint-plugin-import": "^2.27.5",
66 | "eslint-plugin-jsx-a11y": "^6.7.1",
67 | "eslint-plugin-prettier": "^4.2.1",
68 | "eslint-plugin-react": "^7.32.1",
69 | "eslint-plugin-react-hooks": "^4.6.0",
70 | "jest": "^27.5.1",
71 | "prettier": "^2.8.3",
72 | "tsconfig-paths-webpack-plugin": "^4.0.0"
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/pages/ProfilePage.tsx:
--------------------------------------------------------------------------------
1 | import { useGetProfileQueries } from '@/queries/profiles.query';
2 | import { NavLink, Route, Routes, useLocation } from 'react-router-dom';
3 | import { useState } from 'react';
4 | import Profile from '@/components/Profile';
5 | import FeedList from '@/components/feed/FeedList';
6 |
7 | const ProfilePage = () => {
8 | const { state } = useLocation();
9 | const [page, setPage] = useState(1);
10 | const [isFavorited, setIsFavorited] = useState(false);
11 | const [profileInfo, articlesInfo] = useGetProfileQueries(state, page, isFavorited);
12 |
13 | console.log(state);
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | -
24 | `nav-link ${isActive ? 'active' : ''}`}
26 | end
27 | to={`/profile/${state}`}
28 | onClick={() => setIsFavorited(false)}
29 | state={state}
30 | >
31 | My Articles
32 |
33 |
34 | -
35 | `nav-link ${isActive ? 'active' : ''}`}
37 | end
38 | to={`/profile/${state}/favorites`}
39 | onClick={() => setIsFavorited(true)}
40 | state={state}
41 | >
42 | Favorited Articles
43 |
44 |
45 |
46 |
47 |
48 |
49 | } />
50 | }
53 | />
54 |
55 |
56 |
57 |
58 |
59 | );
60 | };
61 |
62 | export default ProfilePage;
63 |
--------------------------------------------------------------------------------
/src/pages/HomePage.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useContext } from 'react';
2 | import { Link } from 'react-router-dom';
3 | import FeedList from '@/components/feed/FeedList';
4 | import { useGetArticlesQueries } from '@/queries/articles.query';
5 | import { UserContext } from '@/contexts/UserContextProvider';
6 |
7 | const HomePage = () => {
8 | const { isLogin } = useContext(UserContext);
9 | const [page, setPage] = useState(1);
10 | const [isGlobal, setIsGlobal] = useState(true);
11 | const [selectedTag, setSelectedTag] = useState('');
12 | const [articlesInfo, tagsInfo] = useGetArticlesQueries(isGlobal, page, selectedTag);
13 |
14 | return (
15 |
16 |
17 |
18 |
conduit
19 |
A place to share your knowledge.
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | {isLogin && (
29 | -
30 | {
34 | setIsGlobal(false);
35 | }}
36 | >
37 | Your Feed
38 |
39 |
40 | )}
41 | -
42 | setIsGlobal(true)}>
43 | Global Feed
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
Popular Tags
54 |
55 |
56 | {tagsInfo.data.map((tag: string) => (
57 | {
62 | setSelectedTag(tag);
63 | }}
64 | >
65 | {tag}
66 |
67 | ))}
68 |
69 |
70 |
71 |
72 |
73 |
74 | );
75 | };
76 |
77 | export default HomePage;
78 |
--------------------------------------------------------------------------------
/src/components/feed/Feed.tsx:
--------------------------------------------------------------------------------
1 | import { useFavoriteArticleMutation, useUnfavoriteArticleMutation } from '@/queries/articles.query';
2 | import { Link, useNavigate } from 'react-router-dom';
3 | import { useContext } from 'react';
4 | import { UserContext } from '@/contexts/UserContextProvider';
5 | import routerMeta from '@/lib/routerMeta';
6 | import queryClient from '@/queries/queryClient';
7 | import { QUERY_ARTICLES_KEY } from '@/constants/query.constant';
8 | import convertToDate from '@/lib/utils/convertToDate';
9 | import { IArticle } from '@/interfaces/main';
10 |
11 | interface IFeedProps {
12 | article: IArticle;
13 | }
14 |
15 | const Feed = ({ article }: IFeedProps) => {
16 | const { isLogin } = useContext(UserContext);
17 | const navigate = useNavigate();
18 | const favoriteArticleMutation = useFavoriteArticleMutation();
19 | const unfavoriteArticleMutation = useUnfavoriteArticleMutation();
20 |
21 | const onToggleFavorite = () => {
22 | const { slug } = article;
23 |
24 | if (!isLogin) {
25 | navigate(routerMeta.SignInPage.path);
26 | return;
27 | }
28 |
29 | if (article.favorited) {
30 | unfavoriteArticleMutation.mutate(
31 | { slug },
32 | {
33 | onSuccess: () => {
34 | queryClient.invalidateQueries({ queryKey: [QUERY_ARTICLES_KEY] });
35 | },
36 | },
37 | );
38 | }
39 |
40 | if (!article.favorited) {
41 | favoriteArticleMutation.mutate(
42 | { slug },
43 | {
44 | onSuccess: () => {
45 | queryClient.invalidateQueries({ queryKey: [QUERY_ARTICLES_KEY] });
46 | },
47 | },
48 | );
49 | return;
50 | }
51 | };
52 |
53 | return (
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | {article.author.username}
62 |
63 | {convertToDate(article.createdAt)}
64 |
65 |
72 |
73 |
74 |
{article.title}
75 |
{article.description}
76 |
Read more...
77 |
78 |
79 | );
80 | };
81 |
82 | export default Feed;
83 |
--------------------------------------------------------------------------------
/src/components/article/ButtonsWIthoutAccess.tsx:
--------------------------------------------------------------------------------
1 | import { useFavoriteArticleMutation, useUnfavoriteArticleMutation } from '@/queries/articles.query';
2 | import { useFollowUserMutation, useUnFollowUserMutation } from '@/queries/profiles.query';
3 | import queryClient from '@/queries/queryClient';
4 | import { QUERY_ARTICLE_KEY } from '@/constants/query.constant';
5 | import { IArticle } from '@/interfaces/main';
6 |
7 | interface IButtonsWIthoutAccessProps {
8 | articleInfo: IArticle;
9 | }
10 |
11 | const ButtonsWIthoutAccess = ({ articleInfo }: IButtonsWIthoutAccessProps) => {
12 | const favoriteArticleMutation = useFavoriteArticleMutation();
13 | const unfavoriteArticleMutation = useUnfavoriteArticleMutation();
14 | const followUserMutation = useFollowUserMutation();
15 | const unfollowUserMutation = useUnFollowUserMutation();
16 |
17 | const onToggleFavorite = () => {
18 | const { slug } = articleInfo;
19 |
20 | if (articleInfo.favorited) {
21 | unfavoriteArticleMutation.mutate(
22 | { slug },
23 | {
24 | onSuccess: () => {
25 | queryClient.invalidateQueries({ queryKey: [QUERY_ARTICLE_KEY] });
26 | },
27 | },
28 | );
29 | return;
30 | }
31 |
32 | if (!articleInfo.favorited) {
33 | favoriteArticleMutation.mutate(
34 | { slug },
35 | {
36 | onSuccess: () => {
37 | queryClient.invalidateQueries({ queryKey: [QUERY_ARTICLE_KEY] });
38 | },
39 | },
40 | );
41 | return;
42 | }
43 | };
44 |
45 | const onToggleFollow = () => {
46 | const { username, following } = articleInfo.author;
47 |
48 | if (following) {
49 | unfollowUserMutation.mutate(
50 | { username },
51 | {
52 | onSuccess: () => {
53 | queryClient.invalidateQueries({ queryKey: [QUERY_ARTICLE_KEY] });
54 | },
55 | },
56 | );
57 | return;
58 | }
59 | if (!following) {
60 | followUserMutation.mutate(
61 | { username },
62 | {
63 | onSuccess: () => {
64 | queryClient.invalidateQueries({ queryKey: [QUERY_ARTICLE_KEY] });
65 | },
66 | },
67 | );
68 | return;
69 | }
70 | };
71 |
72 | return (
73 | <>
74 |
82 |
83 |
91 | >
92 | );
93 | };
94 |
95 | export default ButtonsWIthoutAccess;
96 |
--------------------------------------------------------------------------------
/src/repositories/articles/articlesRepository.ts:
--------------------------------------------------------------------------------
1 | import apiClient from '@/repositories/apiClient';
2 | import {
3 | getArticlesParam,
4 | getArticleParam,
5 | createArticleParam,
6 | updateArticleParam,
7 | deleteArticleParam,
8 | getCommentsParam,
9 | createCommentParam,
10 | deleteCommentParam,
11 | favoriteParam,
12 | } from './articlesRepository.param';
13 | import { UNIT_PER_PAGE } from '@/constants/units.constants';
14 |
15 | export const getArticles = async ({ isGlobal, selectedTag, page, username, isFavorited }: getArticlesParam) => {
16 | return await apiClient({
17 | method: 'get',
18 | url: `/articles${isGlobal || username ? '' : '/feed'}?limit=${UNIT_PER_PAGE}&offset=${UNIT_PER_PAGE * (page - 1)}${
19 | selectedTag ? `&tag=${selectedTag}` : ''
20 | }${username ? `&${isFavorited ? 'favorited' : 'author'}=${username}` : ''}`,
21 | });
22 | };
23 |
24 | export const getArticle = async ({ slug }: getArticleParam) => {
25 | return await apiClient({
26 | method: 'get',
27 | url: `/articles/${slug}`,
28 | });
29 | };
30 |
31 | export const createArticle = async ({ title, description, body, tagList }: createArticleParam) => {
32 | return await apiClient({
33 | method: 'post',
34 | url: `/articles`,
35 | data: {
36 | article: {
37 | title,
38 | description,
39 | body,
40 | tagList,
41 | },
42 | },
43 | });
44 | };
45 |
46 | export const updateArticle = async ({ slug, title, description, body, tagList }: updateArticleParam) => {
47 | return await apiClient({
48 | method: 'put',
49 | url: `/articles/${slug}`,
50 | data: {
51 | article: {
52 | title,
53 | description,
54 | body,
55 | tagList,
56 | },
57 | },
58 | });
59 | };
60 |
61 | export const deleteArticle = async ({ slug }: deleteArticleParam) => {
62 | return await apiClient({
63 | method: 'delete',
64 | url: `/articles/${slug}`,
65 | });
66 | };
67 |
68 | export const getComments = async ({ slug }: getCommentsParam) => {
69 | return await apiClient({
70 | method: 'get',
71 | url: `/articles/${slug}/comments`,
72 | });
73 | };
74 |
75 | export const createComment = async ({ slug, body }: createCommentParam) => {
76 | return await apiClient({
77 | method: 'post',
78 | url: `/articles/${slug}/comments`,
79 | data: {
80 | comment: {
81 | body,
82 | },
83 | },
84 | });
85 | };
86 |
87 | export const deleteComment = async ({ slug, id }: deleteCommentParam) => {
88 | return await apiClient({
89 | method: 'delete',
90 | url: `/articles/${slug}/comments/${id}`,
91 | });
92 | };
93 |
94 | export const favoriteArticle = async ({ slug }: favoriteParam) => {
95 | return await apiClient({
96 | method: 'post',
97 | url: `/articles/${slug}/favorite`,
98 | });
99 | };
100 |
101 | export const unfavoriteArticle = async ({ slug }: favoriteParam) => {
102 | return await apiClient({
103 | method: 'delete',
104 | url: `/articles/${slug}/favorite`,
105 | });
106 | };
107 |
--------------------------------------------------------------------------------
/src/pages/SignInPage.tsx:
--------------------------------------------------------------------------------
1 | import { ACCESS_TOKEN_KEY } from '@/constants/token.contant';
2 | import useInputs from '@/lib/hooks/useInputs';
3 | import routerMeta from '@/lib/routerMeta';
4 | import token from '@/lib/token';
5 | import { postLogin } from '@/repositories/users/usersRepository';
6 | import { Link, useNavigate } from 'react-router-dom';
7 | import { useContext, useState } from 'react';
8 | import { UserContext } from '@/contexts/UserContextProvider';
9 |
10 | const SignInPage = () => {
11 | const [error, setError] = useState({
12 | email: '',
13 | password: '',
14 | emailOrPassword: '',
15 | });
16 |
17 | const [signIndata, onChangeSignInData] = useInputs({ email: '', password: '' });
18 | const { setIsLogin } = useContext(UserContext);
19 |
20 | const navigate = useNavigate();
21 |
22 | const onLogin = (event: React.FormEvent) => {
23 | event.preventDefault();
24 | postLogin(signIndata)
25 | .then((res) => {
26 | token.setToken(ACCESS_TOKEN_KEY, res.data.user.token);
27 | setIsLogin(!!token.getToken(ACCESS_TOKEN_KEY));
28 | navigate('/', { replace: true });
29 | })
30 | .catch((err) => {
31 | setError({
32 | email: err.response.data.errors.email,
33 | password: err.response.data.errors.password,
34 | emailOrPassword: err.response.data.errors['email or password'],
35 | });
36 | });
37 | };
38 |
39 | return (
40 |
41 |
42 |
43 |
44 |
Sign in
45 |
46 | Not registered?
47 |
48 |
49 |
50 | {error.email && - email {error.email}
}
51 | {error.password && - password {error.password}
}
52 | {error.emailOrPassword && - email or password {error.emailOrPassword}
}
53 |
54 |
55 |
81 |
82 |
83 |
84 |
85 | );
86 | };
87 |
88 | export default SignInPage;
89 |
--------------------------------------------------------------------------------
/src/components/SettingForm.tsx:
--------------------------------------------------------------------------------
1 | import { QUERY_USER_KEY } from '@/constants/query.constant';
2 | import useInputs from '@/lib/hooks/useInputs';
3 | import queryClient from '@/queries/queryClient';
4 | import { usePutUserMutation } from '@/queries/user.query';
5 | import { useNavigate } from 'react-router-dom';
6 |
7 | interface ISettingFormProps {
8 | data: { [key: string]: string | number };
9 | }
10 |
11 | const SettingForm = ({ data }: ISettingFormProps) => {
12 | const navigate = useNavigate();
13 | const [userData, onChangeUserData] = useInputs({
14 | email: data.email,
15 | username: data.username,
16 | bio: data.bio,
17 | image: data.image,
18 | password: '',
19 | });
20 |
21 | const putUserMutation = usePutUserMutation();
22 |
23 | const onUpdateSetting = (event: React.FormEvent) => {
24 | event.preventDefault();
25 |
26 | putUserMutation.mutate(
27 | { user: userData },
28 | {
29 | onSuccess: () => {
30 | queryClient.invalidateQueries({ queryKey: [QUERY_USER_KEY] });
31 | navigate('/');
32 | },
33 | },
34 | );
35 | };
36 |
37 | return (
38 | <>
39 |
97 | >
98 | );
99 | };
100 |
101 | export default SettingForm;
102 |
--------------------------------------------------------------------------------
/src/components/article/Comment.tsx:
--------------------------------------------------------------------------------
1 | import { useCreateCommentMutation, useDeleteCommentMutation } from '@/queries/articles.query';
2 | import { useGetUserQuery } from '@/queries/user.query';
3 | import useInputs from '@/lib/hooks/useInputs';
4 | import queryClient from '@/queries/queryClient';
5 | import { QUERY_COMMENTS_KEY } from '@/constants/query.constant';
6 | import convertToDate from '@/lib/utils/convertToDate';
7 | import { IComment } from '@/interfaces/main';
8 |
9 | interface ICommentProps {
10 | comments: IComment[];
11 | slug: string;
12 | }
13 |
14 | const Comment = ({ comments, slug }: ICommentProps) => {
15 | const { data } = useGetUserQuery();
16 | const [newComment, onChangeNewComment, setNewComment] = useInputs({ body: '' });
17 | const createCommentMutation = useCreateCommentMutation();
18 | const deleteCommentMutation = useDeleteCommentMutation();
19 |
20 | const onPostComment = async (e: React.FormEvent) => {
21 | e.preventDefault();
22 | const { body } = newComment;
23 | createCommentMutation.mutate(
24 | { body, slug },
25 | {
26 | onSuccess: (_) => {
27 | setNewComment({ body: '', slug });
28 | queryClient.invalidateQueries({ queryKey: [QUERY_COMMENTS_KEY] });
29 | },
30 | },
31 | );
32 | };
33 |
34 | const onDelete = (slug: string, id: number) => {
35 | deleteCommentMutation.mutate(
36 | { slug, id },
37 | {
38 | onSuccess: (_) => {
39 | queryClient.invalidateQueries({ queryKey: [QUERY_COMMENTS_KEY] });
40 | },
41 | },
42 | );
43 | };
44 |
45 | return (
46 | <>
47 |
65 |
66 | {comments.map((comment, index) => (
67 |
68 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | {comment.author.username}
78 |
79 |
{convertToDate(comment.updatedAt)}
80 | {data.username === comment.author.username ? (
81 |
82 | {/* */}
83 | onDelete(slug, comment.id)}>
84 |
85 | ) : (
86 | <>>
87 | )}
88 |
89 |
90 | ))}
91 | >
92 | );
93 | };
94 |
95 | export default Comment;
96 |
--------------------------------------------------------------------------------
/src/pages/SignUpPage.tsx:
--------------------------------------------------------------------------------
1 | import { ACCESS_TOKEN_KEY } from '@/constants/token.contant';
2 | import useInputs from '@/lib/hooks/useInputs';
3 | import routerMeta from '@/lib/routerMeta';
4 | import token from '@/lib/token';
5 | import { postRegister } from '@/repositories/users/usersRepository';
6 | import { useState, useContext } from 'react';
7 | import { UserContext } from '@/contexts/UserContextProvider';
8 | import { Link, useNavigate } from 'react-router-dom';
9 |
10 | const SignUpPage = () => {
11 | const [error, setError] = useState({
12 | email: '',
13 | username: '',
14 | password: '',
15 | });
16 | const [signUpdata, onChangeSignUpData] = useInputs({ username: '', email: '', password: '' });
17 | const { setIsLogin } = useContext(UserContext);
18 |
19 | const navigate = useNavigate();
20 |
21 | const onRegister = (event: React.FormEvent) => {
22 | event.preventDefault();
23 | postRegister(signUpdata)
24 | .then((res) => {
25 | token.setToken(ACCESS_TOKEN_KEY, res.data.user.token);
26 | setIsLogin(!!token.getToken(ACCESS_TOKEN_KEY));
27 | navigate('/', { replace: true });
28 | })
29 | .catch((err) => {
30 | setError({
31 | email: err.response.data.errors.email,
32 | password: err.response.data.errors.password,
33 | username: err.response.data.errors.username,
34 | });
35 | });
36 | };
37 |
38 | return (
39 |
94 | );
95 | };
96 |
97 | export default SignUpPage;
98 |
--------------------------------------------------------------------------------
/src/pages/ArticlePage.tsx:
--------------------------------------------------------------------------------
1 | import { useGetArticleQueries } from '@/queries/articles.query';
2 | import { Link, useLocation } from 'react-router-dom';
3 | import ReactMarkdown from 'react-markdown';
4 | import remarkGfm from 'remark-gfm';
5 | import ButtonSelector from '@/components/article/ButtonSelector';
6 | import { useContext } from 'react';
7 | import { UserContext } from '@/contexts/UserContextProvider';
8 | import Comment from '@/components/article/Comment';
9 | import routerMeta from '@/lib/routerMeta';
10 | import convertToDate from '@/lib/utils/convertToDate';
11 |
12 | const ArticlePage = () => {
13 | const { state } = useLocation();
14 | const [articleInfo, commentsInfo] = useGetArticleQueries(state);
15 | const { isLogin } = useContext(UserContext);
16 |
17 | return (
18 |
19 |
20 |
21 |
{articleInfo.data.title}
22 |
23 |
24 |
25 |

26 |
27 |
28 |
29 |
34 | {articleInfo.data.author.username}
35 |
36 | {convertToDate(articleInfo.data.updatedAt)}
37 |
38 | {isLogin ?
: <>>}
39 |
40 |
41 |
42 |
43 |
44 |
49 |
50 | {articleInfo.data.tagList.map((tag: string) => (
51 |
52 | {tag}
53 |
54 | ))}
55 |
56 |
57 |
58 |
59 |
60 |
61 |

62 |
63 |
64 |
69 | {articleInfo.data.author.username}
70 |
71 | {convertToDate(articleInfo.data.updatedAt)}
72 |
73 | {isLogin ?
: <>>}
74 |
75 |
76 |
77 |
78 | {isLogin ? (
79 |
80 | ) : (
81 |
82 | Sign in
83 | or
84 | Sign up
85 | to add comments on this article.
86 |
87 | )}
88 |
89 |
90 |
91 |
92 | );
93 | };
94 |
95 | export default ArticlePage;
96 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 
2 |
3 | [](http://realworld.io)
4 |
5 | > ### React + React Query codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API.
6 |
7 | ### [Demo](https://react-query-realworld.netlify.app) [RealWorld](https://github.com/gothinkster/realworld)
8 |
9 | This codebase was created to demonstrate a fully fledged fullstack application built with **React + React Query** including CRUD operations, authentication, routing, pagination, and more.
10 |
11 | We've gone to great lengths to adhere to the [TanStack Query](https://tanstack.com/query/latest/docs/react/overview) community styleguides & best practices.
12 |
13 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo.
14 |
15 | # How it works
16 |
17 | ```bash
18 | src
19 | ├─ App.tsx
20 | ├─ index.tsx
21 | ├─ react-app-env.d.ts
22 | ├─ Router.tsx # dynamic router assignment
23 | ├─ components # components
24 | ├─ constants # constants
25 | ├─ contexts # context API
26 | ├─ lib
27 | │ ├─ routerMeta.ts # meta data of router
28 | │ ├─ token.ts # localstorage class
29 | │ ├─ utils # utility funcs
30 | │ └─ hooks # custom hooks
31 | ├─ pages # page components
32 | ├─ queries # react query func
33 | └─ repositories # api service
34 | └─ apiClient.ts # Axios Instance & Interceptor
35 | ```
36 |
37 | ### Making requests to the backend API
38 |
39 | For convenience, we have a live API server running at https://conduit.productionready.io/api for the application to make requests against. You can view [the API spec here](https://api.realworld.io/api-docs/) which contains all routes & responses for the server.
40 |
41 | The source code for the backend server (available for Node, Rails and Django) can be found in the [main RealWorld repo](https://github.com/gothinkster/realworld).
42 |
43 | ### Using Marked Up Templates
44 |
45 | You can check the marked up [frontend spec here](https://realworld-docs.netlify.app/docs/specs/frontend-specs/templates).
46 |
47 | # Getting Started
48 |
49 | #### Install
50 | ```
51 | npm i
52 | ```
53 | #### Build
54 | ```
55 | npm run build
56 | ```
57 | #### Start
58 | ```
59 | npm start
60 | ```
61 |
62 | # Functionality overview
63 |
64 | The example application is a social blogging site (i.e. a Medium.com clone) called "Conduit". It uses a custom API for all requests, including authentication. You can view a live demo over at [https://react-query-realworld.netlify.app](https://react-query-realworld.netlify.app)
65 |
66 | **General functionality:**
67 |
68 | - Authenticate users via JWT (login/signup pages + logout button on settings page)
69 | - CRU- users (sign up & settings page - no deleting required)
70 | - CRUD Articles
71 | - CR-D Comments on articles (no updating required)
72 | - GET and display paginated lists of articles
73 | - Favorite articles
74 | - Follow other users
75 |
76 | **The general page breakdown looks like this:**
77 |
78 | - Home page (URL: /#/ )
79 | - List of tags
80 | - List of articles pulled from either Feed, Global, or by Tag
81 | - Pagination for list of articles
82 | - Sign in/Sign up pages (URL: /#/login, /#/register )
83 | - Uses JWT (store the token in localStorage)
84 | - Authentication can be easily switched to session/cookie based
85 | - Settings page (URL: /#/settings )
86 | - Editor page to create/edit articles (URL: /#/editor, /#/editor/article-slug-here )
87 | - Article page (URL: /#/article/article-slug-here )
88 | - Delete article button (only shown to article's author)
89 | - Render markdown from server client side
90 | - Comments section at bottom of page
91 | - Delete comment button (only shown to comment's author)
92 | - Profile page (URL: /#/profile/:username, /#/profile/:username/favorites )
93 | - Show basic user info
94 | - List of articles populated from author's created articles or author's favorited articles
95 |
96 |
97 |
98 | [](https://thinkster.io)
99 |
--------------------------------------------------------------------------------
/src/pages/NewArticlePage.tsx:
--------------------------------------------------------------------------------
1 | import useInputs from '@/lib/hooks/useInputs';
2 | import queryClient from '@/queries/queryClient';
3 | import { useCreateArticleMutation } from '@/queries/articles.query';
4 | import { QUERY_ARTICLES_KEY } from '@/constants/query.constant';
5 | import { useNavigate } from 'react-router-dom';
6 |
7 | const NewArticlePage = () => {
8 | const navigate = useNavigate();
9 | const [articleData, onChangeArticleData, setArticleData] = useInputs({
10 | title: '',
11 | description: '',
12 | body: '',
13 | tag: '',
14 | tagList: [],
15 | });
16 |
17 | const onEnter = (event: React.KeyboardEvent) => {
18 | if (event.key === 'Enter') {
19 | event.preventDefault();
20 | if (!articleData.tagList.includes(articleData.tag)) {
21 | addTag(articleData.tag);
22 | }
23 | }
24 | };
25 |
26 | const addTag = (newTag: string) => {
27 | setArticleData({
28 | ...articleData,
29 | tag: '',
30 | tagList: [...articleData.tagList, newTag],
31 | });
32 | };
33 |
34 | const removeTag = (target: string) => {
35 | setArticleData({ ...articleData, tagList: articleData.tagList.filter((tag: string) => tag !== target) });
36 | };
37 |
38 | const createArticleMutation = useCreateArticleMutation();
39 |
40 | const onPublish = async (e: React.FormEvent) => {
41 | e.preventDefault();
42 | const { title, description, body, tagList } = articleData;
43 | createArticleMutation.mutate(
44 | { title, description, body, tagList },
45 | {
46 | onSuccess: (res) => {
47 | queryClient.invalidateQueries({ queryKey: [QUERY_ARTICLES_KEY] });
48 | const slug = res.data.article.slug;
49 | navigate(`/article/${slug}`, { state: slug });
50 | },
51 | },
52 | );
53 | };
54 |
55 | return (
56 |
125 | );
126 | };
127 |
128 | export default NewArticlePage;
129 |
--------------------------------------------------------------------------------
/src/pages/EditArticlePage.tsx:
--------------------------------------------------------------------------------
1 | import useInputs from '@/lib/hooks/useInputs';
2 | import queryClient from '@/queries/queryClient';
3 | import { useUpdateArticleMutation } from '@/queries/articles.query';
4 | import { QUERY_ARTICLE_KEY } from '@/constants/query.constant';
5 | import { useLocation, useNavigate } from 'react-router-dom';
6 |
7 | const EditArticlePage = () => {
8 | const { state } = useLocation();
9 | const navigate = useNavigate();
10 |
11 | const [articleData, onChangeArticleData, setArticleData] = useInputs({
12 | slug: state.slug,
13 | title: state.title,
14 | description: state.description,
15 | body: state.body,
16 | tag: '',
17 | tagList: state.tagList,
18 | });
19 |
20 | const onEnter = (event: React.KeyboardEvent) => {
21 | if (event.key === 'Enter') {
22 | event.preventDefault();
23 | if (!articleData.tagList.includes(articleData.tag)) {
24 | addTag(articleData.tag);
25 | }
26 | }
27 | };
28 |
29 | const addTag = (newTag: string) => {
30 | setArticleData({
31 | ...articleData,
32 | tag: '',
33 | tagList: [...articleData.tagList, newTag],
34 | });
35 | };
36 |
37 | const removeTag = (target: string) => {
38 | setArticleData({ ...articleData, tagList: articleData.tagList.filter((tag: string) => tag !== target) });
39 | };
40 |
41 | const updateArticleMutation = useUpdateArticleMutation();
42 |
43 | const onUpdate = async (e: React.FormEvent) => {
44 | e.preventDefault();
45 | const { slug, title, description, body, tagList } = articleData;
46 | updateArticleMutation.mutate(
47 | { slug, title, description, body, tagList },
48 | {
49 | onSuccess: (res) => {
50 | queryClient.invalidateQueries({ queryKey: [QUERY_ARTICLE_KEY] });
51 | const newSlug = res.data.article.slug;
52 | navigate(`/article/${newSlug}`, { state: newSlug });
53 | },
54 | },
55 | );
56 | };
57 |
58 | return (
59 |
128 | );
129 | };
130 |
131 | export default EditArticlePage;
132 |
--------------------------------------------------------------------------------