├── .czrc ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── nextjs.yml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── prepare-commit-msg ├── .lintstagedrc.js ├── .prettierignore ├── .prettierrc.js ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── app ├── (feed) │ ├── error.tsx │ ├── feed │ │ ├── [tag] │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ └── personal │ │ │ └── page.tsx │ ├── layout.tsx │ ├── loading.tsx │ └── page.tsx ├── (user) │ ├── login │ │ └── page.tsx │ ├── profile │ │ └── [username] │ │ │ ├── favorites │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ ├── register │ │ └── page.tsx │ └── settings │ │ └── page.tsx ├── head.tsx └── layout.tsx ├── commitlint.config.js ├── favicon.ico ├── logo.png ├── next.config.js ├── package.json ├── public └── favicon.ico ├── src ├── common │ ├── constants.ts │ ├── types.ts │ └── utils.ts └── components │ ├── PlatformFooter.tsx │ ├── PlatformHeader.tsx │ ├── common │ └── LoadingSpinner │ │ ├── LoadingSpinner.module.css │ │ └── LoadingSpinner.tsx │ ├── home │ ├── ArticlePreview.tsx │ ├── ArticlesList.tsx │ ├── Banner.tsx │ ├── FeedManager.tsx │ ├── LoadingState │ │ ├── LoadingState.module.css │ │ └── LoadingState.tsx │ ├── TagsList.tsx │ └── utils.ts │ └── user │ ├── EditProfileLink.tsx │ ├── FollowUserButton.tsx │ ├── LoginForm.tsx │ ├── ProfileFeedManager.tsx │ ├── RegisterForm.tsx │ ├── SettingsForm.tsx │ ├── UserInfo.tsx │ ├── constants.ts │ └── utils.ts ├── tsconfig.json └── yarn.lock /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "./node_modules/cz-conventional-changelog" 3 | } 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_BASE_URL=https://api.realworld.io/api -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Enable certain dot files and folders 2 | !.*.js 3 | 4 | # Build output folders 5 | .next 6 | build -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const propTypeSortOrder = { 2 | callbacksLast: false, 3 | ignoreCase: false, 4 | noSortAlphabetically: false, 5 | }; 6 | 7 | module.exports = { 8 | env: { 9 | browser: true, 10 | es2020: true, 11 | jest: true, 12 | node: true, 13 | }, 14 | extends: [ 15 | 'airbnb-typescript', 16 | 'plugin:react/recommended', 17 | 'plugin:@typescript-eslint/recommended', 18 | 'plugin:compat/recommended', 19 | 'next', 20 | // NOTE: This has to come last 21 | 'plugin:prettier/recommended', 22 | ], 23 | plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'prettier', 'react-hooks'], 24 | parser: '@typescript-eslint/parser', 25 | parserOptions: { 26 | ecmaFeatures: { 27 | jsx: true, 28 | }, 29 | ecmaVersion: 2020, 30 | impliedStrict: true, 31 | project: './tsconfig.json', 32 | sourceType: 'module', 33 | }, 34 | rules: { 35 | 'arrow-body-style': ['error', 'as-needed'], 36 | 'class-methods-use-this': 'off', 37 | curly: ['error', 'all'], 38 | 'func-style': ['error', 'expression', { allowArrowFunctions: true }], 39 | 'jest/consistent-test-it': ['error', { fn: 'it' }], 40 | 'jsx-a11y/anchor-is-valid': [ 41 | 'error', 42 | { 43 | components: ['Link'], 44 | specialLink: ['to'], 45 | aspects: ['noHref', 'invalidHref', 'preferButton'], 46 | }, 47 | ], 48 | 'import/extensions': [ 49 | 'error', 50 | 'ignorePackages', 51 | { 52 | js: 'never', 53 | jsx: 'never', 54 | ts: 'never', 55 | tsx: 'never', 56 | }, 57 | ], 58 | 'import/order': [ 59 | 'error', 60 | { 61 | 'newlines-between': 'always', 62 | alphabetize: { 63 | order: 'asc', 64 | caseInsensitive: false, 65 | }, 66 | groups: [ 67 | 'builtin', 68 | 'external', 69 | 'internal', 70 | ['index', 'sibling', 'parent'], 71 | 'type', 72 | 'object', 73 | ], 74 | pathGroups: [ 75 | { 76 | pattern: '~/**', 77 | group: 'internal', 78 | }, 79 | ], 80 | pathGroupsExcludedImportTypes: ['type'], 81 | }, 82 | ], 83 | 'import/no-default-export': 'error', 84 | 'import/no-extraneous-dependencies': ['error', { devDependencies: true }], 85 | 'import/prefer-default-export': 'off', 86 | 'max-classes-per-file': 'off', 87 | 'max-len': [ 88 | 'error', 89 | { 90 | code: 100, 91 | comments: 80, 92 | ignoreUrls: true, 93 | ignoreStrings: true, 94 | ignoreTemplateLiterals: true, 95 | ignoreRegExpLiterals: true, 96 | ignorePattern: 'eslint-disable+', 97 | }, 98 | ], 99 | 'no-undef': 'error', 100 | 'no-unused-vars': 'off', 101 | '@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }], 102 | quotes: ['error', 'single', { allowTemplateLiterals: true }], 103 | 'react/boolean-prop-naming': ['error', { rule: '^(is|has|can)[A-Z]([A-Za-z0-9]?)+' }], 104 | 'react/button-has-type': ['error', { reset: false }], 105 | 'react/function-component-definition': [ 106 | 'error', 107 | { 108 | namedComponents: 'arrow-function', 109 | unnamedComponents: 'arrow-function', 110 | }, 111 | ], 112 | 'react/jsx-filename-extension': 'off', 113 | 'react/jsx-handler-names': 'error', 114 | 'react/jsx-sort-props': [ 115 | 'error', 116 | { 117 | ...propTypeSortOrder, 118 | shorthandFirst: false, 119 | shorthandLast: false, 120 | reservedFirst: false, 121 | }, 122 | ], 123 | 'react/prop-types': 'off', 124 | 'react/require-default-props': 'off', 125 | 'react/sort-prop-types': [ 126 | 'error', 127 | { 128 | ...propTypeSortOrder, 129 | requiredFirst: true, 130 | sortShapeProp: true, 131 | }, 132 | ], 133 | 'react-hooks/rules-of-hooks': 'error', 134 | 'react-hooks/exhaustive-deps': 'error', 135 | '@next/next/no-html-link-for-pages': ['error', './app'], 136 | '@typescript-eslint/explicit-function-return-type': 'error', 137 | '@typescript-eslint/explicit-module-boundary-types': 'off', 138 | '@typescript-eslint/member-ordering': ['error', { default: { order: 'alphabetically' } }], 139 | '@typescript-eslint/no-explicit-any': ['error', { ignoreRestArgs: true }], 140 | '@typescript-eslint/consistent-type-imports': 'error', 141 | }, 142 | overrides: [ 143 | { 144 | files: ['app/**/*'], 145 | rules: { 146 | 'import/no-default-export': 'off', 147 | }, 148 | }, 149 | { 150 | files: ['app/**/*'], 151 | rules: { 152 | '@next/next/no-css-tags': 'off', 153 | }, 154 | }, 155 | { 156 | files: ['*.js'], 157 | rules: { 158 | '@typescript-eslint/no-var-requires': 'off', 159 | '@typescript-eslint/explicit-function-return-type': 'off', 160 | }, 161 | }, 162 | { 163 | files: ['*.ts', '*.tsx'], 164 | rules: { 165 | '@typescript-eslint/explicit-module-boundary-types': ['error'], 166 | }, 167 | }, 168 | { 169 | files: ['*.d.ts', '*.stories.tsx', '.storybook/**/*'], 170 | rules: { 171 | 'import/no-default-export': 'off', 172 | }, 173 | }, 174 | { 175 | files: ['*.test.tsx', '*.stories.tsx'], 176 | rules: { 177 | 'react/jsx-props-no-spreading': 'off', 178 | '@typescript-eslint/explicit-function-return-type': 'off', 179 | }, 180 | }, 181 | ], 182 | settings: { 183 | 'import/resolver': { 184 | typescript: {}, 185 | }, 186 | react: { 187 | version: 'detect', 188 | }, 189 | }, 190 | }; 191 | -------------------------------------------------------------------------------- /.github/workflows/nextjs.yml: -------------------------------------------------------------------------------- 1 | name: Validate Next.js app 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | # Allows you to run this workflow manually from the Actions tab 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | - name: Restore Node.js and Next.js caches 16 | uses: actions/cache@v3 17 | with: 18 | path: | 19 | ${{ github.workspace }}/node_modules 20 | ${{ github.workspace }}/.next/cache 21 | key: ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} 22 | restore-keys: | 23 | ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}- 24 | - name: Set up Node.js version 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: "16" 28 | - name: Install dependencies 29 | run: yarn 30 | - name: Build application 31 | run: yarn next build 32 | - name: Linting 33 | run: yarn lint 34 | -------------------------------------------------------------------------------- /.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env* 30 | 31 | !.env.example 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | 40 | #editors 41 | /.idea 42 | /.vscode 43 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn lint-staged -------------------------------------------------------------------------------- /.husky/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | exec < /dev/tty && yarn cz --hook || true 5 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.{json,md,html,js,ts,tsx}': ['prettier --write'], 3 | '*.{js,ts,tsx}': ['eslint --fix'], 4 | }; 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Enable certain dot files and folders 2 | !.*.js 3 | 4 | # Build output folders 5 | .next 6 | build 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | proseWrap: 'never', 4 | singleQuote: true, 5 | overrides: [ 6 | { 7 | files: '*.md', 8 | options: { 9 | tabWidth: 4, 10 | }, 11 | }, 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement by contacting the maintainer team 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 RealWorld 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App](logo.png) 2 | 3 | > ### Next.js 13+ codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 4 | 5 | ### [Demo](https://next-layouts-realworld-example-app.vercel.app/)    [RealWorld](https://github.com/gothinkster/realworld) 6 | 7 | > Work in progress... 8 | 9 | Vercel team introduced brand-new approach of building Next.js applications in theirs 13-th major version ([announcement](https://nextjs.org/blog/next-13)). This upgrade brings React 18 features like Server Components, Suspence, Server HTML Streaming and so on. This approach is quite different from what we used before in Next.js. 10 | 11 | A goal of this project is to demonstrate a fully fledged Front End application built with this new paradigm including CRUD operations, authentication, routing, pagination, and more. 12 | 13 | # How it works 14 | 15 | > Work in progress... 16 | 17 | # Getting started 18 | 19 | ## Installation 20 | 21 | Node.js is a prerequisite. See the "engines" property in `package.json` for the version range. 22 | 23 | To install, run the following commands: 24 | 25 | 1. `git clone git@github.com:Dima-Abramenko/next-layouts-realworld-example-app.git` 26 | 2. `cd next-layouts-realworld-example-app` 27 | 3. `yarn` 28 | 29 | ## CI/CD 30 | 31 | This project is automatically built & validated using `GitHub actions` whose configurations are defined in the `.github/workflows` folder. 32 | 33 | ## Development 34 | 35 | To run the development server: 36 | 37 | ``` 38 | yarn dev 39 | ``` 40 | 41 | To run the development server using TurboPack: 42 | 43 | ``` 44 | yarn dev --turbo 45 | ``` 46 | 47 | To run the project in production mode: 48 | 49 | ``` 50 | yarn build && yarn start 51 | ``` 52 | 53 | ## Git Guidelines 54 | 55 | ### How to Commit 56 | 57 | The project uses a toolchain ([Commitizen](http://commitizen.github.io/cz-cli/) and [commitlint](https://commitlint.js.org/)) to handle its commits; therefore, _please use the command line when making a commit_ and do not use the `-m` option, i.e., only `git commit` is required. Once triggered you will be provided with prompts for the type of commit, ticket number, commit message, body as well as some other questions. 58 | 59 | ### Commit Message Format 60 | 61 | On top of the Conventional Commits types: **`fix`** and **`feat`**, MPTC uses additional types according to [@commitlint/config-conventional (based on the Angular convention)](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional#type-enum) which get presented when [running a commit](#how-to-commit). 62 | 63 | - Use the imperative, present tense (e.g. "fix", not "fixed" and [more examples](https://www.conventionalcommits.org/en/v1.0.0/#examples)) 64 | - Start with a verb 65 | - Use the body/'long message' of the commit if more context is needed. - prompted after the header/'short message' 66 | 67 | In addition to these general guidelines, all commit messages are linted via [commitlint](https://commitlint.js.org/) using the rules from [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional#rules). 68 | -------------------------------------------------------------------------------- /app/(feed)/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | 5 | import type { FC } from 'react'; 6 | 7 | type Props = { 8 | error: Error; 9 | }; 10 | 11 | // TODO: Update Error state 12 | const Error: FC = ({ error }: Props) => { 13 | useEffect(() => { 14 | console.error(error); 15 | }, [error]); 16 | 17 | return ( 18 |
19 |

Something went wrong!

20 | {error.message &&
{error.message}
} 21 |
22 | ); 23 | }; 24 | 25 | export default Error; 26 | -------------------------------------------------------------------------------- /app/(feed)/feed/[tag]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingState } from '~/components/home/LoadingState/LoadingState'; 2 | 3 | import type { FC } from 'react'; 4 | 5 | const Loading: FC = () => ; 6 | 7 | export default Loading; 8 | -------------------------------------------------------------------------------- /app/(feed)/feed/[tag]/page.tsx: -------------------------------------------------------------------------------- 1 | import { ArticlesList } from '~/components/home/ArticlesList'; 2 | 3 | import type { ReactElement } from 'react'; 4 | import type { ArticlesResponse } from '~/common/types'; 5 | 6 | // TODO: Complete data fetching 7 | const getArticles = async (tag: string): Promise => { 8 | // eslint-disable-next-line compat/compat 9 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/articles?tag=${tag}`, { 10 | cache: 'no-store', 11 | }); 12 | 13 | if (!response.ok) { 14 | throw new Error('Failed to fetch feed by given tag'); 15 | } 16 | 17 | return response.json(); 18 | }; 19 | 20 | type Props = { 21 | params: { tag: string }; 22 | }; 23 | 24 | // TODO: Complete TagFeed page 25 | const TagFeed = async ({ params: { tag } }: Props): Promise => { 26 | const data = await getArticles(tag); 27 | const { articles } = data; 28 | 29 | return ; 30 | }; 31 | 32 | export default TagFeed; 33 | -------------------------------------------------------------------------------- /app/(feed)/feed/personal/page.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { redirect } from 'next/navigation'; 3 | 4 | import { USER_LOGIN_ROUTE } from '~/common/constants'; 5 | import { ArticlesList } from '~/components/home/ArticlesList'; 6 | 7 | import type { ReactElement } from 'react'; 8 | import type { User, UserResponse } from '~/common/types'; 9 | import type { ArticlesResponse } from '~/common/types'; 10 | 11 | // TODO: Extract the function to shared folder & add caching 12 | const getCurrentUser = async (): Promise => { 13 | const cookieStore = cookies(); 14 | const userTokenCookie = cookieStore.get('auth'); 15 | 16 | const hasUserToken = userTokenCookie && userTokenCookie.value; 17 | 18 | if (!hasUserToken) { 19 | return null; 20 | } 21 | 22 | // eslint-disable-next-line compat/compat 23 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/user`, { 24 | headers: { 25 | authorization: `Bearer ${userTokenCookie.value}`, 26 | }, 27 | cache: 'no-store', 28 | }); 29 | 30 | if (!response.ok) { 31 | return null; 32 | } 33 | 34 | const currentUserResponse: UserResponse = await response.json(); 35 | 36 | return currentUserResponse.user; 37 | }; 38 | 39 | // TODO: Complete data fetching 40 | const getArticles = async (user: User): Promise => { 41 | // eslint-disable-next-line compat/compat 42 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/articles/feed`, { 43 | cache: 'no-store', 44 | headers: { 45 | authorization: `Bearer ${user.token}`, 46 | }, 47 | }); 48 | 49 | if (!response.ok) { 50 | throw new Error('Failed to fetch your personal feed'); 51 | } 52 | 53 | return response.json(); 54 | }; 55 | 56 | // TODO: Complete TagFeed page 57 | const YourFeed = async (): Promise => { 58 | const user = await getCurrentUser(); 59 | 60 | if (!user) { 61 | redirect(USER_LOGIN_ROUTE); 62 | } 63 | 64 | const data = await getArticles(user); 65 | const { articles } = data; 66 | 67 | return ; 68 | }; 69 | 70 | export default YourFeed; 71 | -------------------------------------------------------------------------------- /app/(feed)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import React from 'react'; 3 | 4 | import { Banner } from '~/components/home/Banner'; 5 | import { FeedManager } from '~/components/home/FeedManager'; 6 | import { TagsList } from '~/components/home/TagsList'; 7 | 8 | import type { Metadata } from 'next'; 9 | import type { ReactElement, ReactNode } from 'react'; 10 | import type { User, UserResponse } from '~/common/types'; 11 | import type { TagsResponse } from '~/common/types'; 12 | 13 | // TODO: Extract the function to shared folder & add caching 14 | const getCurrentUser = async (): Promise => { 15 | const cookieStore = cookies(); 16 | const userTokenCookie = cookieStore.get('auth'); 17 | 18 | const hasUserToken = userTokenCookie && userTokenCookie.value; 19 | 20 | if (!hasUserToken) { 21 | return null; 22 | } 23 | 24 | // eslint-disable-next-line compat/compat 25 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/user`, { 26 | headers: { 27 | authorization: `Bearer ${userTokenCookie.value}`, 28 | }, 29 | cache: 'no-store', 30 | }); 31 | 32 | if (!response.ok) { 33 | return null; 34 | } 35 | 36 | const currentUserResponse: UserResponse = await response.json(); 37 | 38 | return currentUserResponse.user; 39 | }; 40 | 41 | // TODO: Complete data fetching & issue with caching 42 | const getTags = async (): Promise => { 43 | // eslint-disable-next-line compat/compat 44 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/tags`, { 45 | cache: 'no-store', 46 | }); 47 | 48 | if (!response.ok) { 49 | throw new Error('Failed to fetch tags'); 50 | } 51 | 52 | return response.json(); 53 | }; 54 | 55 | type Props = { 56 | children: ReactNode; 57 | }; 58 | 59 | export const metadata: Metadata = { 60 | title: 'Home - Conduit', 61 | }; 62 | 63 | // TODO: Complete FeedLayout 64 | const FeedLayout = async ({ children }: Props): Promise => { 65 | const user = await getCurrentUser(); 66 | 67 | const data = await getTags(); 68 | const { tags } = data; 69 | 70 | return ( 71 |
72 | 73 |
74 |
75 |
76 | 77 | {children} 78 |
79 |
80 | 81 |
82 |
83 |
84 |
85 | ); 86 | }; 87 | 88 | export default FeedLayout; 89 | -------------------------------------------------------------------------------- /app/(feed)/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingState } from '~/components/home/LoadingState/LoadingState'; 2 | 3 | import type { FC } from 'react'; 4 | 5 | const Loading: FC = () => ; 6 | 7 | export default Loading; 8 | -------------------------------------------------------------------------------- /app/(feed)/page.tsx: -------------------------------------------------------------------------------- 1 | import { ArticlesList } from '~/components/home/ArticlesList'; 2 | 3 | import type { ReactElement } from 'react'; 4 | import type { ArticlesResponse } from '~/common/types'; 5 | 6 | // TODO: Complete data fetching 7 | const getArticles = async (): Promise => { 8 | // eslint-disable-next-line compat/compat 9 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/articles`, { 10 | cache: 'no-store', 11 | }); 12 | 13 | if (!response.ok) { 14 | throw new Error('Failed to fetch global feed'); 15 | } 16 | 17 | return response.json(); 18 | }; 19 | 20 | // TODO: Complete GlobalFeed page 21 | const GlobalFeed = async (): Promise => { 22 | const data = await getArticles(); 23 | const { articles } = data; 24 | 25 | return ; 26 | }; 27 | 28 | export default GlobalFeed; 29 | -------------------------------------------------------------------------------- /app/(user)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import Link from 'next/link'; 3 | import { redirect } from 'next/navigation'; 4 | 5 | import { GLOBAL_FEED_ROUTE, USER_REGISTER_ROUTE } from '~/common/constants'; 6 | import { LoginForm } from '~/components/user/LoginForm'; 7 | 8 | import type { Metadata } from 'next'; 9 | import type { ReactElement } from 'react'; 10 | import type { User, UserResponse } from '~/common/types'; 11 | 12 | // TODO: Extract the function to shared folder & add caching 13 | const getCurrentUser = async (): Promise => { 14 | const cookieStore = cookies(); 15 | const userTokenCookie = cookieStore.get('auth'); 16 | 17 | const hasUserToken = userTokenCookie && userTokenCookie.value; 18 | 19 | if (!hasUserToken) { 20 | return null; 21 | } 22 | 23 | // eslint-disable-next-line compat/compat 24 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/user`, { 25 | headers: { 26 | authorization: `Bearer ${userTokenCookie.value}`, 27 | }, 28 | cache: 'no-store', 29 | }); 30 | 31 | if (!response.ok) { 32 | return null; 33 | } 34 | 35 | const currentUserResponse: UserResponse = await response.json(); 36 | 37 | return currentUserResponse.user; 38 | }; 39 | 40 | export const metadata: Metadata = { 41 | title: 'Sign in - Conduit', 42 | }; 43 | 44 | // TODO: Complete Login page 45 | const Login = async (): Promise => { 46 | const user = await getCurrentUser(); 47 | 48 | if (user) { 49 | redirect(GLOBAL_FEED_ROUTE); 50 | } 51 | 52 | return ( 53 |
54 |
55 |
56 |
57 |

Sign in

58 |

59 | Need account? 60 |

61 | 62 |
63 |
64 |
65 |
66 | ); 67 | }; 68 | 69 | export default Login; 70 | -------------------------------------------------------------------------------- /app/(user)/profile/[username]/favorites/page.tsx: -------------------------------------------------------------------------------- 1 | // TODO: Think about refactoring of articles components 2 | import { ArticlesList } from '~/components/home/ArticlesList'; 3 | 4 | import type { ReactElement } from 'react'; 5 | import type { ArticlesResponse } from '~/common/types'; 6 | 7 | // TODO: Complete data fetching 8 | const getFavoritedArticles = async (username: string): Promise => { 9 | // eslint-disable-next-line compat/compat 10 | const response = await fetch( 11 | `${process.env.NEXT_PUBLIC_API_BASE_URL}/articles?favorited=${username}`, 12 | { 13 | cache: 'no-store', 14 | } 15 | ); 16 | 17 | if (!response.ok) { 18 | throw new Error('Failed to fetch feed by given tag'); 19 | } 20 | 21 | return response.json(); 22 | }; 23 | 24 | type Props = { 25 | params: { username: string }; 26 | }; 27 | 28 | // TODO: Complete UserProfileFavoritesPage 29 | const UserProfileFavoritesPage = async ({ params: { username } }: Props): Promise => { 30 | const data = await getFavoritedArticles(username); 31 | const { articles } = data; 32 | 33 | return ; 34 | }; 35 | 36 | export default UserProfileFavoritesPage; 37 | -------------------------------------------------------------------------------- /app/(user)/profile/[username]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { redirect } from 'next/navigation'; 3 | import React from 'react'; 4 | 5 | import { GLOBAL_FEED_ROUTE } from '~/common/constants'; 6 | import { ProfileFeedManager } from '~/components/user/ProfileFeedManager'; 7 | import { UserInfo } from '~/components/user/UserInfo'; 8 | import { checkProfileBelongsToUser } from '~/components/user/utils'; 9 | 10 | import type { ReactNode } from 'react'; 11 | import type { ReactElement } from 'react'; 12 | import type { User, UserResponse, UserProfile, UserProfileResponse } from '~/common/types'; 13 | 14 | const getUserProfile = async (username: string): Promise => { 15 | // eslint-disable-next-line compat/compat 16 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/profiles/${username}`, { 17 | cache: 'no-store', 18 | }); 19 | 20 | if (!response.ok) { 21 | return null; 22 | } 23 | 24 | const userProfileResponse: UserProfileResponse = await response.json(); 25 | 26 | return userProfileResponse.profile; 27 | }; 28 | 29 | // TODO: Extract the function to shared folder & add caching 30 | const getCurrentUser = async (): Promise => { 31 | const cookieStore = cookies(); 32 | const userTokenCookie = cookieStore.get('auth'); 33 | 34 | const hasUserToken = userTokenCookie && userTokenCookie.value; 35 | 36 | if (!hasUserToken) { 37 | return null; 38 | } 39 | 40 | // eslint-disable-next-line compat/compat 41 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/user`, { 42 | headers: { 43 | authorization: `Bearer ${userTokenCookie.value}`, 44 | }, 45 | cache: 'no-store', 46 | }); 47 | 48 | if (!response.ok) { 49 | return null; 50 | } 51 | 52 | const currentUserResponse: UserResponse = await response.json(); 53 | 54 | return currentUserResponse.user; 55 | }; 56 | 57 | type Props = { 58 | children: ReactNode; 59 | params: { username: string }; 60 | }; 61 | 62 | // TODO: Complete UserProfileLayout 63 | const UserProfileLayout = async ({ 64 | children, 65 | params: { username }, 66 | }: Props): Promise => { 67 | const userProfile = await getUserProfile(username); 68 | const currentUser = await getCurrentUser(); 69 | 70 | if (!userProfile) { 71 | redirect(GLOBAL_FEED_ROUTE); 72 | } 73 | 74 | const isProfileBelongsToUser = checkProfileBelongsToUser(userProfile, currentUser); 75 | 76 | return ( 77 |
78 | 79 | 80 |
81 |
82 |
83 | 87 | {children} 88 |
89 |
90 |
91 |
92 | ); 93 | }; 94 | 95 | export default UserProfileLayout; 96 | -------------------------------------------------------------------------------- /app/(user)/profile/[username]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingState } from '~/components/home/LoadingState/LoadingState'; 2 | 3 | import type { FC } from 'react'; 4 | 5 | const Loading: FC = () => ; 6 | 7 | export default Loading; 8 | -------------------------------------------------------------------------------- /app/(user)/profile/[username]/page.tsx: -------------------------------------------------------------------------------- 1 | // TODO: Think about refactoring of articles components 2 | import { ArticlesList } from '~/components/home/ArticlesList'; 3 | 4 | import type { ReactElement } from 'react'; 5 | import type { ArticlesResponse } from '~/common/types'; 6 | 7 | // TODO: Complete data fetching 8 | const getUserArticles = async (username: string): Promise => { 9 | // eslint-disable-next-line compat/compat 10 | const response = await fetch( 11 | `${process.env.NEXT_PUBLIC_API_BASE_URL}/articles?author=${username}`, 12 | { 13 | cache: 'no-store', 14 | } 15 | ); 16 | 17 | if (!response.ok) { 18 | throw new Error('Failed to fetch feed by given tag'); 19 | } 20 | 21 | return response.json(); 22 | }; 23 | 24 | type Props = { 25 | params: { username: string }; 26 | }; 27 | 28 | // TODO: Complete UserProfileFavoritesPage 29 | const UserProfilePage = async ({ params: { username } }: Props): Promise => { 30 | const data = await getUserArticles(username); 31 | const { articles } = data; 32 | 33 | return ; 34 | }; 35 | 36 | export default UserProfilePage; 37 | -------------------------------------------------------------------------------- /app/(user)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import Link from 'next/link'; 3 | import { redirect } from 'next/navigation'; 4 | 5 | import { GLOBAL_FEED_ROUTE, USER_LOGIN_ROUTE } from '~/common/constants'; 6 | import { RegisterForm } from '~/components/user/RegisterForm'; 7 | 8 | import type { Metadata } from 'next'; 9 | import type { ReactElement } from 'react'; 10 | import type { User, UserResponse } from '~/common/types'; 11 | 12 | // TODO: Extract the function to shared folder & add caching 13 | const getCurrentUser = async (): Promise => { 14 | const cookieStore = cookies(); 15 | const userTokenCookie = cookieStore.get('auth'); 16 | 17 | const hasUserToken = userTokenCookie && userTokenCookie.value; 18 | 19 | if (!hasUserToken) { 20 | return null; 21 | } 22 | 23 | // eslint-disable-next-line compat/compat 24 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/user`, { 25 | headers: { 26 | authorization: `Bearer ${userTokenCookie.value}`, 27 | }, 28 | cache: 'no-store', 29 | }); 30 | 31 | if (!response.ok) { 32 | return null; 33 | } 34 | 35 | const currentUserResponse: UserResponse = await response.json(); 36 | 37 | return currentUserResponse.user; 38 | }; 39 | 40 | export const metadata: Metadata = { 41 | title: 'Sign up - Conduit', 42 | }; 43 | 44 | // TODO: Complete Register page 45 | const Register = async (): Promise => { 46 | const user = await getCurrentUser(); 47 | 48 | if (user) { 49 | redirect(GLOBAL_FEED_ROUTE); 50 | } 51 | 52 | return ( 53 |
54 |
55 |
56 |
57 |

Sign up

58 |

59 | Have an account? 60 |

61 | 62 |
63 |
64 |
65 |
66 | ); 67 | }; 68 | 69 | export default Register; 70 | -------------------------------------------------------------------------------- /app/(user)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { redirect } from 'next/navigation'; 3 | 4 | import { GLOBAL_FEED_ROUTE } from '~/common/constants'; 5 | import { SettingsForm } from '~/components/user/SettingsForm'; 6 | 7 | import type { Metadata } from 'next'; 8 | import type { ReactElement } from 'react'; 9 | import type { User, UserResponse } from '~/common/types'; 10 | 11 | // TODO: Extract the function to shared folder & add caching 12 | const getCurrentUser = async (): Promise => { 13 | const cookieStore = cookies(); 14 | const userTokenCookie = cookieStore.get('auth'); 15 | 16 | const hasUserToken = userTokenCookie && userTokenCookie.value; 17 | 18 | if (!hasUserToken) { 19 | return null; 20 | } 21 | 22 | // eslint-disable-next-line compat/compat 23 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/user`, { 24 | headers: { 25 | authorization: `Bearer ${userTokenCookie.value}`, 26 | }, 27 | cache: 'no-store', 28 | }); 29 | 30 | if (!response.ok) { 31 | return null; 32 | } 33 | 34 | const currentUserResponse: UserResponse = await response.json(); 35 | 36 | return currentUserResponse.user; 37 | }; 38 | 39 | export const metadata: Metadata = { 40 | title: 'Settings - Conduit', 41 | }; 42 | 43 | // TODO: Complete Register page 44 | const Settings = async (): Promise => { 45 | const user = await getCurrentUser(); 46 | 47 | if (!user) { 48 | redirect(GLOBAL_FEED_ROUTE); 49 | } 50 | 51 | return ( 52 |
53 |
54 |
55 |
56 |

Your Settings

57 | 58 |
59 |
60 |
61 |
62 | ); 63 | }; 64 | 65 | export default Settings; 66 | -------------------------------------------------------------------------------- /app/head.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 2 | // @ts-nocheck 3 | // Added @ts-nocheck here as property 'precedence' does not exist on type 4 | // 'DetailedHTMLProps, HTMLLinkElement>' 5 | // but this new React-specific property is required 6 | // for adding in Next.js 13+ head component: 7 | // https://beta.nextjs.org/docs/api-reference/file-conventions/head 8 | import type { ReactElement } from 'react'; 9 | 10 | const Head = (): ReactElement => ( 11 | <> 12 | 13 | {/* Import IonIcon icons, Google Fonts & custom Bootstrap 4 theme */} 14 | {/* It's necessary to ensure all RealWorld frontends had consistent UI */} 15 | {/* For more details: https://www.realworld.how/docs/specs/frontend-specs/styles */} 16 | 22 | 28 | 29 | 30 | ); 31 | 32 | export default Head; 33 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import React from 'react'; 3 | 4 | import { PlatformFooter } from '~/components/PlatformFooter'; 5 | import { PlatformHeader } from '~/components/PlatformHeader'; 6 | 7 | import type { Metadata } from 'next'; 8 | import type { ReactElement, ReactNode } from 'react'; 9 | import type { User, UserResponse } from '~/common/types'; 10 | 11 | type Props = { 12 | children: ReactNode; 13 | }; 14 | 15 | // TODO: Extract the function to shared folder & add caching 16 | const getCurrentUser = async (): Promise => { 17 | const cookieStore = cookies(); 18 | const userTokenCookie = cookieStore.get('auth'); 19 | 20 | const hasUserToken = userTokenCookie && userTokenCookie.value; 21 | 22 | if (!hasUserToken) { 23 | return null; 24 | } 25 | 26 | // eslint-disable-next-line compat/compat 27 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/user`, { 28 | headers: { 29 | authorization: `Bearer ${userTokenCookie.value}`, 30 | }, 31 | cache: 'no-store', 32 | }); 33 | 34 | if (!response.ok) { 35 | return null; 36 | } 37 | 38 | const currentUserResponse: UserResponse = await response.json(); 39 | 40 | return currentUserResponse.user; 41 | }; 42 | 43 | export const metadata: Metadata = { 44 | title: 'Conduit', 45 | description: 46 | 'RealWorld app implementation powered by Next.js 13+ (Layout, Server Components and so on)', 47 | viewport: { width: 'device-width', initialScale: 1 }, 48 | }; 49 | 50 | const RootLayout = async ({ children }: Props): Promise => { 51 | const user = await getCurrentUser(); 52 | 53 | return ( 54 | 55 | {/* 56 | will contain the components returned by the nearest parent 57 | head.tsx. Find out more at https://beta.nextjs.org/docs/api-reference/file-conventions/head 58 | */} 59 | 60 | 61 | 62 | {children} 63 | 64 | 65 | 66 | ); 67 | }; 68 | 69 | export default RootLayout; 70 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dima-Abramenko/next-layouts-realworld-example-app/2a31d85e9c9d7b2eea2ac194fc4cd00528c032ab/favicon.ico -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dima-Abramenko/next-layouts-realworld-example-app/2a31d85e9c9d7b2eea2ac194fc4cd00528c032ab/logo.png -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | }, 6 | images: { 7 | remotePatterns: [ 8 | { 9 | protocol: 'https', 10 | hostname: 'api.realworld.io', 11 | port: '', 12 | pathname: '/images/**', 13 | }, 14 | ], 15 | }, 16 | }; 17 | 18 | module.exports = nextConfig; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-layouts-realworld-example-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "contributors": [ 6 | { 7 | "name": "Dmitry Abramenko", 8 | "email": "dmitry.abramenko1995@gmail.com" 9 | } 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/Dima-Abramenko/next-layouts-realworld-example-app.git" 14 | }, 15 | "engines": { 16 | "node": ">=14" 17 | }, 18 | "scripts": { 19 | "dev": "next dev", 20 | "build": "next build", 21 | "start": "next start", 22 | "lint": "eslint .", 23 | "postinstall": "husky install" 24 | }, 25 | "dependencies": { 26 | "formik": "^2.2.9", 27 | "js-cookie": "^3.0.1", 28 | "next": "13.2.3", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0" 31 | }, 32 | "devDependencies": { 33 | "@commitlint/cli": "^17.3.0", 34 | "@commitlint/config-conventional": "^17.3.0", 35 | "@types/js-cookie": "^3.0.2", 36 | "@types/node": "18.11.18", 37 | "@types/react": "18.0.26", 38 | "@types/react-dom": "18.0.10", 39 | "@typescript-eslint/eslint-plugin": "^5.47.1", 40 | "@typescript-eslint/parser": "^5.47.1", 41 | "commitizen": "^4.2.6", 42 | "cz-conventional-changelog": "^3.3.0", 43 | "eslint": "8.30.0", 44 | "eslint-config-airbnb-typescript": "^17.0.0", 45 | "eslint-config-next": "13.1.1", 46 | "eslint-config-prettier": "^8.5.0", 47 | "eslint-import-resolver-typescript": "^3.5.2", 48 | "eslint-plugin-compat": "^4.0.2", 49 | "eslint-plugin-import": "^2.26.0", 50 | "eslint-plugin-jest": "^27.1.7", 51 | "eslint-plugin-prettier": "^4.2.1", 52 | "eslint-plugin-react": "^7.31.11", 53 | "eslint-plugin-react-hooks": "^4.6.0", 54 | "husky": "^8.0.2", 55 | "lint-staged": "^13.1.0", 56 | "prettier": "^2.8.1", 57 | "typescript": "4.9.4" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dima-Abramenko/next-layouts-realworld-example-app/2a31d85e9c9d7b2eea2ac194fc4cd00528c032ab/public/favicon.ico -------------------------------------------------------------------------------- /src/common/constants.ts: -------------------------------------------------------------------------------- 1 | export const GLOBAL_FEED_ROUTE = '/'; 2 | export const PERSONAL_FEED_ROUTE = '/feed/personal'; 3 | export const TAG_FEED_ROUTE = '/feed/[tag]'; 4 | export const USER_LOGIN_ROUTE = '/login'; 5 | export const USER_REGISTER_ROUTE = '/register'; 6 | export const USER_SETTINGS_ROUTE = '/settings'; 7 | export const USER_PROFILE_ROUTE = '/profile/[username]'; 8 | export const USER_PROFILE_FAVORITES_ROUTE = '/profile/[username]/favorites'; 9 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/member-ordering */ 2 | 3 | export type User = { 4 | username: string; 5 | bio: string; 6 | image: string; 7 | email: string; 8 | token: string; 9 | }; 10 | 11 | export type UserResponse = { 12 | user: User; 13 | }; 14 | 15 | export type UserLogin = { 16 | email: string; 17 | password: string; 18 | }; 19 | 20 | export type UserRegister = { 21 | username: string; 22 | email: string; 23 | password: string; 24 | }; 25 | 26 | export type UserSettings = { 27 | email: string; 28 | password: string; 29 | username: string; 30 | bio: string; 31 | image: string; 32 | }; 33 | 34 | export type UserProfile = { 35 | username: string; 36 | bio: string; 37 | image: string; 38 | following: boolean; 39 | }; 40 | 41 | export type UserProfileResponse = { 42 | profile: UserProfile; 43 | }; 44 | 45 | export type Article = { 46 | slug: string; 47 | title: string; 48 | description: string; 49 | body: string; 50 | tagList: string[]; 51 | createdAt: string; 52 | updatedAt: string; 53 | favorited: boolean; 54 | favoritesCount: number; 55 | author: UserProfile; 56 | }; 57 | 58 | export type ArticlesResponse = { 59 | articles: Article[]; 60 | articlesCount: number; 61 | }; 62 | 63 | export type TagsResponse = { 64 | tags: string[]; 65 | }; 66 | -------------------------------------------------------------------------------- /src/common/utils.ts: -------------------------------------------------------------------------------- 1 | // TODO: Complete replaceDynamicRoute helper function 2 | export const replaceDynamicRoute = (route: string, slug: string, dynamicValue: string): string => 3 | route.replace(`[${slug}]`, encodeURIComponent(dynamicValue)); 4 | -------------------------------------------------------------------------------- /src/components/PlatformFooter.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import React from 'react'; 3 | 4 | import { GLOBAL_FEED_ROUTE } from '~/common/constants'; 5 | 6 | import type { ReactElement } from 'react'; 7 | 8 | // TODO: Complete PlatformFooter component 9 | export const PlatformFooter = (): ReactElement => ( 10 |
11 |
12 | 13 | conduit 14 | 15 | 16 | An interactive learning project from{' '} 17 | 18 | Thinkster 19 | 20 | . Code & design licensed under MIT. 21 | 22 |
23 |
24 | ); 25 | -------------------------------------------------------------------------------- /src/components/PlatformHeader.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import React from 'react'; 3 | 4 | import { 5 | GLOBAL_FEED_ROUTE, 6 | USER_LOGIN_ROUTE, 7 | USER_PROFILE_ROUTE, 8 | USER_REGISTER_ROUTE, 9 | USER_SETTINGS_ROUTE, 10 | } from '~/common/constants'; 11 | import { replaceDynamicRoute } from '~/common/utils'; 12 | 13 | import type { ReactElement } from 'react'; 14 | import type { User } from '~/common/types'; 15 | 16 | type Props = { 17 | user: User | null; 18 | }; 19 | 20 | // TODO: Complete PlatformHeader component 21 | export const PlatformHeader = ({ user }: Props): ReactElement => ( 22 | 77 | ); 78 | -------------------------------------------------------------------------------- /src/components/common/LoadingSpinner/LoadingSpinner.module.css: -------------------------------------------------------------------------------- 1 | .loadingSpinner { 2 | border: 4px solid black; 3 | border-top: 4px solid transparent; 4 | border-radius: 50%; 5 | width: 20px; 6 | height: 20px; 7 | animation: spinner 1s linear infinite 8 | } 9 | 10 | @keyframes spinner { 11 | 0% { 12 | transform: rotate(0deg); 13 | } 14 | 100% { 15 | transform: rotate(360deg); 16 | } 17 | } -------------------------------------------------------------------------------- /src/components/common/LoadingSpinner/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import styles from './LoadingSpinner.module.css'; 2 | 3 | import type { ReactElement } from 'react'; 4 | 5 | // TODO: Complete LoadingSpinner common component 6 | export const LoadingSpinner = (): ReactElement =>
; 7 | -------------------------------------------------------------------------------- /src/components/home/ArticlePreview.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import Link from 'next/link'; 3 | 4 | import { USER_PROFILE_ROUTE } from '~/common/constants'; 5 | import { replaceDynamicRoute } from '~/common/utils'; 6 | 7 | import type { ReactElement } from 'react'; 8 | import type { Article } from '~/common/types'; 9 | 10 | type Props = { 11 | article: Article; 12 | }; 13 | 14 | // TODO: Complete ArticlePreview component 15 | export const ArticlePreview = ({ article }: Props): ReactElement => { 16 | const { author, slug, title, description, favoritesCount, createdAt, tagList } = article; 17 | 18 | return ( 19 |
20 |
21 | 22 | user avatar 23 | 24 |
25 | 29 | {author.username} 30 | 31 | {createdAt} 32 |
33 | 36 |
37 | {/* TODO: replace with Link tag */} 38 | 39 |

{title}

40 |

{description}

41 | Read more... 42 | {tagList.length && ( 43 |
    44 | {tagList.map((tag) => ( 45 |
  • 46 | {tag} 47 |
  • 48 | ))} 49 |
50 | )} 51 |
52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/home/ArticlesList.tsx: -------------------------------------------------------------------------------- 1 | import { ArticlePreview } from './ArticlePreview'; 2 | 3 | import type { ReactElement } from 'react'; 4 | import type { Article } from '~/common/types'; 5 | 6 | type Props = { 7 | articles: Article[]; 8 | }; 9 | 10 | export const ArticlesList = ({ articles }: Props): ReactElement => { 11 | if (articles.length === 0) { 12 | return
No articles are here... yet.
; 13 | } 14 | 15 | return ( 16 | <> 17 | {articles.map((article) => ( 18 | 19 | ))} 20 | {/* TODO: add articles pagination */} 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/home/Banner.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from 'react'; 2 | 3 | // TODO: Complete Banner component 4 | export const Banner = (): ReactElement => ( 5 |
6 |
7 |

conduit

8 |

A place to share your knowledge.

9 |
10 |
11 | ); 12 | -------------------------------------------------------------------------------- /src/components/home/FeedManager.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import { usePathname } from 'next/navigation'; 5 | import React from 'react'; 6 | 7 | import { GLOBAL_FEED_ROUTE, PERSONAL_FEED_ROUTE, TAG_FEED_ROUTE } from '~/common/constants'; 8 | import { replaceDynamicRoute } from '~/common/utils'; 9 | 10 | import { getTagFromFeedPath } from './utils'; 11 | 12 | import type { ReactElement } from 'react'; 13 | import type { User } from '~/common/types'; 14 | 15 | type Props = { 16 | user: User | null; 17 | }; 18 | // TODO: Complete FeedManager component 19 | export const FeedManager = ({ user }: Props): ReactElement => { 20 | const path = usePathname(); 21 | const tag = getTagFromFeedPath(path); 22 | 23 | return ( 24 |
25 |
    26 |
  • 27 | {/* TODO: refactoring of tab highlighting */} 28 | 32 | Global Feed 33 | 34 |
  • 35 | {user && ( 36 |
  • 37 | {/* TODO: refactoring of tab highlighting */} 38 | 42 | Your Feed 43 | 44 |
  • 45 | )} 46 | {tag && ( 47 |
  • 48 | {/* TODO: refactoring of tab highlighting */} 49 | 55 | # {tag} 56 | 57 |
  • 58 | )} 59 |
60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/home/LoadingState/LoadingState.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | height: 150px; 6 | width: 100%; 7 | } -------------------------------------------------------------------------------- /src/components/home/LoadingState/LoadingState.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from '~/components/common/LoadingSpinner/LoadingSpinner'; 2 | 3 | import styles from './LoadingState.module.css'; 4 | 5 | import type { ReactElement } from 'react'; 6 | 7 | export const LoadingState = (): ReactElement => ( 8 |
9 | 10 |
11 | ); 12 | -------------------------------------------------------------------------------- /src/components/home/TagsList.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import React from 'react'; 3 | 4 | import { TAG_FEED_ROUTE } from '~/common/constants'; 5 | import { replaceDynamicRoute } from '~/common/utils'; 6 | 7 | import type { ReactElement } from 'react'; 8 | 9 | type Props = { 10 | tags: string[]; 11 | }; 12 | 13 | // TODO: Complete TagsList component 14 | export const TagsList = ({ tags }: Props): ReactElement => ( 15 |
16 |

Popular Tags

17 |
18 | {tags.map((tag) => ( 19 | 24 | {tag} 25 | 26 | ))} 27 |
28 |
29 | ); 30 | -------------------------------------------------------------------------------- /src/components/home/utils.ts: -------------------------------------------------------------------------------- 1 | // TODO: Complete getTagFromFeedPath helper function 2 | export const getTagFromFeedPath = (path: string | null): string | null => { 3 | if (path === null) { 4 | return null; 5 | } 6 | 7 | const [, prefix, tag] = path.split('/'); 8 | 9 | if (prefix !== 'feed' || !tag) { 10 | return null; 11 | } 12 | 13 | if (tag === 'personal') { 14 | return null; 15 | } 16 | 17 | return tag; 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/user/EditProfileLink.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import { USER_SETTINGS_ROUTE } from '~/common/constants'; 4 | 5 | import type { ReactElement } from 'react'; 6 | 7 | // TODO: Complete EditProfileLink Component 8 | export const EditProfileLink = (): ReactElement => ( 9 | 10 | Edit Profile Settings 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/components/user/FollowUserButton.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from 'react'; 2 | import type { UserProfile } from '~/common/types'; 3 | 4 | type Props = { 5 | userProfile: UserProfile; 6 | }; 7 | 8 | // TODO: Complete FollowUserButton component 9 | export const FollowUserButton = ({ userProfile }: Props): ReactElement => ( 10 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/components/user/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Formik } from 'formik'; 3 | import Cookies from 'js-cookie'; 4 | import { useRouter } from 'next/navigation'; 5 | import { useTransition } from 'react'; 6 | 7 | import { INITIAL_USER_LOGIN_DATA } from './constants'; 8 | import { parseAuthErrors } from './utils'; 9 | 10 | import type { FormikHelpers } from 'formik'; 11 | import type { FormEvent, ReactElement } from 'react'; 12 | import type { UserLogin } from '~/common/types'; 13 | 14 | // TODO: Complete LoginForm component 15 | export const LoginForm = (): ReactElement => { 16 | const router = useRouter(); 17 | const [isPending, startTransition] = useTransition(); 18 | 19 | // TODO: reimplement with httpOnly Cookies 20 | const handleSubmitForm = async ( 21 | { email, password }: UserLogin, 22 | { setErrors }: FormikHelpers 23 | ): Promise => { 24 | // eslint-disable-next-line compat/compat 25 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/users/login`, { 26 | method: 'POST', 27 | body: JSON.stringify({ 28 | user: { email, password }, 29 | }), 30 | headers: { 31 | 'Content-Type': 'application/json', 32 | }, 33 | }); 34 | 35 | if (!response.ok) { 36 | const errorData = await response.json(); 37 | 38 | const parsedErrors = parseAuthErrors(errorData.errors); 39 | setErrors(parsedErrors); 40 | 41 | return; 42 | } 43 | 44 | const userData = await response.json(); 45 | 46 | const { 47 | user: { token }, 48 | } = userData; 49 | 50 | Cookies.set('auth', token, { 51 | expires: 1, 52 | sameSite: 'strict', 53 | secure: process.env.NODE_ENV === 'production', 54 | }); 55 | 56 | startTransition(() => { 57 | // Refresh the current route and fetch new data from the server without 58 | // losing client-side browser or React state. 59 | router.refresh(); 60 | }); 61 | }; 62 | 63 | // TODO: add client-side validation 64 | return ( 65 | 66 | {({ values, errors, handleChange, handleSubmit, isSubmitting }): ReactElement => ( 67 |
): void => event.preventDefault()} 70 | > 71 |
72 | 80 | {errors.email &&
{errors.email}
} 81 |
82 |
83 | 91 | {errors.password &&
{errors.password}
} 92 |
93 | 101 |
102 | )} 103 |
104 | ); 105 | }; 106 | -------------------------------------------------------------------------------- /src/components/user/ProfileFeedManager.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import { usePathname } from 'next/navigation'; 5 | import React from 'react'; 6 | 7 | import { USER_PROFILE_FAVORITES_ROUTE, USER_PROFILE_ROUTE } from '~/common/constants'; 8 | import { replaceDynamicRoute } from '~/common/utils'; 9 | 10 | import type { ReactElement } from 'react'; 11 | import type { UserProfile } from '~/common/types'; 12 | 13 | type Props = { 14 | isProfileBelongsToUser: boolean; 15 | userProfile: UserProfile; 16 | }; 17 | 18 | // TODO: Complete ProfileFeedManager component 19 | export const ProfileFeedManager = ({ 20 | isProfileBelongsToUser, 21 | userProfile: { username }, 22 | }: Props): ReactElement => { 23 | const path = usePathname(); 24 | 25 | const primaryTabLabel = isProfileBelongsToUser ? 'My Articles' : `${username}'s Articles`; 26 | 27 | return ( 28 |
29 |
    30 |
  • 31 | 37 | {primaryTabLabel} 38 | 39 |
  • 40 |
  • 41 | 48 | Favorited Articles 49 | 50 |
  • 51 |
52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/user/RegisterForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Formik } from 'formik'; 3 | import Cookies from 'js-cookie'; 4 | import { useRouter } from 'next/navigation'; 5 | import React, { useTransition } from 'react'; 6 | 7 | import { INITIAL_USER_REGISTER_DATA } from './constants'; 8 | import { parseAuthErrors } from './utils'; 9 | 10 | import type { FormikHelpers } from 'formik'; 11 | import type { FormEvent, ReactElement } from 'react'; 12 | import type { UserRegister } from '~/common/types'; 13 | 14 | // TODO: Complete RegisterForm component 15 | export const RegisterForm = (): ReactElement => { 16 | const router = useRouter(); 17 | const [isPending, startTransition] = useTransition(); 18 | 19 | // TODO: reimplement with httpOnly Cookies 20 | const handleSubmitForm = async ( 21 | { username, email, password }: UserRegister, 22 | { setErrors }: FormikHelpers 23 | ): Promise => { 24 | // eslint-disable-next-line compat/compat 25 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/users`, { 26 | method: 'POST', 27 | body: JSON.stringify({ 28 | user: { username, email, password }, 29 | }), 30 | headers: { 31 | 'Content-Type': 'application/json', 32 | }, 33 | }); 34 | 35 | if (!response.ok) { 36 | const errorData = await response.json(); 37 | 38 | const parsedErrors = parseAuthErrors(errorData.errors); 39 | setErrors(parsedErrors); 40 | 41 | return; 42 | } 43 | 44 | const userData = await response.json(); 45 | 46 | const { 47 | user: { token }, 48 | } = userData; 49 | 50 | Cookies.set('auth', token, { 51 | expires: 1, 52 | sameSite: 'strict', 53 | secure: process.env.NODE_ENV === 'production', 54 | }); 55 | 56 | startTransition(() => { 57 | // Refresh the current route and fetch new data from the server without 58 | // losing client-side browser or React state. 59 | router.refresh(); 60 | }); 61 | }; 62 | 63 | // TODO: add client-side validation 64 | return ( 65 | 66 | {({ values, errors, handleChange, handleSubmit, isSubmitting }): ReactElement => ( 67 |
): void => event.preventDefault()} 70 | > 71 |
72 | 80 | {errors.username &&
{errors.username}
} 81 |
82 |
83 | 91 | {errors.email &&
{errors.email}
} 92 |
93 |
94 | 102 | {errors.password &&
{errors.password}
} 103 |
104 | 112 |
113 | )} 114 |
115 | ); 116 | }; 117 | -------------------------------------------------------------------------------- /src/components/user/SettingsForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Formik } from 'formik'; 3 | import Cookies from 'js-cookie'; 4 | import { useRouter } from 'next/navigation'; 5 | import React, { useMemo, useTransition } from 'react'; 6 | 7 | import { mapUserDataToUserSettingsData } from './utils'; 8 | 9 | import type { ReactElement, FormEvent } from 'react'; 10 | import type { User, UserSettings } from '~/common/types'; 11 | 12 | type Props = { 13 | user: User; 14 | }; 15 | 16 | // TODO: Complete SettingsForm component 17 | export const SettingsForm = ({ user }: Props): ReactElement => { 18 | const router = useRouter(); 19 | const [isPending, startTransition] = useTransition(); 20 | 21 | const INITIAL_USER_SETTINGS_DATA = useMemo(() => mapUserDataToUserSettingsData(user), [user]); 22 | 23 | const handleSubmitForm = async ({ 24 | username, 25 | email, 26 | password, 27 | bio, 28 | image, 29 | }: UserSettings): Promise => { 30 | // TODO: handle API errors 31 | // eslint-disable-next-line compat/compat 32 | await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/user`, { 33 | method: 'PUT', 34 | body: JSON.stringify({ 35 | user: { username, email, password, bio, image }, 36 | }), 37 | headers: { 38 | 'Content-Type': 'application/json', 39 | authorization: `Bearer ${user.token}`, 40 | }, 41 | }); 42 | 43 | startTransition(() => { 44 | // Refresh the current route and fetch new data from the server without 45 | // losing client-side browser or React state. 46 | router.refresh(); 47 | }); 48 | }; 49 | 50 | const logoutUser = (): void => { 51 | Cookies.remove('auth'); 52 | 53 | startTransition(() => { 54 | // Refresh the current route and fetch new data from the server without 55 | // losing client-side browser or React state. 56 | router.refresh(); 57 | }); 58 | }; 59 | 60 | return ( 61 | <> 62 | 63 | {({ values, handleChange, handleSubmit, isSubmitting }): ReactElement => ( 64 |
): void => event.preventDefault()} 67 | > 68 |
69 |
70 | 78 |
79 |
80 | 88 |
89 |
90 | 98 |
99 |
100 | 108 |
109 |
110 | 118 |
119 | 127 |
128 |
129 | )} 130 |
131 |
132 | 135 | 136 | ); 137 | }; 138 | -------------------------------------------------------------------------------- /src/components/user/UserInfo.tsx: -------------------------------------------------------------------------------- 1 | import { EditProfileLink } from '~/components/user/EditProfileLink'; 2 | import { FollowUserButton } from '~/components/user/FollowUserButton'; 3 | 4 | import type { ReactElement } from 'react'; 5 | import type { UserProfile } from '~/common/types'; 6 | 7 | type Props = { 8 | isProfileBelongsToUser: boolean; 9 | userProfile: UserProfile; 10 | }; 11 | 12 | export const UserInfo = ({ isProfileBelongsToUser, userProfile }: Props): ReactElement => { 13 | const { username, image, bio } = userProfile; 14 | 15 | return ( 16 |
17 |
18 |
19 |
20 | {/* TODO: replace with Next.js image */} 21 | user profile image 22 |

{username}

23 | {bio &&

{bio}

} 24 | {isProfileBelongsToUser ? ( 25 | 26 | ) : ( 27 | 28 | )} 29 |
30 |
31 |
32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/user/constants.ts: -------------------------------------------------------------------------------- 1 | import type { UserLogin, UserRegister } from '~/common/types'; 2 | 3 | export const INITIAL_USER_LOGIN_DATA: UserLogin = { 4 | email: '', 5 | password: '', 6 | }; 7 | 8 | export const INITIAL_USER_REGISTER_DATA: UserRegister = { 9 | email: '', 10 | password: '', 11 | username: '', 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/user/utils.ts: -------------------------------------------------------------------------------- 1 | // TODO: solve TypeScript issues for parseAuthErrors helper function 2 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 3 | // @ts-nocheck 4 | import type { FormikErrors } from 'formik'; 5 | import type { User, UserSettings, UserProfile } from '~/common/types'; 6 | 7 | // TODO: make refactoring of error parsing 8 | export const parseAuthErrors = (errors: { [key: string]: string[] }): FormikErrors => { 9 | const parsedErrors: FormikErrors = {}; 10 | 11 | if (errors.username) { 12 | parsedErrors.username = `username ${errors.username[0]}`; 13 | } 14 | 15 | if (errors.email) { 16 | parsedErrors.email = `email ${errors.email[0]}`; 17 | } 18 | 19 | if (errors.password) { 20 | parsedErrors.password = `password ${errors.password[0]}`; 21 | } 22 | 23 | if (errors['email or password']) { 24 | parsedErrors.email = `email or password ${errors['email or password']}`; 25 | } 26 | 27 | return parsedErrors; 28 | }; 29 | 30 | export const mapUserDataToUserSettingsData = (user: User): UserSettings => ({ 31 | email: user.email, 32 | password: '', 33 | username: user.username, 34 | bio: user.bio || '', 35 | image: user.image, 36 | }); 37 | 38 | export const checkProfileBelongsToUser = ( 39 | userProfile: UserProfile, 40 | currentUser: User | null 41 | ): boolean => { 42 | if (!currentUser) { 43 | return false; 44 | } 45 | 46 | return currentUser.username === userProfile.username; 47 | }; 48 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "paths": { 11 | "~/*": ["./src/*"] 12 | }, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ] 25 | }, 26 | "include": ["*.js", ".*.js", "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | --------------------------------------------------------------------------------