├── .env.example
├── .eslintrc.js
├── .github
├── FUNDING.yml
├── issue-branch.yml
└── workflows
│ ├── create-branch.yml
│ ├── issue-autolink.yml
│ ├── lint.yml
│ └── release-please.yml
├── .gitignore
├── .husky
├── commit-msg
├── post-merge
└── pre-commit
├── .npmrc
├── .prettierignore
├── .prettierrc.js
├── .vscode
├── css.code-snippets
├── extensions.json
├── settings.json
└── typescriptreact.code-snippets
├── README.md
├── commitlint.config.js
├── jest.config.js
├── jest.setup.js
├── next-env.d.ts
├── next-sitemap.config.js
├── next.config.js
├── package.json
├── postcss.config.js
├── public
├── favicon.ico
├── favicon
│ ├── android-icon-144x144.png
│ ├── android-icon-192x192.png
│ ├── android-icon-36x36.png
│ ├── android-icon-48x48.png
│ ├── android-icon-72x72.png
│ ├── android-icon-96x96.png
│ ├── apple-icon-114x114.png
│ ├── apple-icon-120x120.png
│ ├── apple-icon-144x144.png
│ ├── apple-icon-152x152.png
│ ├── apple-icon-180x180.png
│ ├── apple-icon-57x57.png
│ ├── apple-icon-60x60.png
│ ├── apple-icon-72x72.png
│ ├── apple-icon-76x76.png
│ ├── apple-icon-precomposed.png
│ ├── apple-icon.png
│ ├── browserconfig.xml
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon-96x96.png
│ ├── large-og.jpg
│ ├── manifest.json
│ ├── ms-icon-144x144.png
│ ├── ms-icon-150x150.png
│ ├── ms-icon-310x310.png
│ └── ms-icon-70x70.png
├── fonts
│ └── inter-var-latin.woff2
├── images
│ ├── large-og.png
│ └── new-tab.png
└── svg
│ └── Vercel.svg
├── src
├── components
│ ├── DismissableToast.tsx
│ ├── Logo.tsx
│ ├── NextImage.tsx
│ ├── Seo.tsx
│ ├── Skeleton.tsx
│ ├── buttons
│ │ └── Button.tsx
│ ├── content
│ │ ├── StyledCode.tsx
│ │ ├── StyledJSON.tsx
│ │ └── TryAlert.tsx
│ ├── forms
│ │ ├── DatePicker.tsx
│ │ ├── DropzoneInput.tsx
│ │ ├── FilePreview.tsx
│ │ ├── Input.tsx
│ │ ├── PasswordInput.tsx
│ │ ├── SelectInput.tsx
│ │ └── TextArea.tsx
│ ├── hoc
│ │ └── withAuth.tsx
│ ├── layout
│ │ ├── DashboardLayout.tsx
│ │ ├── Footer.tsx
│ │ ├── Header.tsx
│ │ ├── Layout.tsx
│ │ ├── NavigationList.tsx
│ │ └── Sidebar.tsx
│ └── links
│ │ ├── ArrowLink.tsx
│ │ ├── BackToHome.tsx
│ │ ├── ButtonLink.tsx
│ │ ├── PrimaryLink.tsx
│ │ ├── UnderlineLink.tsx
│ │ └── UnstyledLink.tsx
├── constant
│ ├── env.ts
│ ├── navigation.ts
│ └── toast.ts
├── hooks
│ └── toast
│ │ ├── useLoadingToast.tsx
│ │ └── useRQWithToast.tsx
├── lib
│ ├── __tests__
│ │ └── helper.test.ts
│ ├── axios-mock.ts
│ ├── axios.ts
│ ├── clsxm.ts
│ ├── helper.ts
│ └── logger.ts
├── pages
│ ├── 404.tsx
│ ├── _app.tsx
│ ├── _document.tsx
│ ├── api
│ │ ├── hello.ts
│ │ └── mock
│ │ │ ├── login.ts
│ │ │ └── me.ts
│ ├── auth.tsx
│ ├── dashboard.tsx
│ ├── index.tsx
│ ├── login.tsx
│ ├── optional.tsx
│ ├── protected.tsx
│ └── sandbox
│ │ ├── auth-rq.tsx
│ │ ├── auth.tsx
│ │ ├── rhf.tsx
│ │ └── toast-rq.tsx
├── store
│ └── useAuthStore.tsx
├── styles
│ └── globals.css
└── types
│ ├── api.ts
│ ├── auth.ts
│ └── dropzone.ts
├── tailwind.config.js
├── tsconfig.json
├── vercel.json
└── yarn.lock
/.env.example:
--------------------------------------------------------------------------------
1 |
2 | # DEVELOPMENT TOOLS
3 | # Ideally, don't add them to production deployment envs
4 | NEXT_PUBLIC_SHOW_LOGGER="false"
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | node: true,
6 | },
7 | plugins: ['@typescript-eslint', 'simple-import-sort', 'unused-imports'],
8 | extends: [
9 | 'eslint:recommended',
10 | 'next',
11 | 'next/core-web-vitals',
12 | 'plugin:@typescript-eslint/recommended',
13 | 'prettier',
14 | ],
15 | rules: {
16 | 'no-unused-vars': 'off',
17 | 'no-console': 'warn',
18 | '@typescript-eslint/explicit-module-boundary-types': 'off',
19 | 'react/no-unescaped-entities': 'off',
20 |
21 | 'react/display-name': 'off',
22 | 'react/jsx-curly-brace-presence': [
23 | 'warn',
24 | { props: 'never', children: 'never' },
25 | ],
26 |
27 | //#region //*=========== Unused Import ===========
28 | '@typescript-eslint/no-unused-vars': 'off',
29 | 'unused-imports/no-unused-imports': 'warn',
30 | 'unused-imports/no-unused-vars': [
31 | 'warn',
32 | {
33 | vars: 'all',
34 | varsIgnorePattern: '^_',
35 | args: 'after-used',
36 | argsIgnorePattern: '^_',
37 | },
38 | ],
39 | //#endregion //*======== Unused Import ===========
40 |
41 | //#region //*=========== Import Sort ===========
42 | 'simple-import-sort/exports': 'warn',
43 | 'simple-import-sort/imports': [
44 | 'warn',
45 | {
46 | groups: [
47 | // ext library & side effect imports
48 | ['^@?\\w', '^\\u0000'],
49 | // {s}css files
50 | ['^.+\\.s?css$'],
51 | // Lib and hooks
52 | ['^@/lib', '^@/hooks'],
53 | // static data
54 | ['^@/data'],
55 | // components
56 | ['^@/components', '^@/container'],
57 | // zustand store
58 | ['^@/store'],
59 | // Other imports
60 | ['^@/'],
61 | // relative paths up until 3 level
62 | [
63 | '^\\./?$',
64 | '^\\.(?!/?$)',
65 | '^\\.\\./?$',
66 | '^\\.\\.(?!/?$)',
67 | '^\\.\\./\\.\\./?$',
68 | '^\\.\\./\\.\\.(?!/?$)',
69 | '^\\.\\./\\.\\./\\.\\./?$',
70 | '^\\.\\./\\.\\./\\.\\.(?!/?$)',
71 | ],
72 | ['^@/types'],
73 | // other that didnt fit in
74 | ['^'],
75 | ],
76 | },
77 | ],
78 | //#endregion //*======== Import Sort ===========
79 | },
80 | globals: {
81 | React: true,
82 | JSX: true,
83 | },
84 | };
85 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: theodorusclarence
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
14 |
--------------------------------------------------------------------------------
/.github/issue-branch.yml:
--------------------------------------------------------------------------------
1 | # https://github.com/robvanderleek/create-issue-branch#option-2-configure-github-action
2 |
3 | # ex: i4-lower_camel_upper
4 | branchName: 'i${issue.number}-${issue.title,}'
5 | branches:
6 | - label: epic
7 | skip: true
8 | - label: debt
9 | skip: true
10 |
--------------------------------------------------------------------------------
/.github/workflows/create-branch.yml:
--------------------------------------------------------------------------------
1 | name: Create Branch from Issue
2 |
3 | on:
4 | issues:
5 | types: [assigned]
6 |
7 | jobs:
8 | create_issue_branch_job:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Create Issue Branch
12 | uses: robvanderleek/create-issue-branch@main
13 | env:
14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
15 |
--------------------------------------------------------------------------------
/.github/workflows/issue-autolink.yml:
--------------------------------------------------------------------------------
1 | name: 'Issue Autolink'
2 | on:
3 | pull_request:
4 | types: [opened]
5 |
6 | jobs:
7 | issue-links:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: tkt-actions/add-issue-links@v1.6.0
11 | with:
12 | repo-token: '${{ secrets.GITHUB_TOKEN }}'
13 | branch-prefix: 'i'
14 | resolve: 'true'
15 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | # https://github.com/kentcdodds/kentcdodds.com/blob/main/.github/workflows/deployment.yml
2 | name: Code Check
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request: {}
8 |
9 | jobs:
10 | lint:
11 | name: ⬣ ESLint
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: 🛑 Cancel Previous Runs
15 | uses: styfle/cancel-workflow-action@0.9.1
16 |
17 | - name: ⬇️ Checkout repo
18 | uses: actions/checkout@v2
19 |
20 | - name: ⎔ Setup node
21 | uses: actions/setup-node@v2
22 | with:
23 | node-version: 16
24 |
25 | - name: 📥 Download deps
26 | uses: bahmutov/npm-install@v1
27 |
28 | - name: 🔬 Lint
29 | run: yarn lint:strict
30 |
31 | typecheck:
32 | name: ʦ TypeScript
33 | runs-on: ubuntu-latest
34 | steps:
35 | - name: 🛑 Cancel Previous Runs
36 | uses: styfle/cancel-workflow-action@0.9.1
37 |
38 | - name: ⬇️ Checkout repo
39 | uses: actions/checkout@v2
40 |
41 | - name: ⎔ Setup node
42 | uses: actions/setup-node@v2
43 | with:
44 | node-version: 16
45 |
46 | - name: 📥 Download deps
47 | uses: bahmutov/npm-install@v1
48 |
49 | - name: 🔎 Type check
50 | run: yarn typecheck
51 |
52 | prettier:
53 | name: 💅 Prettier
54 | runs-on: ubuntu-latest
55 | steps:
56 | - name: 🛑 Cancel Previous Runs
57 | uses: styfle/cancel-workflow-action@0.9.1
58 |
59 | - name: ⬇️ Checkout repo
60 | uses: actions/checkout@v2
61 |
62 | - name: ⎔ Setup node
63 | uses: actions/setup-node@v2
64 | with:
65 | node-version: 16
66 |
67 | - name: 📥 Download deps
68 | uses: bahmutov/npm-install@v1
69 |
70 | - name: 🔎 Type check
71 | run: yarn format:check
72 |
73 | test:
74 | name: 🃏 Test
75 | runs-on: ubuntu-latest
76 | steps:
77 | - name: 🛑 Cancel Previous Runs
78 | uses: styfle/cancel-workflow-action@0.9.1
79 |
80 | - name: ⬇️ Checkout repo
81 | uses: actions/checkout@v2
82 |
83 | - name: ⎔ Setup node
84 | uses: actions/setup-node@v2
85 | with:
86 | node-version: 16
87 |
88 | - name: 📥 Download deps
89 | uses: bahmutov/npm-install@v1
90 |
91 | - name: 🃏 Run jest
92 | run: yarn test
93 |
--------------------------------------------------------------------------------
/.github/workflows/release-please.yml:
--------------------------------------------------------------------------------
1 | name: release-please
2 | on:
3 | # !Option 1: Manual Trigger from GitHub
4 | # workflow_dispatch:
5 | # !Option 2: Release on every push on main branch
6 | push:
7 | branches:
8 | - main
9 | jobs:
10 | release-please:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: google-github-actions/release-please-action@v3
14 | with:
15 | release-type: node
16 | package-name: release-please-action
17 |
--------------------------------------------------------------------------------
/.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 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
36 | # next-sitemap
37 | robots.txt
38 | sitemap.xml
39 | sitemap-*.xml
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx --no-install commitlint --edit "$1"
5 |
--------------------------------------------------------------------------------
/.husky/post-merge:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | yarn
5 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx lint-staged
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | v16.14.0
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
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 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env.local
30 | .env.development.local
31 | .env.test.local
32 | .env.production.local
33 |
34 | # vercel
35 | .vercel
36 |
37 | # changelog
38 | CHANGELOG.md
39 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | arrowParens: 'always',
3 | singleQuote: true,
4 | jsxSingleQuote: true,
5 | tabWidth: 2,
6 | semi: true,
7 | };
8 |
--------------------------------------------------------------------------------
/.vscode/css.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | "Region CSS": {
3 | "prefix": "regc",
4 | "body": [
5 | "/* #region /**=========== ${1} =========== */",
6 | "$0",
7 | "/* #endregion /**======== ${1} =========== */"
8 | ]
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | // Tailwind CSS Intellisense
4 | "bradlc.vscode-tailwindcss",
5 | "esbenp.prettier-vscode",
6 | "dbaeumer.vscode-eslint",
7 | "aaron-bond.better-comments"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "css.validate": false,
3 | "editor.formatOnSave": true,
4 | "editor.tabSize": 2,
5 | "editor.codeActionsOnSave": {
6 | "source.fixAll": true
7 | },
8 | "headwind.runOnSave": false,
9 | "typescript.preferences.importModuleSpecifier": "non-relative"
10 | }
11 |
--------------------------------------------------------------------------------
/.vscode/typescriptreact.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | //#region //*=========== React ===========
3 | "import React": {
4 | "prefix": "ir",
5 | "body": ["import * as React from 'react';"]
6 | },
7 | "React.useState": {
8 | "prefix": "us",
9 | "body": [
10 | "const [${1}, set${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}] = React.useState<$3>(${2:initial${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}})$0"
11 | ]
12 | },
13 | "React.useEffect": {
14 | "prefix": "uf",
15 | "body": ["React.useEffect(() => {", " $0", "}, []);"]
16 | },
17 | "React.useReducer": {
18 | "prefix": "ur",
19 | "body": [
20 | "const [state, dispatch] = React.useReducer(${0:someReducer}, {",
21 | " ",
22 | "})"
23 | ]
24 | },
25 | "React.useRef": {
26 | "prefix": "urf",
27 | "body": ["const ${1:someRef} = React.useRef($0)"]
28 | },
29 | "React Functional Component": {
30 | "prefix": "rc",
31 | "body": [
32 | "import * as React from 'react';\n",
33 | "export default function ${1:${TM_FILENAME_BASE}}() {",
34 | " return (",
35 | "
",
36 | " $0",
37 | "
",
38 | " )",
39 | "}"
40 | ]
41 | },
42 | "React Functional Component with Props": {
43 | "prefix": "rcp",
44 | "body": [
45 | "import * as React from 'react';\n",
46 | "import clsxm from '@/lib/clsxm';\n",
47 | "type ${1:${TM_FILENAME_BASE}}Props= {\n",
48 | "} & React.ComponentPropsWithoutRef<'div'>\n",
49 | "export default function ${1:${TM_FILENAME_BASE}}({className, ...rest}: ${1:${TM_FILENAME_BASE}}Props) {",
50 | " return (",
51 | " ",
52 | " $0",
53 | "
",
54 | " )",
55 | "}"
56 | ]
57 | },
58 | //#endregion //*======== React ===========
59 |
60 | //#region //*=========== Commons ===========
61 | "Region": {
62 | "prefix": "reg",
63 | "scope": "javascript, typescript, javascriptreact, typescriptreact",
64 | "body": [
65 | "//#region //*=========== ${1} ===========",
66 | "${TM_SELECTED_TEXT}$0",
67 | "//#endregion //*======== ${1} ==========="
68 | ]
69 | },
70 | "Region CSS": {
71 | "prefix": "regc",
72 | "scope": "css, scss",
73 | "body": [
74 | "/* #region /**=========== ${1} =========== */",
75 | "${TM_SELECTED_TEXT}$0",
76 | "/* #endregion /**======== ${1} =========== */"
77 | ]
78 | },
79 | //#endregion //*======== Commons ===========
80 |
81 | //#region //*=========== Nextjs ===========
82 | "Next Pages": {
83 | "prefix": "np",
84 | "body": [
85 | "import * as React from 'react';\n",
86 | "import Layout from '@/components/layout/Layout';",
87 | "import Seo from '@/components/Seo';\n",
88 | "export default function ${1:${TM_FILENAME_BASE/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}}Page() {",
89 | " return (",
90 | " ",
91 | " \n",
92 | " \n",
93 | " ",
94 | " ",
95 | " $0",
96 | "
",
97 | " ",
98 | " ",
99 | " ",
100 | " )",
101 | "}"
102 | ]
103 | },
104 | "Next API": {
105 | "prefix": "napi",
106 | "body": [
107 | "import { NextApiRequest, NextApiResponse } from 'next';\n",
108 | "export default async function handler(req: NextApiRequest, res: NextApiResponse) {",
109 | " if (req.method === 'GET') {",
110 | " res.status(200).json({ name: 'Bambang' });",
111 | " } else {",
112 | " res.status(405).json({ message: 'Method Not Allowed' });",
113 | " }",
114 | "}"
115 | ]
116 | },
117 | "Get Static Props": {
118 | "prefix": "gsp",
119 | "body": [
120 | "export const getStaticProps = async (context: GetStaticPropsContext) => {",
121 | " return {",
122 | " props: {}",
123 | " };",
124 | "}"
125 | ]
126 | },
127 | "Get Static Paths": {
128 | "prefix": "gspa",
129 | "body": [
130 | "export const getStaticPaths: GetStaticPaths = async () => {",
131 | " return {",
132 | " paths: [",
133 | " { params: { $1 }}",
134 | " ],",
135 | " fallback: ",
136 | " };",
137 | "}"
138 | ]
139 | },
140 | "Get Server Side Props": {
141 | "prefix": "gssp",
142 | "body": [
143 | "export const getServerSideProps = async (context: GetServerSidePropsContext) => {",
144 | " return {",
145 | " props: {}",
146 | " };",
147 | "}"
148 | ]
149 | },
150 | "Infer Get Static Props": {
151 | "prefix": "igsp",
152 | "body": "InferGetStaticPropsType"
153 | },
154 | "Infer Get Server Side Props": {
155 | "prefix": "igssp",
156 | "body": "InferGetServerSidePropsType"
157 | },
158 | "Import useRouter": {
159 | "prefix": "imust",
160 | "body": ["import { useRouter } from 'next/router';"]
161 | },
162 | "Import Next Image": {
163 | "prefix": "imimg",
164 | "body": ["import Image from 'next/image';"]
165 | },
166 | "Import Next Link": {
167 | "prefix": "iml",
168 | "body": ["import Link from 'next/link';"]
169 | },
170 | //#endregion //*======== Nextjs ===========
171 |
172 | //#region //*=========== Snippet Wrap ===========
173 | "Wrap with Fragment": {
174 | "prefix": "ff",
175 | "body": ["<>", "\t${TM_SELECTED_TEXT}", ">"]
176 | },
177 | "Wrap with clsx": {
178 | "prefix": "cx",
179 | "body": ["{clsx(${TM_SELECTED_TEXT}$0)}"]
180 | },
181 | "Wrap with clsxm": {
182 | "prefix": "cxm",
183 | "body": ["{clsxm(${TM_SELECTED_TEXT}$0, className)}"]
184 | },
185 | //#endregion //*======== Snippet Wrap ===========
186 |
187 | "Logger": {
188 | "prefix": "lg",
189 | "body": [
190 | "logger({ ${1:${CLIPBOARD}} }, '${TM_FILENAME} line ${TM_LINE_NUMBER}')"
191 | ]
192 | }
193 | }
194 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
Next.js Authentication HOC Pattern
3 |
Authentication pattern example using HOC, Zustand, and React Query
4 |
Made by Theodorus Clarence & Rizqi Tsani
5 |
6 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['@commitlint/config-conventional'],
3 | rules: {
4 | // TODO Add Scope Enum Here
5 | // 'scope-enum': [2, 'always', ['yourscope', 'yourscope']],
6 | 'type-enum': [
7 | 2,
8 | 'always',
9 | [
10 | 'feat',
11 | 'fix',
12 | 'docs',
13 | 'chore',
14 | 'style',
15 | 'refactor',
16 | 'ci',
17 | 'test',
18 | 'perf',
19 | 'revert',
20 | 'vercel',
21 | ],
22 | ],
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 | const nextJest = require('next/jest');
3 |
4 | const createJestConfig = nextJest({
5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
6 | dir: './',
7 | });
8 |
9 | // Add any custom config to be passed to Jest
10 | const customJestConfig = {
11 | // Add more setup options before each test is run
12 | setupFilesAfterEnv: ['/jest.setup.js'],
13 |
14 | // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
15 | moduleDirectories: ['node_modules', '/'],
16 |
17 | testEnvironment: 'jest-environment-jsdom',
18 |
19 | /**
20 | * Absolute imports and Module Path Aliases
21 | */
22 | moduleNameMapper: {
23 | '^@/(.*)$': '/src/$1',
24 | '^~/(.*)$': '/public/$1',
25 | },
26 | };
27 |
28 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
29 | module.exports = createJestConfig(customJestConfig);
30 |
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom/extend-expect';
2 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next-sitemap.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('next-sitemap').IConfig}
3 | * @see https://github.com/iamvishnusankar/next-sitemap#readme
4 | */
5 | module.exports = {
6 | /** Without additional '/' on the end, e.g. https://theodorusclarence.com */
7 | siteUrl: 'https://auth-hoc.thcl.dev',
8 | generateRobotsTxt: true,
9 | robotsTxtOptions: {
10 | policies: [{ userAgent: '*', allow: '/' }],
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | module.exports = {
3 | eslint: {
4 | dirs: ['src'],
5 | },
6 |
7 | reactStrictMode: true,
8 |
9 | // Uncoment to add domain whitelist
10 | images: {
11 | domains: ['images.unsplash.com'],
12 | },
13 |
14 | // SVGR
15 | webpack(config) {
16 | config.module.rules.push({
17 | test: /\.svg$/i,
18 | issuer: /\.[jt]sx?$/,
19 | use: [
20 | {
21 | loader: '@svgr/webpack',
22 | options: {
23 | typescript: true,
24 | icon: true,
25 | },
26 | },
27 | ],
28 | });
29 |
30 | return config;
31 | },
32 | };
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ts-nextjs-tailwind-starter",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "lint:fix": "eslint src --fix && yarn format",
11 | "lint:strict": "eslint --max-warnings=0 src",
12 | "typecheck": "tsc --noEmit --incremental false",
13 | "test:watch": "jest --watch",
14 | "test": "jest",
15 | "format": "prettier -w .",
16 | "format:check": "prettier -c .",
17 | "postbuild": "next-sitemap --config next-sitemap.config.js",
18 | "prepare": "husky install"
19 | },
20 | "dependencies": {
21 | "@headlessui/react": "^1.7.2",
22 | "@tanstack/react-query": "^4.3.4",
23 | "@tanstack/react-query-devtools": "^4.3.5",
24 | "auto-zustand-selectors-hook": "^2.0.0",
25 | "axios": "^0.27.2",
26 | "clsx": "^1.2.1",
27 | "immer": "^9.0.15",
28 | "next": "^12.2.5",
29 | "react": "^18.2.0",
30 | "react-datepicker": "^4.8.0",
31 | "react-dom": "^18.2.0",
32 | "react-dropzone": "^14.2.2",
33 | "react-highlight": "^0.14.0",
34 | "react-hook-form": "^7.34.2",
35 | "react-hot-toast": "^2.3.0",
36 | "react-icons": "^4.4.0",
37 | "react-image-lightbox": "^5.1.4",
38 | "tailwind-merge": "^1.6.0",
39 | "zustand": "^4.1.1"
40 | },
41 | "devDependencies": {
42 | "@commitlint/cli": "^16.3.0",
43 | "@commitlint/config-conventional": "^16.2.4",
44 | "@svgr/webpack": "^6.3.1",
45 | "@tailwindcss/forms": "^0.5.3",
46 | "@tailwindcss/typography": "^0.5.7",
47 | "@testing-library/jest-dom": "^5.16.5",
48 | "@testing-library/react": "^13.3.0",
49 | "@types/react": "^18.0.18",
50 | "@types/react-datepicker": "^4.4.2",
51 | "@types/react-highlight": "^0.12.5",
52 | "@typescript-eslint/eslint-plugin": "^5.36.1",
53 | "@typescript-eslint/parser": "^5.36.1",
54 | "autoprefixer": "^10.4.8",
55 | "eslint": "^8.23.0",
56 | "eslint-config-next": "^12.2.5",
57 | "eslint-config-prettier": "^8.5.0",
58 | "eslint-plugin-simple-import-sort": "^7.0.0",
59 | "eslint-plugin-unused-imports": "^2.0.0",
60 | "husky": "^7.0.4",
61 | "jest": "^27.5.1",
62 | "lint-staged": "^12.5.0",
63 | "next-sitemap": "^2.5.28",
64 | "postcss": "^8.4.16",
65 | "prettier": "^2.7.1",
66 | "prettier-plugin-tailwindcss": "^0.1.13",
67 | "tailwindcss": "^3.1.8",
68 | "typescript": "^4.8.2"
69 | },
70 | "lint-staged": {
71 | "src/**/*.{js,jsx,ts,tsx}": [
72 | "eslint --max-warnings=0",
73 | "prettier -w"
74 | ],
75 | "src/**/*.{json,css,scss,md}": [
76 | "prettier -w"
77 | ]
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon/android-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/android-icon-144x144.png
--------------------------------------------------------------------------------
/public/favicon/android-icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/android-icon-192x192.png
--------------------------------------------------------------------------------
/public/favicon/android-icon-36x36.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/android-icon-36x36.png
--------------------------------------------------------------------------------
/public/favicon/android-icon-48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/android-icon-48x48.png
--------------------------------------------------------------------------------
/public/favicon/android-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/android-icon-72x72.png
--------------------------------------------------------------------------------
/public/favicon/android-icon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/android-icon-96x96.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-114x114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/apple-icon-114x114.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/apple-icon-120x120.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/apple-icon-144x144.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/apple-icon-152x152.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/apple-icon-180x180.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-57x57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/apple-icon-57x57.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/apple-icon-60x60.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/apple-icon-72x72.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/apple-icon-76x76.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/apple-icon-precomposed.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/apple-icon.png
--------------------------------------------------------------------------------
/public/favicon/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 | #ffffff
--------------------------------------------------------------------------------
/public/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/favicon-96x96.png
--------------------------------------------------------------------------------
/public/favicon/large-og.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/large-og.jpg
--------------------------------------------------------------------------------
/public/favicon/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "App",
3 | "icons": [
4 | {
5 | "src": "/android-icon-36x36.png",
6 | "sizes": "36x36",
7 | "type": "image/png",
8 | "density": "0.75"
9 | },
10 | {
11 | "src": "/android-icon-48x48.png",
12 | "sizes": "48x48",
13 | "type": "image/png",
14 | "density": "1.0"
15 | },
16 | {
17 | "src": "/android-icon-72x72.png",
18 | "sizes": "72x72",
19 | "type": "image/png",
20 | "density": "1.5"
21 | },
22 | {
23 | "src": "/android-icon-96x96.png",
24 | "sizes": "96x96",
25 | "type": "image/png",
26 | "density": "2.0"
27 | },
28 | {
29 | "src": "/android-icon-144x144.png",
30 | "sizes": "144x144",
31 | "type": "image/png",
32 | "density": "3.0"
33 | },
34 | {
35 | "src": "/android-icon-192x192.png",
36 | "sizes": "192x192",
37 | "type": "image/png",
38 | "density": "4.0"
39 | }
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/public/favicon/ms-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/ms-icon-144x144.png
--------------------------------------------------------------------------------
/public/favicon/ms-icon-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/ms-icon-150x150.png
--------------------------------------------------------------------------------
/public/favicon/ms-icon-310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/ms-icon-310x310.png
--------------------------------------------------------------------------------
/public/favicon/ms-icon-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/favicon/ms-icon-70x70.png
--------------------------------------------------------------------------------
/public/fonts/inter-var-latin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/fonts/inter-var-latin.woff2
--------------------------------------------------------------------------------
/public/images/large-og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/images/large-og.png
--------------------------------------------------------------------------------
/public/images/new-tab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/nextjs-with-auth-hoc/af85e0a0fb8ce83ae3522074fd6009bdc9cfadb1/public/images/new-tab.png
--------------------------------------------------------------------------------
/public/svg/Vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/DismissableToast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { toast, ToastBar, Toaster } from 'react-hot-toast';
3 | import { HiX } from 'react-icons/hi';
4 |
5 | export default function DismissableToast() {
6 | return (
7 |
8 |
19 | {(t) => (
20 |
21 | {({ icon, message }) => (
22 | <>
23 | {icon}
24 | {message}
25 | {t.type !== 'loading' && (
26 |
32 | )}
33 | >
34 | )}
35 |
36 | )}
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/Logo.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import clsxm from '@/lib/clsxm';
4 |
5 | import Vercel from '~/svg/Vercel.svg';
6 |
7 | type LogoProps = React.ComponentPropsWithoutRef<'div'>;
8 |
9 | export default function Logo({ className, ...rest }: LogoProps) {
10 | return (
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/NextImage.tsx:
--------------------------------------------------------------------------------
1 | import Image, { ImageProps } from 'next/image';
2 | import * as React from 'react';
3 |
4 | import clsxm from '@/lib/clsxm';
5 |
6 | type NextImageProps = {
7 | useSkeleton?: boolean;
8 | imgClassName?: string;
9 | blurClassName?: string;
10 | alt: string;
11 | } & (
12 | | { width: string | number; height: string | number }
13 | | { layout: 'fill'; width?: string | number; height?: string | number }
14 | ) &
15 | ImageProps;
16 |
17 | /**
18 | *
19 | * @description Must set width using `w-` className
20 | * @param useSkeleton add background with pulse animation, don't use it if image is transparent
21 | */
22 | export default function NextImage({
23 | useSkeleton = false,
24 | src,
25 | width,
26 | height,
27 | alt,
28 | className,
29 | imgClassName,
30 | blurClassName,
31 | ...rest
32 | }: NextImageProps) {
33 | const [status, setStatus] = React.useState(
34 | useSkeleton ? 'loading' : 'complete'
35 | );
36 | const widthIsSet = className?.includes('w-') ?? false;
37 |
38 | return (
39 |
43 | setStatus('complete')}
53 | layout='responsive'
54 | {...rest}
55 | />
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/src/components/Seo.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import { useRouter } from 'next/router';
3 |
4 | import { openGraph } from '@/lib/helper';
5 |
6 | const defaultMeta = {
7 | title: 'Next.js Authentication HOC Pattern',
8 | siteName: 'Next.js Authentication HOC Pattern',
9 | description:
10 | 'Authentication pattern example using Higher Order Component, Zustand, and React Query',
11 | /** Without additional '/' on the end, e.g. https://theodorusclarence.com */
12 | url: 'https://auth-hoc.thcl.dev',
13 | type: 'website',
14 | robots: 'follow, index',
15 | /**
16 | * No need to be filled, will be populated with openGraph function
17 | * If you wish to use a normal image, just specify the path below
18 | */
19 | image: 'https://tsnext-tw.thcl.dev/images/large-og.png',
20 | };
21 |
22 | type SeoProps = {
23 | date?: string;
24 | templateTitle?: string;
25 | } & Partial;
26 |
27 | export default function Seo(props: SeoProps) {
28 | const router = useRouter();
29 | const meta = {
30 | ...defaultMeta,
31 | ...props,
32 | };
33 | meta['title'] = props.templateTitle
34 | ? `${props.templateTitle} | ${meta.siteName}`
35 | : meta.title;
36 |
37 | // Use siteName if there is templateTitle
38 | // but show full title if there is none
39 | // ? Uncomment code below if you want to use default open graph
40 | meta['image'] = openGraph({
41 | description: meta.description,
42 | siteName: props.templateTitle ? meta.siteName : meta.title,
43 | templateTitle: props.templateTitle,
44 | });
45 |
46 | return (
47 |
48 | {meta.title}
49 |
50 |
51 |
52 |
53 | {/* Open Graph */}
54 |
55 |
56 |
57 |
58 |
59 | {/* Twitter */}
60 |
61 |
62 |
63 |
64 |
65 | {meta.date && (
66 | <>
67 |
68 |
73 |
78 | >
79 | )}
80 |
81 | {/* Favicons */}
82 | {favicons.map((linkProps) => (
83 |
84 | ))}
85 |
86 |
90 |
91 |
92 | );
93 | }
94 |
95 | type Favicons = {
96 | rel: string;
97 | href: string;
98 | sizes?: string;
99 | type?: string;
100 | };
101 |
102 | const favicons: Array = [
103 | {
104 | rel: 'apple-touch-icon',
105 | sizes: '57x57',
106 | href: '/favicon/apple-icon-57x57.png',
107 | },
108 | {
109 | rel: 'apple-touch-icon',
110 | sizes: '60x60',
111 | href: '/favicon/apple-icon-60x60.png',
112 | },
113 | {
114 | rel: 'apple-touch-icon',
115 | sizes: '72x72',
116 | href: '/favicon/apple-icon-72x72.png',
117 | },
118 | {
119 | rel: 'apple-touch-icon',
120 | sizes: '76x76',
121 | href: '/favicon/apple-icon-76x76.png',
122 | },
123 | {
124 | rel: 'apple-touch-icon',
125 | sizes: '114x114',
126 | href: '/favicon/apple-icon-114x114.png',
127 | },
128 | {
129 | rel: 'apple-touch-icon',
130 | sizes: '120x120',
131 | href: '/favicon/apple-icon-120x120.png',
132 | },
133 | {
134 | rel: 'apple-touch-icon',
135 | sizes: '144x144',
136 | href: '/favicon/apple-icon-144x144.png',
137 | },
138 | {
139 | rel: 'apple-touch-icon',
140 | sizes: '152x152',
141 | href: '/favicon/apple-icon-152x152.png',
142 | },
143 | {
144 | rel: 'apple-touch-icon',
145 | sizes: '180x180',
146 | href: '/favicon/apple-icon-180x180.png',
147 | },
148 | {
149 | rel: 'icon',
150 | type: 'image/png',
151 | sizes: '192x192',
152 | href: '/favicon/android-icon-192x192.png',
153 | },
154 | {
155 | rel: 'icon',
156 | type: 'image/png',
157 | sizes: '32x32',
158 | href: '/favicon/favicon-32x32.png',
159 | },
160 | {
161 | rel: 'icon',
162 | type: 'image/png',
163 | sizes: '96x96',
164 | href: '/favicon/favicon-96x96.png',
165 | },
166 | {
167 | rel: 'icon',
168 | type: 'image/png',
169 | sizes: '16x16',
170 | href: '/favicon/favicon-16x16.png',
171 | },
172 | {
173 | rel: 'manifest',
174 | href: '/favicon/manifest.json',
175 | },
176 | ];
177 |
--------------------------------------------------------------------------------
/src/components/Skeleton.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import clsxm from '@/lib/clsxm';
4 |
5 | type SkeletonProps = React.ComponentPropsWithoutRef<'div'>;
6 |
7 | export default function Skeleton({ className, ...rest }: SkeletonProps) {
8 | return (
9 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/buttons/Button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ImSpinner2 } from 'react-icons/im';
3 |
4 | import clsxm from '@/lib/clsxm';
5 |
6 | enum ButtonVariant {
7 | 'primary',
8 | 'outline',
9 | 'ghost',
10 | 'light',
11 | 'dark',
12 | }
13 |
14 | type ButtonProps = {
15 | isLoading?: boolean;
16 | isDarkBg?: boolean;
17 | variant?: keyof typeof ButtonVariant;
18 | } & React.ComponentPropsWithRef<'button'>;
19 |
20 | const Button = React.forwardRef(
21 | (
22 | {
23 | children,
24 | className,
25 | disabled: buttonDisabled,
26 | isLoading,
27 | variant = 'primary',
28 | isDarkBg = false,
29 | ...rest
30 | },
31 | ref
32 | ) => {
33 | const disabled = isLoading || buttonDisabled;
34 |
35 | return (
36 |
104 | );
105 | }
106 | );
107 |
108 | export default Button;
109 |
--------------------------------------------------------------------------------
/src/components/content/StyledCode.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Highlight, { HighlightProps } from 'react-highlight';
3 |
4 | import 'highlight.js/styles/nord.css';
5 |
6 | import clsxm from '@/lib/clsxm';
7 |
8 | type StyledCodeProps = HighlightProps;
9 |
10 | export default function StyledCode({
11 | className,
12 | children,
13 | ...rest
14 | }: StyledCodeProps) {
15 | return (
16 |
17 | {children}
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/content/StyledJSON.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Highlight, { HighlightProps } from 'react-highlight';
3 |
4 | import 'highlight.js/styles/nord.css';
5 |
6 | import clsxm from '@/lib/clsxm';
7 |
8 | type StyledJSONProps = { data: unknown } & HighlightProps;
9 |
10 | export default function StyledJSON({
11 | className,
12 | data,
13 | ...rest
14 | }: StyledJSONProps) {
15 | return (
16 |
17 | {JSON.stringify(data, null, 2)}
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/content/TryAlert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import clsxm from '@/lib/clsxm';
4 |
5 | type TryAlertProps = {
6 | withoutTitle?: boolean;
7 | } & React.ComponentPropsWithoutRef<'div'>;
8 |
9 | export default function TryAlert({
10 | className,
11 | children,
12 | withoutTitle = false,
13 | ...rest
14 | }: TryAlertProps) {
15 | return (
16 |
23 | {!withoutTitle &&
Try it out!
}
24 | {children}
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/forms/DatePicker.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import ReactDatePicker, { ReactDatePickerProps } from 'react-datepicker';
3 | import { Controller, RegisterOptions, useFormContext } from 'react-hook-form';
4 | import { HiOutlineCalendar } from 'react-icons/hi';
5 |
6 | import 'react-datepicker/dist/react-datepicker.css';
7 |
8 | type DatePickerProps = {
9 | validation?: RegisterOptions;
10 | label: string;
11 | id: string;
12 | placeholder?: string;
13 | defaultYear?: number;
14 | defaultMonth?: number;
15 | defaultValue?: string;
16 | helperText?: string;
17 | readOnly?: boolean;
18 | } & Omit;
19 |
20 | export default function DatePicker({
21 | validation,
22 | label,
23 | id,
24 | placeholder,
25 | defaultYear,
26 | defaultMonth,
27 | defaultValue,
28 | helperText,
29 | readOnly = false,
30 | ...rest
31 | }: DatePickerProps) {
32 | const {
33 | formState: { errors },
34 | control,
35 | } = useFormContext();
36 |
37 | // If there is a year default, then change the year to the props
38 | const defaultDate = new Date();
39 | if (defaultYear) defaultDate.setFullYear(defaultYear);
40 | if (defaultMonth) defaultDate.setMonth(defaultMonth);
41 |
42 | return (
43 |
44 |
47 |
48 |
(
54 | <>
55 |
56 |
79 |
80 |
81 |
82 | {helperText !== '' && (
83 |
{helperText}
84 | )}
85 | {errors[id] && (
86 |
87 | {errors[id]?.message as unknown as string}
88 |
89 | )}
90 |
91 | >
92 | )}
93 | />
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/components/forms/DropzoneInput.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import * as React from 'react';
3 | import { Accept, FileRejection, useDropzone } from 'react-dropzone';
4 | import { Controller, useFormContext } from 'react-hook-form';
5 |
6 | import FilePreview from '@/components/forms/FilePreview';
7 |
8 | import { FileWithPreview } from '@/types/dropzone';
9 |
10 | type DropzoneInputProps = {
11 | accept?: Accept;
12 | helperText?: string;
13 | id: string;
14 | label: string;
15 | maxFiles?: number;
16 | readOnly?: boolean;
17 | validation?: Record;
18 | };
19 |
20 | export default function DropzoneInput({
21 | accept,
22 | helperText = '',
23 | id,
24 | label,
25 | maxFiles = 1,
26 | validation,
27 | readOnly,
28 | }: DropzoneInputProps) {
29 | const {
30 | control,
31 | getValues,
32 | setValue,
33 | setError,
34 | clearErrors,
35 | formState: { errors },
36 | } = useFormContext();
37 |
38 | //#region //*=========== Error Focus ===========
39 | const dropzoneRef = React.useRef(null);
40 |
41 | React.useEffect(() => {
42 | errors[id] && dropzoneRef.current?.focus();
43 | }, [errors, id]);
44 | //#endregion //*======== Error Focus ===========
45 |
46 | const [files, setFiles] = React.useState(
47 | getValues(id) || []
48 | );
49 |
50 | const onDrop = React.useCallback(
51 | (acceptedFiles: T[], rejectedFiles: FileRejection[]) => {
52 | if (rejectedFiles && rejectedFiles.length > 0) {
53 | setValue(id, files ? [...files] : null);
54 | setError(id, {
55 | type: 'manual',
56 | message: rejectedFiles && rejectedFiles[0].errors[0].message,
57 | });
58 | } else {
59 | const acceptedFilesPreview = acceptedFiles.map((file: T) =>
60 | Object.assign(file, {
61 | preview: URL.createObjectURL(file),
62 | })
63 | );
64 |
65 | setFiles(
66 | files
67 | ? [...files, ...acceptedFilesPreview].slice(0, maxFiles)
68 | : acceptedFilesPreview
69 | );
70 |
71 | setValue(
72 | id,
73 | files
74 | ? [...files, ...acceptedFiles].slice(0, maxFiles)
75 | : acceptedFiles,
76 | {
77 | shouldValidate: true,
78 | }
79 | );
80 | clearErrors(id);
81 | }
82 | },
83 | [clearErrors, files, id, maxFiles, setError, setValue]
84 | );
85 |
86 | React.useEffect(() => {
87 | return () => {
88 | () => {
89 | files.forEach((file) => URL.revokeObjectURL(file.preview));
90 | };
91 | };
92 | }, [files]);
93 |
94 | const deleteFile = (
95 | e: React.MouseEvent,
96 | file: FileWithPreview
97 | ) => {
98 | e.preventDefault();
99 | const newFiles = [...files];
100 |
101 | newFiles.splice(newFiles.indexOf(file), 1);
102 |
103 | if (newFiles.length > 0) {
104 | setFiles(newFiles);
105 | setValue(id, newFiles, {
106 | shouldValidate: true,
107 | shouldDirty: true,
108 | shouldTouch: true,
109 | });
110 | } else {
111 | setFiles([]);
112 | setValue(id, null, {
113 | shouldValidate: true,
114 | shouldDirty: true,
115 | shouldTouch: true,
116 | });
117 | }
118 | };
119 |
120 | const { getRootProps, getInputProps } = useDropzone({
121 | onDrop,
122 | accept,
123 | maxFiles,
124 | maxSize: 1000000,
125 | });
126 |
127 | return (
128 |
129 |
132 |
133 | {readOnly && !(files?.length > 0) ? (
134 |
135 | No file uploaded
136 |
137 | ) : files?.length >= maxFiles ? (
138 |
139 | {files.map((file, index) => (
140 |
146 | ))}
147 |
148 | ) : (
149 |
(
154 | <>
155 |
160 |
161 |
169 |
170 |
184 |
185 | Drag and drop file here, or click to choose file
186 |
187 |
{`${
188 | maxFiles - (files?.length || 0)
189 | } file(s) remaining`}
190 |
191 |
192 |
193 |
194 |
195 | {helperText !== '' && (
196 |
{helperText}
197 | )}
198 | {errors[id] && (
199 |
200 | {errors[id]?.message as unknown as string}
201 |
202 | )}
203 |
204 | {!readOnly && !!files?.length && (
205 |
206 | {files.map((file, index) => (
207 |
213 | ))}
214 |
215 | )}
216 | >
217 | )}
218 | />
219 | )}
220 |
221 | );
222 | }
223 |
--------------------------------------------------------------------------------
/src/components/forms/FilePreview.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import {
3 | HiOutlineExternalLink,
4 | HiOutlineEye,
5 | HiOutlinePaperClip,
6 | HiOutlinePhotograph,
7 | HiX,
8 | } from 'react-icons/hi';
9 | import Lightbox from 'react-image-lightbox';
10 |
11 | import 'react-image-lightbox/style.css';
12 |
13 | import UnstyledLink from '@/components/links/UnstyledLink';
14 |
15 | import { FileWithPreview } from '@/types/dropzone';
16 |
17 | type FilePreviewProps = {
18 | file: FileWithPreview;
19 | } & (
20 | | {
21 | deleteFile?: (
22 | e: React.MouseEvent,
23 | file: FileWithPreview
24 | ) => void;
25 | readOnly?: true;
26 | }
27 | | {
28 | deleteFile: (
29 | e: React.MouseEvent,
30 | file: FileWithPreview
31 | ) => void;
32 | readOnly?: false;
33 | }
34 | );
35 |
36 | export default function FilePreview({
37 | deleteFile,
38 | file,
39 | readOnly,
40 | }: FilePreviewProps): React.ReactElement {
41 | const [index, setIndex] = React.useState(0);
42 | const [isOpen, setIsOpen] = React.useState(false);
43 |
44 | const images = [file.preview];
45 |
46 | const handleDelete = (e: React.MouseEvent) => {
47 | e.stopPropagation();
48 | deleteFile?.(e, file);
49 | };
50 |
51 | const imagesType = ['image/png', 'image/jpg', 'image/jpeg'];
52 |
53 | return imagesType.includes(file.type) ? (
54 | <>
55 |
59 |
60 |
64 | {file.name}
65 |
66 |
67 |
74 | {!readOnly && (
75 |
82 | )}
83 |
84 |
85 | {isOpen && (
86 | setIsOpen(false)}
91 | onMovePrevRequest={() =>
92 | setIndex(
93 | (prevIndex) => (prevIndex + images.length - 1) % images.length
94 | )
95 | }
96 | onMoveNextRequest={() =>
97 | setIndex((prevIndex) => (prevIndex + 1) % images.length)
98 | }
99 | />
100 | )}
101 | >
102 | ) : (
103 |
107 |
108 |
112 | {file.name}
113 |
114 |
115 |
119 |
120 |
121 | {!readOnly && (
122 |
129 | )}
130 |
131 |
132 | );
133 | }
134 |
--------------------------------------------------------------------------------
/src/components/forms/Input.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import * as React from 'react';
3 | import { RegisterOptions, useFormContext } from 'react-hook-form';
4 | import { HiExclamationCircle } from 'react-icons/hi';
5 |
6 | export type InputProps = {
7 | /** Input label */
8 | label: string;
9 | /**
10 | * id to be initialized with React Hook Form,
11 | * must be the same with the pre-defined types.
12 | */
13 | id: string;
14 | /** Input placeholder */
15 | placeholder?: string;
16 | /** Small text below input, useful for additional information */
17 | helperText?: string;
18 | /**
19 | * Input type
20 | * @example text, email, password
21 | */
22 | type?: React.HTMLInputTypeAttribute;
23 | /** Disables the input and shows defaultValue (can be set from React Hook Form) */
24 | readOnly?: boolean;
25 | /** Disable error style (not disabling error validation) */
26 | hideError?: boolean;
27 | /** Manual validation using RHF, it is encouraged to use yup resolver instead */
28 | validation?: RegisterOptions;
29 | } & React.ComponentPropsWithoutRef<'input'>;
30 |
31 | export default function Input({
32 | label,
33 | placeholder = '',
34 | helperText,
35 | id,
36 | type = 'text',
37 | readOnly = false,
38 | hideError = false,
39 | validation,
40 | ...rest
41 | }: InputProps) {
42 | const {
43 | register,
44 | formState: { errors },
45 | } = useFormContext();
46 |
47 | return (
48 |
49 |
52 |
53 |
71 |
72 | {!hideError && errors[id] && (
73 |
74 |
75 |
76 | )}
77 |
78 |
79 | {helperText &&
{helperText}
}
80 | {!hideError && errors[id] && (
81 |
82 | {errors[id]?.message as unknown as string}
83 |
84 | )}
85 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/src/components/forms/PasswordInput.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { useState } from 'react';
3 | import { RegisterOptions, useFormContext } from 'react-hook-form';
4 | import { HiEye, HiEyeOff } from 'react-icons/hi';
5 |
6 | export type PasswordInputProps = {
7 | /** Input label */
8 | label: string;
9 | /**
10 | * id to be initialized with React Hook Form,
11 | * must be the same with the pre-defined types.
12 | */
13 | id: string;
14 | /** Input placeholder */
15 | placeholder?: string;
16 | /** Small text below input, useful for additional information */
17 | helperText?: string;
18 | /**
19 | * Input type
20 | * @example text, email, password
21 | */
22 | type?: React.HTMLInputTypeAttribute;
23 | /** Disables the input and shows defaultValue (can be set from React Hook Form) */
24 | readOnly?: boolean;
25 | /** Disable error style (not disabling error validation) */
26 | hideError?: boolean;
27 | /** Manual validation using RHF, it is encouraged to use yup resolver instead */
28 | validation?: RegisterOptions;
29 | } & React.ComponentPropsWithoutRef<'input'>;
30 |
31 | export default function PasswordInput({
32 | label,
33 | placeholder = '',
34 | helperText,
35 | id,
36 | readOnly = false,
37 | validation,
38 | ...rest
39 | }: PasswordInputProps) {
40 | const {
41 | register,
42 | formState: { errors },
43 | } = useFormContext();
44 |
45 | const [showPassword, setShowPassword] = useState(false);
46 | const togglePassword = () => setShowPassword((prev) => !prev);
47 |
48 | return (
49 |
50 |
53 |
54 |
72 |
73 |
84 |
85 |
86 | {helperText &&
{helperText}
}
87 | {errors[id] && (
88 |
89 | {errors[id]?.message as unknown as string}
90 |
91 | )}
92 |
93 |
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/src/components/forms/SelectInput.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import * as React from 'react';
3 | import { RegisterOptions, useFormContext } from 'react-hook-form';
4 | import { HiExclamationCircle } from 'react-icons/hi';
5 |
6 | export type SelectInputProps = {
7 | label: string;
8 | id: string;
9 | placeholder?: string;
10 | helperText?: string;
11 | type?: string;
12 | readOnly?: boolean;
13 | validation?: RegisterOptions;
14 | children: React.ReactNode;
15 | } & React.ComponentPropsWithoutRef<'select'>;
16 |
17 | export default function SelectInput({
18 | label,
19 | helperText,
20 | id,
21 | placeholder,
22 | readOnly = false,
23 | children,
24 | validation,
25 | ...rest
26 | }: SelectInputProps) {
27 | const {
28 | register,
29 | formState: { errors },
30 | watch,
31 | } = useFormContext();
32 |
33 | const value = watch(id);
34 |
35 | // Add disabled and selected attribute to option, will be used if readonly
36 | const readOnlyChildren = React.Children.map(
37 | children,
38 | (child) => {
39 | if (React.isValidElement(child)) {
40 | return React.cloneElement(
41 | child as React.ReactElement,
42 | {
43 | disabled: child.props.value !== rest?.defaultValue,
44 | // selected: child.props.value === rest?.defaultValue,
45 | }
46 | );
47 | }
48 | }
49 | );
50 |
51 | return (
52 |
53 |
56 |
57 |
82 |
83 | {errors[id] && (
84 |
85 |
86 |
87 | )}
88 |
89 |
90 | {helperText &&
{helperText}
}
91 | {errors[id] && (
92 |
93 | {errors[id]?.message as unknown as string}
94 |
95 | )}
96 |
97 |
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/src/components/forms/TextArea.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { RegisterOptions, useFormContext } from 'react-hook-form';
3 | import { HiExclamationCircle } from 'react-icons/hi';
4 |
5 | export type TextAreaProps = {
6 | label: string;
7 | id: string;
8 | placeholder?: string;
9 | helperText?: string;
10 | readOnly?: boolean;
11 | hideError?: boolean;
12 | validation?: RegisterOptions;
13 | } & React.ComponentPropsWithoutRef<'textarea'>;
14 |
15 | export default function TextArea({
16 | label,
17 | placeholder = '',
18 | helperText,
19 | id,
20 | readOnly = false,
21 | hideError = false,
22 | validation,
23 | ...rest
24 | }: TextAreaProps) {
25 | const {
26 | register,
27 | formState: { errors },
28 | } = useFormContext();
29 |
30 | return (
31 |
32 |
35 |
36 |
54 | {!hideError && errors[id] && (
55 |
56 |
57 |
58 | )}
59 |
60 |
61 | {helperText &&
{helperText}
}
62 | {!hideError && errors[id] && (
63 |
64 | {errors[id]?.message as unknown as string}
65 |
66 | )}
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/components/hoc/withAuth.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import * as React from 'react';
3 | import { ImSpinner8 } from 'react-icons/im';
4 |
5 | import apiMock from '@/lib/axios-mock';
6 | import { getFromLocalStorage } from '@/lib/helper';
7 |
8 | import useAuthStore from '@/store/useAuthStore';
9 |
10 | import { ApiReturn } from '@/types/api';
11 | import { User } from '@/types/auth';
12 |
13 | export interface WithAuthProps {
14 | user: User;
15 | }
16 |
17 | const HOME_ROUTE = '/';
18 | const LOGIN_ROUTE = '/login';
19 |
20 | enum RouteRole {
21 | /**
22 | * For authentication pages
23 | * @example /login /register
24 | */
25 | auth,
26 | /**
27 | * Optional authentication
28 | * It doesn't push to login page if user is not authenticated
29 | */
30 | optional,
31 | /**
32 | * For all authenticated user
33 | * will push to login if user is not authenticated
34 | */
35 | all,
36 | }
37 |
38 | /**
39 | * Add role-based access control to a component
40 | *
41 | * @see https://react-typescript-cheatsheet.netlify.app/docs/hoc/full_example/
42 | * @see https://github.com/mxthevs/nextjs-auth/blob/main/src/components/withAuth.tsx
43 | */
44 | export default function withAuth(
45 | Component: React.ComponentType,
46 | routeRole: keyof typeof RouteRole
47 | ) {
48 | const ComponentWithAuth = (props: Omit) => {
49 | const router = useRouter();
50 | const { query } = router;
51 |
52 | //#region //*=========== STORE ===========
53 | const isAuthenticated = useAuthStore.useIsAuthenticated();
54 | const isLoading = useAuthStore.useIsLoading();
55 | const login = useAuthStore.useLogin();
56 | const logout = useAuthStore.useLogout();
57 | const stopLoading = useAuthStore.useStopLoading();
58 | const user = useAuthStore.useUser();
59 | //#endregion //*======== STORE ===========
60 |
61 | const checkAuth = React.useCallback(() => {
62 | const token = getFromLocalStorage('token');
63 | if (!token) {
64 | isAuthenticated && logout();
65 | stopLoading();
66 | return;
67 | }
68 | const loadUser = async () => {
69 | try {
70 | const res = await apiMock.get>('/me');
71 |
72 | login({
73 | ...res.data.data,
74 | token: token + '',
75 | });
76 | } catch (err) {
77 | localStorage.removeItem('token');
78 | } finally {
79 | stopLoading();
80 | }
81 | };
82 |
83 | if (!isAuthenticated) {
84 | loadUser();
85 | }
86 | }, [isAuthenticated, login, logout, stopLoading]);
87 |
88 | React.useEffect(() => {
89 | // run checkAuth every page visit
90 | checkAuth();
91 |
92 | // run checkAuth every focus changes
93 | window.addEventListener('focus', checkAuth);
94 | return () => {
95 | window.removeEventListener('focus', checkAuth);
96 | };
97 | }, [checkAuth]);
98 |
99 | React.useEffect(() => {
100 | if (!isLoading) {
101 | if (isAuthenticated) {
102 | // Prevent authenticated user from accessing auth or other role pages
103 | if (routeRole === 'auth') {
104 | if (query?.redirect) {
105 | router.replace(query.redirect as string);
106 | } else {
107 | router.replace(HOME_ROUTE);
108 | }
109 | }
110 | } else {
111 | // Prevent unauthenticated user from accessing protected pages
112 | if (routeRole !== 'auth' && routeRole !== 'optional') {
113 | router.replace(
114 | `${LOGIN_ROUTE}?redirect=${router.asPath}`,
115 | `${LOGIN_ROUTE}`
116 | );
117 | }
118 | }
119 | }
120 | }, [isAuthenticated, isLoading, query, router, user]);
121 |
122 | if (
123 | // If unauthenticated user want to access protected pages
124 | (isLoading || !isAuthenticated) &&
125 | // auth pages and optional pages are allowed to access without login
126 | routeRole !== 'auth' &&
127 | routeRole !== 'optional'
128 | ) {
129 | return (
130 |
131 |
132 |
Loading...
133 |
134 | );
135 | }
136 |
137 | return ;
138 | };
139 |
140 | return ComponentWithAuth;
141 | }
142 |
--------------------------------------------------------------------------------
/src/components/layout/DashboardLayout.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import Header from '@/components/layout/Header';
4 | import Sidebar from '@/components/layout/Sidebar';
5 |
6 | type DashboardLayoutProps = {
7 | children: React.ReactNode;
8 | hideSidePanel?: boolean;
9 | };
10 |
11 | export default function DashboardLayout({ children }: DashboardLayoutProps) {
12 | const [sidebarOpen, setSidebarOpen] = React.useState(false);
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 | {/*
*/}
21 |
22 |
23 |
24 | {children}
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/layout/Footer.tsx:
--------------------------------------------------------------------------------
1 | import UnstyledLink from '@/components/links/UnstyledLink';
2 | import NextImage from '@/components/NextImage';
3 |
4 | export default function Footer() {
5 | return (
6 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/layout/Header.tsx:
--------------------------------------------------------------------------------
1 | import { Menu, Transition } from '@headlessui/react';
2 | import * as React from 'react';
3 | import { HiMenuAlt2 } from 'react-icons/hi';
4 | import { ImUser } from 'react-icons/im';
5 |
6 | import clsxm from '@/lib/clsxm';
7 |
8 | type HeaderProps = {
9 | setSidebarOpen: React.Dispatch
>;
10 | };
11 |
12 | const userNavigation = [
13 | { name: 'Your Profile', href: '#' },
14 | { name: 'Settings', href: '#' },
15 | { name: 'Sign out', href: '#' },
16 | ];
17 |
18 | export default function Header({ setSidebarOpen }: HeaderProps) {
19 | return (
20 |
21 |
29 |
30 |
31 |
32 | Admin
33 |
34 |
35 | {/* Profile dropdown */}
36 |
71 |
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/src/components/layout/Layout.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export default function Layout({ children }: { children: React.ReactNode }) {
4 | return <>{children}>;
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/layout/NavigationList.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { NextRouter } from 'next/router';
3 |
4 | import UnstyledLink from '../links/UnstyledLink';
5 |
6 | export function NavigationList(
7 | router: NextRouter,
8 | item: { name: string; href: string }
9 | ) {
10 | const isActivePath = router.asPath === item.href;
11 | return (
12 |
23 | {item.name}
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/layout/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog, Transition } from '@headlessui/react';
2 | import { useRouter } from 'next/router';
3 | import * as React from 'react';
4 | import { IconType } from 'react-icons';
5 | import { HiHome, HiX } from 'react-icons/hi';
6 |
7 | import clsxm from '@/lib/clsxm';
8 |
9 | import Vercel from '~/svg/Vercel.svg';
10 |
11 | type Navigation = {
12 | name: string;
13 | href: string;
14 | icon: IconType;
15 | exactMatch?: boolean;
16 | };
17 |
18 | type SidebarProps = {
19 | sidebarOpen: boolean;
20 | setSidebarOpen: React.Dispatch>;
21 | };
22 |
23 | const navigation: Navigation[] = [
24 | { name: 'Dashboard', href: '/dashboard', icon: HiHome },
25 | ];
26 |
27 | export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
28 | const router = useRouter();
29 |
30 | return (
31 | <>
32 |
33 |
121 |
122 |
123 | {/* Static sidebar for desktop */}
124 |
125 | {/* Sidebar component, swap this element with another sidebar if you like */}
126 |
127 |
128 |
129 |
130 |
131 |
162 |
163 |
164 |
165 | >
166 | );
167 | }
168 |
--------------------------------------------------------------------------------
/src/components/links/ArrowLink.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import clsxm from '@/lib/clsxm';
4 |
5 | import UnderlineLink from '@/components/links/UnderlineLink';
6 | import { UnstyledLinkProps } from '@/components/links/UnstyledLink';
7 |
8 | export type ArrowLinkProps = {
9 | as?: C;
10 | direction?: 'left' | 'right';
11 | spanClassName?: string;
12 | } & UnstyledLinkProps &
13 | React.ComponentProps;
14 |
15 | export default function ArrowLink({
16 | children,
17 | className,
18 | spanClassName,
19 | direction = 'right',
20 | as,
21 | ...rest
22 | }: ArrowLinkProps) {
23 | const Component = as || UnderlineLink;
24 |
25 | return (
26 |
34 | {children}
35 |
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/links/BackToHome.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { HiOutlineHome } from 'react-icons/hi';
3 |
4 | import clsxm from '@/lib/clsxm';
5 |
6 | import ArrowLink, { ArrowLinkProps } from '@/components/links/ArrowLink';
7 | import PrimaryLink from '@/components/links/PrimaryLink';
8 |
9 | type BackToHomeProps = Omit, 'href' | 'children'>;
10 |
11 | export default function BackToHome({ className, ...rest }: BackToHomeProps) {
12 | return (
13 |
21 |
22 | HOME
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/links/ButtonLink.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import clsxm from '@/lib/clsxm';
4 |
5 | import UnstyledLink, {
6 | UnstyledLinkProps,
7 | } from '@/components/links/UnstyledLink';
8 |
9 | enum ButtonVariant {
10 | 'primary',
11 | 'outline',
12 | 'ghost',
13 | 'light',
14 | 'dark',
15 | }
16 |
17 | type ButtonLinkProps = {
18 | isDarkBg?: boolean;
19 | variant?: keyof typeof ButtonVariant;
20 | } & UnstyledLinkProps;
21 |
22 | const ButtonLink = React.forwardRef(
23 | (
24 | { children, className, variant = 'primary', isDarkBg = false, ...rest },
25 | ref
26 | ) => {
27 | return (
28 |
76 | {children}
77 |
78 | );
79 | }
80 | );
81 |
82 | export default ButtonLink;
83 |
--------------------------------------------------------------------------------
/src/components/links/PrimaryLink.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import clsxm from '@/lib/clsxm';
4 |
5 | import UnstyledLink, {
6 | UnstyledLinkProps,
7 | } from '@/components/links/UnstyledLink';
8 |
9 | const PrimaryLink = React.forwardRef(
10 | ({ className, children, ...rest }, ref) => {
11 | return (
12 |
22 | {children}
23 |
24 | );
25 | }
26 | );
27 |
28 | export default PrimaryLink;
29 |
--------------------------------------------------------------------------------
/src/components/links/UnderlineLink.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import clsxm from '@/lib/clsxm';
4 |
5 | import UnstyledLink, {
6 | UnstyledLinkProps,
7 | } from '@/components/links/UnstyledLink';
8 |
9 | const UnderlineLink = React.forwardRef(
10 | ({ children, className, ...rest }, ref) => {
11 | return (
12 |
22 | {children}
23 |
24 | );
25 | }
26 | );
27 |
28 | export default UnderlineLink;
29 |
--------------------------------------------------------------------------------
/src/components/links/UnstyledLink.tsx:
--------------------------------------------------------------------------------
1 | import Link, { LinkProps } from 'next/link';
2 | import * as React from 'react';
3 |
4 | import clsxm from '@/lib/clsxm';
5 |
6 | export type UnstyledLinkProps = {
7 | href: string;
8 | children: React.ReactNode;
9 | openNewTab?: boolean;
10 | className?: string;
11 | nextLinkProps?: Omit;
12 | } & React.ComponentPropsWithRef<'a'>;
13 |
14 | const UnstyledLink = React.forwardRef(
15 | ({ children, href, openNewTab, className, nextLinkProps, ...rest }, ref) => {
16 | const isNewTab =
17 | openNewTab !== undefined
18 | ? openNewTab
19 | : href && !href.startsWith('/') && !href.startsWith('#');
20 |
21 | if (!isNewTab) {
22 | return (
23 |
24 |
25 | {children}
26 |
27 |
28 | );
29 | }
30 |
31 | return (
32 |
40 | {children}
41 |
42 | );
43 | }
44 | );
45 |
46 | export default UnstyledLink;
47 |
--------------------------------------------------------------------------------
/src/constant/env.ts:
--------------------------------------------------------------------------------
1 | export const isProd = process.env.NODE_ENV === 'production';
2 | export const isLocal = process.env.NODE_ENV === 'development';
3 |
4 | export const showLogger = isLocal
5 | ? true
6 | : process.env.NEXT_PUBLIC_SHOW_LOGGER === 'true' ?? false;
7 |
--------------------------------------------------------------------------------
/src/constant/navigation.ts:
--------------------------------------------------------------------------------
1 | type Navigation = {
2 | title: string;
3 | content: {
4 | name: string;
5 | href: string;
6 | }[];
7 | }[];
8 |
9 | export const navigationItem: Navigation = [
10 | {
11 | title: 'Laporan',
12 | content: [
13 | {
14 | name: 'Laporan Penerimaan',
15 | href: '/monitoring/report/acceptance-report',
16 | },
17 | {
18 | name: 'Laporan Kinerja Operator',
19 | href: '/monitoring/report/performance-report',
20 | },
21 | ],
22 | },
23 | {
24 | title: 'Monitoring',
25 | content: [
26 | {
27 | name: 'Statistik Per Wilayah',
28 | href: '/monitoring/statistik/pendaftaran/wilayah',
29 | },
30 | {
31 | name: 'Statistik Per Sekolah',
32 | href: '/monitoring/statistik/pendaftaran/sekolah',
33 | },
34 | {
35 | name: 'Verifikasi NIK',
36 | href: '/monitoring/verifikasi-nik',
37 | },
38 | {
39 | name: 'Permintaan Pembatalan',
40 | href: '/monitoring/permintaan-pembatalan',
41 | },
42 | ],
43 | },
44 | ];
45 |
--------------------------------------------------------------------------------
/src/constant/toast.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_TOAST_MESSAGE = {
2 | loading: 'Loading...',
3 | success: 'Success',
4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
5 | error: (err: any) =>
6 | err?.response?.data?.message ?? 'Something is wrong, please try again',
7 | };
8 |
--------------------------------------------------------------------------------
/src/hooks/toast/useLoadingToast.tsx:
--------------------------------------------------------------------------------
1 | import { useToasterStore } from 'react-hot-toast';
2 |
3 | /**
4 | * Hook to get information whether something is loading
5 | * @returns true if there is a loading toast
6 | * @example const isLoading = useLoadingToast();
7 | */
8 | export default function useLoadingToast(): boolean {
9 | const { toasts } = useToasterStore();
10 | const isLoading = toasts.some((toast) => toast.type === 'loading');
11 | return isLoading;
12 | }
13 |
--------------------------------------------------------------------------------
/src/hooks/toast/useRQWithToast.tsx:
--------------------------------------------------------------------------------
1 | import { UseQueryResult } from '@tanstack/react-query';
2 | import * as React from 'react';
3 | import toast from 'react-hot-toast';
4 |
5 | import { DEFAULT_TOAST_MESSAGE } from '@/constant/toast';
6 |
7 | type OptionType = {
8 | runCondition?: boolean;
9 | loading?: string;
10 | success?: string;
11 | error?: string;
12 | };
13 |
14 | export default function useRQWithToast(
15 | query: UseQueryResult,
16 | { runCondition = true, ...customMessages }: OptionType = {}
17 | ) {
18 | const { data, isError, error, isLoading } = query;
19 |
20 | const toastStatus = React.useRef(data ? 'done' : 'idle');
21 | const toastMessage = {
22 | ...DEFAULT_TOAST_MESSAGE,
23 | ...customMessages,
24 | };
25 |
26 | React.useEffect(() => {
27 | if (!runCondition) return;
28 |
29 | // If it is not the first render
30 | if (toastStatus.current === 'done' && !isLoading) return;
31 |
32 | if (isError) {
33 | if (typeof toastMessage.error === 'string') {
34 | toast.error(toastMessage.error, { id: toastStatus.current });
35 | } else {
36 | toast.error(toastMessage.error(error), { id: toastStatus.current });
37 | }
38 | toastStatus.current = 'done';
39 | } else if (isLoading) {
40 | toastStatus.current = toast.loading(toastMessage.loading);
41 | } else if (data) {
42 | toast.success(toastMessage.success, { id: toastStatus.current });
43 | toastStatus.current = 'done';
44 | }
45 |
46 | return () => {
47 | toast.dismiss(toastStatus.current);
48 | };
49 | // eslint-disable-next-line react-hooks/exhaustive-deps
50 | }, [
51 | data,
52 | error,
53 | isError,
54 | isLoading,
55 | runCondition,
56 | toastMessage.error,
57 | toastMessage.loading,
58 | toastMessage.success,
59 | ]);
60 |
61 | return { ...query };
62 | }
63 |
--------------------------------------------------------------------------------
/src/lib/__tests__/helper.test.ts:
--------------------------------------------------------------------------------
1 | import { openGraph } from '@/lib/helper';
2 |
3 | describe('Open Graph function should work correctly', () => {
4 | it('should not return templateTitle when not specified', () => {
5 | const result = openGraph({
6 | description: 'Test description',
7 | siteName: 'Test site name',
8 | });
9 | expect(result).not.toContain('&templateTitle=');
10 | });
11 |
12 | it('should return templateTitle when specified', () => {
13 | const result = openGraph({
14 | templateTitle: 'Test Template Title',
15 | description: 'Test description',
16 | siteName: 'Test site name',
17 | });
18 | expect(result).toContain('&templateTitle=Test%20Template%20Title');
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/src/lib/axios-mock.ts:
--------------------------------------------------------------------------------
1 | import { QueryFunction } from '@tanstack/react-query';
2 | import axios from 'axios';
3 |
4 | /** Add NEXT_PUBLIC_MOCK_DEPLOYMENT_URL to your production deployment on vercel! */
5 | const baseURL = process.env.NEXT_PUBLIC_MOCK_DEPLOYMENT_URL
6 | ? `https://${process.env.NEXT_PUBLIC_MOCK_DEPLOYMENT_URL}/api/mock`
7 | : process.env.NEXT_PUBLIC_VERCEL_URL
8 | ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}/api/mock`
9 | : 'http://localhost:3000/api/mock';
10 |
11 | export const apiMock = axios.create({
12 | baseURL,
13 | headers: {
14 | 'Content-Type': 'application/json',
15 | },
16 | withCredentials: false,
17 | });
18 |
19 | apiMock.defaults.withCredentials = false;
20 |
21 | apiMock.interceptors.request.use(function (config) {
22 | const token = localStorage.getItem('token');
23 | if (config.headers) {
24 | config.headers.Authorization = token ? `Bearer ${token}` : '';
25 | }
26 | return config;
27 | });
28 |
29 | export default apiMock;
30 |
31 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
32 | export const mockQuery: QueryFunction = async ({ queryKey }) => {
33 | const [url] = queryKey;
34 |
35 | const { data } = await apiMock.get(url as string);
36 | return data;
37 | };
38 |
--------------------------------------------------------------------------------
/src/lib/axios.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | const api = axios.create({
4 | baseURL: 'http://127.0.0.1:8000/api/v1',
5 | headers: {
6 | Authorization: '',
7 | 'Content-Type': 'application/json',
8 | },
9 | });
10 |
11 | api.defaults.withCredentials = false;
12 |
13 | api.interceptors.request.use(function (config) {
14 | const token = localStorage.getItem('token');
15 | if (config.headers) {
16 | config.headers.Authorization = token ? `Bearer ${token}` : '';
17 | }
18 | return config;
19 | });
20 |
21 | export default api;
22 |
--------------------------------------------------------------------------------
/src/lib/clsxm.ts:
--------------------------------------------------------------------------------
1 | import clsx, { ClassValue } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | /** Merge classes with tailwind-merge with clsx full feature */
5 | export default function clsxm(...classes: ClassValue[]) {
6 | return twMerge(clsx(...classes));
7 | }
8 |
--------------------------------------------------------------------------------
/src/lib/helper.ts:
--------------------------------------------------------------------------------
1 | type OpenGraphType = {
2 | siteName: string;
3 | description: string;
4 | templateTitle?: string;
5 | logo?: string;
6 | };
7 | // Please clone them and self-host if your site is going to be visited by many people.
8 | // Then change the url and the default logo.
9 | export function openGraph({
10 | siteName,
11 | templateTitle,
12 | description,
13 | logo = 'https://og.clarence.link/images/logo.jpg',
14 | }: OpenGraphType): string {
15 | const ogLogo = encodeURIComponent(logo);
16 | const ogSiteName = encodeURIComponent(siteName.trim());
17 | const ogTemplateTitle = templateTitle
18 | ? encodeURIComponent(templateTitle.trim())
19 | : undefined;
20 | const ogDesc = encodeURIComponent(description.trim());
21 |
22 | return `https://og.clarence.link/api/general?siteName=${ogSiteName}&description=${ogDesc}&logo=${ogLogo}${
23 | ogTemplateTitle ? `&templateTitle=${ogTemplateTitle}` : ''
24 | }`;
25 | }
26 |
27 | export function getFromLocalStorage(key: string): string | null {
28 | if (typeof window !== 'undefined') {
29 | return window.localStorage.getItem(key);
30 | }
31 | return null;
32 | }
33 |
--------------------------------------------------------------------------------
/src/lib/logger.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import { showLogger } from '@/constant/env';
3 |
4 | /**
5 | * A logger function that will only logs on development
6 | * @param object - The object to log
7 | * @param comment - Autogenerated with `lg` snippet
8 | */
9 | export default function logger(object: unknown, comment?: string): void {
10 | if (!showLogger) return;
11 |
12 | console.log(
13 | '%c ============== INFO LOG \n',
14 | 'color: #22D3EE',
15 | `${typeof window !== 'undefined' && window?.location.pathname}\n`,
16 | `=== ${comment ?? ''}\n`,
17 | object
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { RiAlarmWarningFill } from 'react-icons/ri';
3 |
4 | import Layout from '@/components/layout/Layout';
5 | import ArrowLink from '@/components/links/ArrowLink';
6 | import Seo from '@/components/Seo';
7 |
8 | export default function NotFoundPage() {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
Page Not Found
21 |
22 | Back to Home
23 |
24 |
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | QueryClient,
3 | QueryClientProvider,
4 | QueryOptions,
5 | } from '@tanstack/react-query';
6 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
7 | import { AppProps } from 'next/app';
8 |
9 | import '@/styles/globals.css';
10 |
11 | import api from '@/lib/axios';
12 |
13 | import DismissableToast from '@/components/DismissableToast';
14 |
15 | const defaultQueryFn = async ({ queryKey }: QueryOptions) => {
16 | const { data } = await api.get(`${queryKey?.[0]}`);
17 | return data;
18 | };
19 |
20 | const queryClient = new QueryClient({
21 | defaultOptions: {
22 | queries: {
23 | queryFn: defaultQueryFn,
24 | },
25 | },
26 | });
27 |
28 | function MyApp({ Component, pageProps }: AppProps) {
29 | return (
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 |
38 | export default MyApp;
39 |
--------------------------------------------------------------------------------
/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, {
2 | DocumentContext,
3 | Head,
4 | Html,
5 | Main,
6 | NextScript,
7 | } from 'next/document';
8 |
9 | class MyDocument extends Document {
10 | static async getInitialProps(ctx: DocumentContext) {
11 | const initialProps = await Document.getInitialProps(ctx);
12 | return { ...initialProps };
13 | }
14 |
15 | render() {
16 | return (
17 |
18 |
19 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 | }
35 |
36 | export default MyDocument;
37 |
--------------------------------------------------------------------------------
/src/pages/api/hello.ts:
--------------------------------------------------------------------------------
1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
2 |
3 | import { NextApiRequest, NextApiResponse } from 'next';
4 |
5 | export default function hello(req: NextApiRequest, res: NextApiResponse) {
6 | res.status(200).json({ name: 'Bambang' });
7 | }
8 |
--------------------------------------------------------------------------------
/src/pages/api/mock/login.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 |
3 | export default async function login(req: NextApiRequest, res: NextApiResponse) {
4 | if (req.method === 'POST') {
5 | res.status(200).json({
6 | code: 200,
7 | data: {
8 | token: 'dummy-token',
9 | },
10 | });
11 | } else {
12 | res.status(405).json({ message: 'Method Not Allowed' });
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/pages/api/mock/me.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 |
3 | export default async function login(req: NextApiRequest, res: NextApiResponse) {
4 | if (req.method === 'GET') {
5 | const token = req.headers.authorization?.split(' ')[1];
6 | if (token === 'dummy-token') {
7 | return res.status(200).json({
8 | code: 200,
9 | data: {
10 | id: 1,
11 | name: 'Theodorus Clarence',
12 | token: 'dummy-token',
13 | },
14 | });
15 | } else {
16 | return res.status(401).json({
17 | code: 401,
18 | status: 'Error',
19 | message: "You're not authorized to access this resource",
20 | });
21 | }
22 | } else {
23 | res.status(405).json({ message: 'Method Not Allowed' });
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/pages/auth.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import * as React from 'react';
3 |
4 | import Button from '@/components/buttons/Button';
5 | import StyledCode from '@/components/content/StyledCode';
6 | import TryAlert from '@/components/content/TryAlert';
7 | import withAuth from '@/components/hoc/withAuth';
8 | import Layout from '@/components/layout/Layout';
9 | import BackToHome from '@/components/links/BackToHome';
10 | import ButtonLink from '@/components/links/ButtonLink';
11 | import Seo from '@/components/Seo';
12 |
13 | export default withAuth(AuthPage, 'auth');
14 | function AuthPage() {
15 | const router = useRouter();
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | Auth Page
26 |
27 |
28 | {`export default withAuth(AuthPage, 'auth');
29 | function AuthPage() { /* react component here */ }`}
30 |
31 |
32 | Use this for authentication pages such as Login and Register or
33 | any other page that suits with the behavior
34 |
35 |
36 | Behavior
37 |
38 | -
39 | Unauthenticated users{' '}
40 | can access this page without any loading page
41 |
42 |
51 |
52 |
53 | -
54 | Authenticated users{' '}
55 | will be redirected to
HOME_ROUTE
(default:{' '}
56 | /
).
57 |
58 | Note:
59 | -
60 | Authenticated users will see a flash before getting
61 | redirected;
62 |
63 | -
64 | We omit the loading spinner—that blocks content flash—in
65 | this page to prevent unauthenticated users from seeing
66 | annoying loading before they're trying to login;
67 |
68 | -
69 | You're expected to hide all links to the
auth
{' '}
70 | page when the user is authenticated.
71 |
72 |
73 |
74 |
75 | After you login, you'll be redirected right to this page,
76 | then you'll see a content flash before
77 | getting redirected back to the home page.
78 |
79 |
84 | Login
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/src/pages/dashboard.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import DashboardLayout from '@/components/layout/DashboardLayout';
4 | import Seo from '@/components/Seo';
5 |
6 | export default function DashboardPage() {
7 | return (
8 |
9 |
10 |
11 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import toast from 'react-hot-toast';
3 | import { HiOutlineCheckCircle, HiOutlineXCircle } from 'react-icons/hi';
4 |
5 | import Button from '@/components/buttons/Button';
6 | import withAuth from '@/components/hoc/withAuth';
7 | import ArrowLink from '@/components/links/ArrowLink';
8 | import ButtonLink from '@/components/links/ButtonLink';
9 | import UnderlineLink from '@/components/links/UnderlineLink';
10 | import Seo from '@/components/Seo';
11 |
12 | import useAuthStore from '@/store/useAuthStore';
13 |
14 | /**
15 | * SVGR Support
16 | * Caveat: No React Props Type.
17 | *
18 | * You can override the next-env if the type is important to you
19 | * @see https://stackoverflow.com/questions/68103844/how-to-override-next-js-svg-module-declaration
20 | */
21 | import Vercel from '~/svg/Vercel.svg';
22 |
23 | export default withAuth(HomePage, 'optional');
24 | function HomePage() {
25 | const isAuthenticated = useAuthStore.useIsAuthenticated();
26 | const logout = useAuthStore.useLogout();
27 |
28 | return (
29 | <>
30 | {/* */}
31 |
32 |
33 |
34 |
35 |
36 |
37 |
Next.js Authentication HOC Pattern
38 |
39 | Authentication pattern example using Higher Order Component,
40 | Zustand, and React Query
41 |
42 |
43 |
44 | See the repository
45 |
46 |
47 |
48 |
49 |
50 | isAuthenticated:{' '}
51 |
52 | {isAuthenticated ? (
53 | <>
54 |
55 | true
56 | >
57 | ) : (
58 | <>
59 |
60 | false
61 | >
62 | )}
63 |
64 |
65 |
66 | {isAuthenticated ? (
67 |
70 | ) : (
71 |
72 | Login
73 |
74 | )}
75 |
76 |
77 |
78 |
79 | {isAuthenticated ? (
80 |
105 | ) : (
106 |
107 | Auth
108 |
109 | )}
110 |
111 | Protected
112 |
113 |
114 | Optional
115 |
116 |
117 |
118 |
128 |
129 |
130 |
131 | >
132 | );
133 | }
134 |
--------------------------------------------------------------------------------
/src/pages/login.tsx:
--------------------------------------------------------------------------------
1 | import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
2 | import toast from 'react-hot-toast';
3 |
4 | import apiMock from '@/lib/axios-mock';
5 | import logger from '@/lib/logger';
6 | import useLoadingToast from '@/hooks/toast/useLoadingToast';
7 |
8 | import Button from '@/components/buttons/Button';
9 | import Input from '@/components/forms/Input';
10 | import PasswordInput from '@/components/forms/PasswordInput';
11 | import withAuth from '@/components/hoc/withAuth';
12 | import Layout from '@/components/layout/Layout';
13 | import BackToHome from '@/components/links/BackToHome';
14 | import UnstyledLink from '@/components/links/UnstyledLink';
15 | import Logo from '@/components/Logo';
16 | import NextImage from '@/components/NextImage';
17 | import Seo from '@/components/Seo';
18 |
19 | import useAuthStore from '@/store/useAuthStore';
20 |
21 | import { DEFAULT_TOAST_MESSAGE } from '@/constant/toast';
22 |
23 | import { ApiReturn } from '@/types/api';
24 | import { User } from '@/types/auth';
25 |
26 | type LoginData = {
27 | email: string;
28 | password: string;
29 | };
30 |
31 | export default withAuth(LoginPage, 'auth');
32 | function LoginPage() {
33 | const isLoading = useLoadingToast();
34 |
35 | //#region //*=========== Store ===========
36 | const login = useAuthStore.useLogin();
37 | //#endregion //*======== Store ===========
38 |
39 | //#region //*============== Form
40 | const methods = useForm({
41 | mode: 'onTouched',
42 | defaultValues: {
43 | email: 'me@email.com',
44 | password: 'password',
45 | },
46 | });
47 | const { handleSubmit } = methods;
48 | //#endregion //*============== Form
49 |
50 | //#region //*============== Form Submit
51 | const onSubmit: SubmitHandler = (data) => {
52 | logger({ data }, 'signin.tsx line 36');
53 | let tempToken: string;
54 |
55 | toast.promise(
56 | apiMock
57 | .post(`/login`, data)
58 | .then((res) => {
59 | const { token } = res.data.data;
60 | tempToken = token;
61 | localStorage.setItem('token', token);
62 |
63 | return apiMock.get>('/me');
64 | })
65 | .then((user) => {
66 | login({
67 | ...user.data.data,
68 | token: tempToken,
69 | });
70 | }),
71 | {
72 | ...DEFAULT_TOAST_MESSAGE,
73 | success: 'Successfully logged in',
74 | }
75 | );
76 |
77 | return;
78 | };
79 | //#endregion //*============== Form Submit
80 |
81 | return (
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | Login
96 |
97 |
98 | You can use any email and password you want.
99 |
100 |
101 |
102 |
134 |
135 |
136 |
137 |
144 |
145 |
146 |
147 |
148 | );
149 | }
150 |
--------------------------------------------------------------------------------
/src/pages/optional.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import Button from '@/components/buttons/Button';
4 | import StyledCode from '@/components/content/StyledCode';
5 | import StyledJSON from '@/components/content/StyledJSON';
6 | import TryAlert from '@/components/content/TryAlert';
7 | import withAuth from '@/components/hoc/withAuth';
8 | import Layout from '@/components/layout/Layout';
9 | import BackToHome from '@/components/links/BackToHome';
10 | import ButtonLink from '@/components/links/ButtonLink';
11 | import Seo from '@/components/Seo';
12 |
13 | import useAuthStore from '@/store/useAuthStore';
14 |
15 | export default withAuth(OptionalPage, 'optional');
16 | function OptionalPage() {
17 | const isAuthenticated = useAuthStore.useIsAuthenticated();
18 | const user = useAuthStore.useUser();
19 | const logout = useAuthStore.useLogout();
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | Optional Page
31 |
32 |
33 | {`export default withAuth(OptionalPage, 'optional');
34 | function OptionalPage() { /* react component here */ }`}
35 |
36 |
37 | Use this for pages that doesn't require the user to be
38 | authenticated but it can access the user object
39 |
40 |
41 | Behavior
42 |
43 | -
44 | This page is accessible to all users
45 | {isAuthenticated ? (
46 | <>
47 |
48 | You're logged in, you should see the user object below
49 |
50 |
51 |
56 | Logout
57 |
58 |
59 | >
60 | ) : (
61 | <>
62 |
63 | You're logged out, you should see that the user object
64 | is null
65 |
66 |
67 |
68 | After you login, you'll be redirected right to this
69 | page, then you can see the user object below.
70 |
71 |
76 | Login
77 |
78 |
79 | >
80 | )}
81 |
82 |
83 | User Object
84 |
85 | const user = useAuthStore.useUser();
86 |
87 |
88 |
89 | isAuthenticated
90 |
91 | const isAuthenticated = useAuthStore.useIsAuthenticated();
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/src/pages/protected.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import * as React from 'react';
3 |
4 | import Button from '@/components/buttons/Button';
5 | import StyledCode from '@/components/content/StyledCode';
6 | import StyledJSON from '@/components/content/StyledJSON';
7 | import TryAlert from '@/components/content/TryAlert';
8 | import withAuth from '@/components/hoc/withAuth';
9 | import Layout from '@/components/layout/Layout';
10 | import BackToHome from '@/components/links/BackToHome';
11 | import ButtonLink from '@/components/links/ButtonLink';
12 | import Seo from '@/components/Seo';
13 |
14 | import useAuthStore from '@/store/useAuthStore';
15 |
16 | export default withAuth(ProtectedPage, 'all');
17 | function ProtectedPage() {
18 | const isAuthenticated = useAuthStore.useIsAuthenticated();
19 | const user = useAuthStore.useUser();
20 | const logout = useAuthStore.useLogout();
21 |
22 | const router = useRouter();
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | Protected Page
34 |
35 |
36 | {`export default withAuth(ProtectedPage, 'all');
37 | function ProtectedPage() { /* react component here */ }`}
38 |
39 |
40 | Behavior
41 |
42 | -
43 | Unauthenticated users{' '}
44 | will be redirected to
LOGIN_ROUTE
(default:{' '}
45 | /login
), without any content flashing
46 |
47 | -
48 | Authenticated users{' '}
49 | will see this page in this following scenario:
50 |
51 |
52 | Logout
53 |
54 |
55 |
56 |
57 | -
58 | Direct visit using link → user will see a
59 | loading page while the
withAuth
component
60 | checks the token, then this page will be shown
61 |
62 | {
66 | router.reload();
67 | }}
68 | >
69 | Reload This Page
70 |
71 |
72 |
73 | -
74 | Visit from other page (
75 |
router.push
) → user will see this page
76 | immediately
77 |
78 | Go to home, then come back here
79 |
80 | Home
81 |
82 |
83 |
84 |
85 |
86 |
87 | User Object
88 |
89 | const user = useAuthStore.useUser();
90 |
91 |
92 |
93 | isAuthenticated
94 |
95 | const isAuthenticated = useAuthStore.useIsAuthenticated();
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/src/pages/sandbox/auth-rq.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import { useRouter } from 'next/router';
3 | import * as React from 'react';
4 |
5 | import { mockQuery } from '@/lib/axios-mock';
6 | import useRQWithToast from '@/hooks/toast/useRQWithToast';
7 |
8 | import Button from '@/components/buttons/Button';
9 | import Layout from '@/components/layout/Layout';
10 | import Seo from '@/components/Seo';
11 |
12 | import { ApiReturn } from '@/types/api';
13 | import { User } from '@/types/auth';
14 |
15 | export default function AuthPage() {
16 | const router = useRouter();
17 | const { data: data } = useRQWithToast(
18 | useQuery, Error>(['/me'], mockQuery)
19 | );
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | {
32 | localStorage.setItem('token', 'dummy-token');
33 | router.reload();
34 | }}
35 | >
36 | Set Token
37 |
38 | {
41 | localStorage.removeItem('token');
42 | router.reload();
43 | }}
44 | >
45 | Remove Token
46 |
47 |
48 |
49 |
50 | {JSON.stringify(data ?? {}, null, 2)}
51 |
52 |
53 |
54 |
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/src/pages/sandbox/auth.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import toast from 'react-hot-toast';
3 |
4 | import apiMock from '@/lib/axios-mock';
5 | import { getFromLocalStorage } from '@/lib/helper';
6 |
7 | import Button from '@/components/buttons/Button';
8 | import Layout from '@/components/layout/Layout';
9 | import Seo from '@/components/Seo';
10 |
11 | import { DEFAULT_TOAST_MESSAGE } from '@/constant/toast';
12 |
13 | export default function AuthPage() {
14 | const [, triggerRerender] = React.useState(false);
15 | const [data, setData] = React.useState();
16 |
17 | const getData = () => {
18 | toast.promise(
19 | apiMock.get('/me').then((res) => setData(res.data)),
20 | {
21 | ...DEFAULT_TOAST_MESSAGE,
22 | }
23 | );
24 | };
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | {
37 | localStorage.setItem('token', 'dummy-token');
38 | triggerRerender((prev) => !prev);
39 | }}
40 | >
41 | Set Token
42 |
43 | {
46 | localStorage.removeItem('token');
47 | triggerRerender((prev) => !prev);
48 | }}
49 | >
50 | Remove Token
51 |
52 |
53 | Fetch Data
54 |
55 |
56 |
57 | token: {getFromLocalStorage('token') ?? '-'}
58 |
59 |
60 | {JSON.stringify(data ?? {}, null, 2)}
61 |
62 |
63 |
64 |
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/src/pages/sandbox/rhf.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { FormProvider, useForm } from 'react-hook-form';
3 |
4 | import Button from '@/components/buttons/Button';
5 | import DatePicker from '@/components/forms/DatePicker';
6 | import DropzoneInput from '@/components/forms/DropzoneInput';
7 | import Input from '@/components/forms/Input';
8 | import SelectInput from '@/components/forms/SelectInput';
9 | import TextArea from '@/components/forms/TextArea';
10 | import Layout from '@/components/layout/Layout';
11 | import Seo from '@/components/Seo';
12 |
13 | export default function RHFSandbox() {
14 | //#region //*=========== Form ===========
15 | const methods = useForm({
16 | mode: 'onTouched',
17 | });
18 | const { handleSubmit } = methods;
19 | //#endregion //*======== Form ===========
20 |
21 | //#region //*=========== Form Submit ===========
22 | const onSubmit = (data: unknown) => {
23 | // eslint-disable-next-line no-console
24 | console.log(data);
25 | return;
26 | };
27 | //#endregion //*======== Form Submit ===========
28 |
29 | return (
30 |
31 |
32 |
33 |
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/pages/sandbox/toast-rq.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import * as React from 'react';
3 | import toast from 'react-hot-toast';
4 |
5 | import api from '@/lib/axios';
6 | import useLoadingToast from '@/hooks/toast/useLoadingToast';
7 | import useRQWithToast from '@/hooks/toast/useRQWithToast';
8 |
9 | import Button from '@/components/buttons/Button';
10 | import Seo from '@/components/Seo';
11 |
12 | import { DEFAULT_TOAST_MESSAGE } from '@/constant/toast';
13 |
14 | export type DataType = {
15 | id: number;
16 | title: string;
17 | completed: boolean;
18 | };
19 |
20 | const queryFn = async () => {
21 | const { data } = await api.get('https://jsonplaceholder.typicode.com/todos');
22 | return data;
23 | };
24 |
25 | export default function SandboxPage() {
26 | const isLoading = useLoadingToast();
27 |
28 | const { data: queryData } = useRQWithToast(
29 | useQuery(['/statistics'], queryFn)
30 | );
31 |
32 | return (
33 | <>
34 |
35 |
36 |
37 |
38 |
toast.success('Hello!')}>Open Toast
39 |
42 | toast.promise(
43 | new Promise(function (resolve) {
44 | setTimeout(resolve, 1000);
45 | }),
46 | {
47 | ...DEFAULT_TOAST_MESSAGE,
48 | }
49 | )
50 | }
51 | >
52 | Submit
53 |
54 | {queryData && (
55 |
{JSON.stringify(queryData)}
56 | )}
57 |
58 |
59 | >
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/src/store/useAuthStore.tsx:
--------------------------------------------------------------------------------
1 | import { createSelectorHooks } from 'auto-zustand-selectors-hook';
2 | import produce from 'immer';
3 | import create from 'zustand';
4 |
5 | import { User } from '@/types/auth';
6 |
7 | type AuthStoreType = {
8 | user: User | null;
9 | isAuthenticated: boolean;
10 | isLoading: boolean;
11 | login: (user: User) => void;
12 | logout: () => void;
13 | stopLoading: () => void;
14 | };
15 |
16 | const useAuthStoreBase = create((set) => ({
17 | user: null,
18 | isAuthenticated: false,
19 | isLoading: true,
20 | login: (user) => {
21 | localStorage.setItem('token', user.token);
22 | set(
23 | produce((state) => {
24 | state.isAuthenticated = true;
25 | state.user = user;
26 | })
27 | );
28 | },
29 | logout: () => {
30 | localStorage.removeItem('token');
31 | set(
32 | produce((state) => {
33 | state.isAuthenticated = false;
34 | state.user = null;
35 | })
36 | );
37 | },
38 | stopLoading: () => {
39 | set(
40 | produce((state) => {
41 | state.isLoading = false;
42 | })
43 | );
44 | },
45 | }));
46 |
47 | const useAuthStore = createSelectorHooks(useAuthStoreBase);
48 |
49 | export default useAuthStore;
50 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | /* #region /**=========== Primary Color =========== */
7 | --tw-color-primary-50: 240 249 255;
8 | --tw-color-primary-100: 224 242 254;
9 | --tw-color-primary-200: 186 230 253;
10 | --tw-color-primary-300: 125 211 252;
11 | --tw-color-primary-400: 56 189 248;
12 | --tw-color-primary-500: 14 165 233;
13 | --tw-color-primary-600: 2 132 199;
14 | --tw-color-primary-700: 3 105 161;
15 | --tw-color-primary-800: 7 89 133;
16 | --tw-color-primary-900: 12 74 110;
17 | --color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0f9ff */
18 | --color-primary-100: rgb(var(--tw-color-primary-100)); /* #e0f2fe */
19 | --color-primary-200: rgb(var(--tw-color-primary-200)); /* #bae6fd */
20 | --color-primary-300: rgb(var(--tw-color-primary-300)); /* #7dd3fc */
21 | --color-primary-400: rgb(var(--tw-color-primary-400)); /* #38bdf8 */
22 | --color-primary-500: rgb(var(--tw-color-primary-500)); /* #0ea5e9 */
23 | --color-primary-600: rgb(var(--tw-color-primary-600)); /* #0284c7 */
24 | --color-primary-700: rgb(var(--tw-color-primary-700)); /* #0369a1 */
25 | --color-primary-800: rgb(var(--tw-color-primary-800)); /* #075985 */
26 | --color-primary-900: rgb(var(--tw-color-primary-900)); /* #0c4a6e */
27 | /* #endregion /**======== Primary Color =========== */
28 | }
29 |
30 | @layer base {
31 | /* inter var - latin */
32 | @font-face {
33 | font-family: 'Inter';
34 | font-style: normal;
35 | font-weight: 100 900;
36 | font-display: optional;
37 | src: url('/fonts/inter-var-latin.woff2') format('woff2');
38 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
39 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212,
40 | U+2215, U+FEFF, U+FFFD;
41 | }
42 |
43 | .cursor-newtab {
44 | cursor: url('/images/new-tab.png') 10 10, pointer;
45 | }
46 |
47 | /* #region /**=========== Typography =========== */
48 | .h0 {
49 | @apply font-primary text-3xl font-bold md:text-5xl;
50 | }
51 |
52 | h1,
53 | .h1 {
54 | @apply font-primary text-2xl font-bold md:text-4xl;
55 | }
56 |
57 | h2,
58 | .h2 {
59 | @apply font-primary text-xl font-bold md:text-3xl;
60 | }
61 |
62 | h3,
63 | .h3 {
64 | @apply font-primary text-lg font-bold md:text-2xl;
65 | }
66 |
67 | h4,
68 | .h4 {
69 | @apply font-primary text-base font-bold md:text-lg;
70 | }
71 |
72 | body,
73 | .p {
74 | @apply font-primary text-sm md:text-base;
75 | }
76 | /* #endregion /**======== Typography =========== */
77 |
78 | .layout {
79 | /* 1100px */
80 | max-width: 68.75rem;
81 | @apply mx-auto w-11/12;
82 | }
83 |
84 | .bg-dark a.custom-link {
85 | @apply border-gray-200 hover:border-gray-200/0;
86 | }
87 |
88 | /* Class to adjust with sticky footer */
89 | .min-h-main {
90 | @apply min-h-[calc(100vh-56px)];
91 | }
92 | }
93 |
94 | @layer utilities {
95 | .animated-underline {
96 | background-image: linear-gradient(#33333300, #33333300),
97 | linear-gradient(
98 | to right,
99 | var(--color-primary-400),
100 | var(--color-primary-500)
101 | );
102 | background-size: 100% 2px, 0 2px;
103 | background-position: 100% 100%, 0 100%;
104 | background-repeat: no-repeat;
105 | }
106 | @media (prefers-reduced-motion: no-preference) {
107 | .animated-underline {
108 | transition: 0.3s ease;
109 | transition-property: background-size, color, background-color,
110 | border-color;
111 | }
112 | }
113 | .animated-underline:hover,
114 | .animated-underline:focus-visible {
115 | background-size: 0 2px, 100% 2px;
116 | }
117 | }
118 |
119 | /* #region /**=========== Datepicker =========== */
120 | .react-datepicker-wrapper {
121 | display: block;
122 | width: 100%;
123 | }
124 |
125 | .react-datepicker__navigation.react-datepicker__navigation--previous,
126 | .react-datepicker__navigation.react-datepicker__navigation--next {
127 | top: 6px;
128 | }
129 |
130 | .react-datepicker__header__dropdown.react-datepicker__header__dropdown--select {
131 | padding: 0 5px;
132 | }
133 |
134 | .react-datepicker__header__dropdown {
135 | margin-top: 0.5rem;
136 | }
137 |
138 | .react-datepicker__year-select,
139 | .react-datepicker__month-select {
140 | padding-top: 0.2rem;
141 | padding-bottom: 0.2rem;
142 | padding-left: 0.7rem;
143 | border-radius: 0.25rem;
144 | }
145 |
146 | /* Selected date color */
147 | .react-datepicker__day--selected,
148 | .react-datepicker__day--in-selecting-range,
149 | .react-datepicker__day--in-range,
150 | .react-datepicker__month-text--selected,
151 | .react-datepicker__month-text--in-selecting-range,
152 | .react-datepicker__month-text--in-range,
153 | .react-datepicker__quarter-text--selected,
154 | .react-datepicker__quarter-text--in-selecting-range,
155 | .react-datepicker__quarter-text--in-range,
156 | .react-datepicker__year-text--selected,
157 | .react-datepicker__year-text--in-selecting-range,
158 | .react-datepicker__year-text--in-range,
159 | .react-datepicker__day--keyboard-selected,
160 | .react-datepicker__month-text--keyboard-selected,
161 | .react-datepicker__quarter-text--keyboard-selected,
162 | .react-datepicker__year-text--keyboard-selected {
163 | @apply !bg-primary-500;
164 | }
165 | /* #endregion /**======== Datepicker =========== */
166 |
--------------------------------------------------------------------------------
/src/types/api.ts:
--------------------------------------------------------------------------------
1 | export type ApiReturn = {
2 | code: string;
3 | data: T;
4 | };
5 |
--------------------------------------------------------------------------------
/src/types/auth.ts:
--------------------------------------------------------------------------------
1 | export type User = {
2 | id: number;
3 | name: string;
4 | token: string;
5 | };
6 |
--------------------------------------------------------------------------------
/src/types/dropzone.ts:
--------------------------------------------------------------------------------
1 | import { FileWithPath } from 'react-dropzone';
2 |
3 | export type FileWithPreview = FileWithPath & { preview: string };
4 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | const { fontFamily } = require('tailwindcss/defaultTheme');
3 |
4 | /** @type {import('tailwindcss').Config} */
5 | module.exports = {
6 | content: ['./src/**/*.{js,jsx,ts,tsx}'],
7 | theme: {
8 | extend: {
9 | fontFamily: {
10 | primary: ['Inter', ...fontFamily.sans],
11 | },
12 | colors: {
13 | primary: {
14 | // Customize it on globals.css :root
15 | 50: 'rgb(var(--tw-color-primary-50) / )',
16 | 100: 'rgb(var(--tw-color-primary-100) / )',
17 | 200: 'rgb(var(--tw-color-primary-200) / )',
18 | 300: 'rgb(var(--tw-color-primary-300) / )',
19 | 400: 'rgb(var(--tw-color-primary-400) / )',
20 | 500: 'rgb(var(--tw-color-primary-500) / )',
21 | 600: 'rgb(var(--tw-color-primary-600) / )',
22 | 700: 'rgb(var(--tw-color-primary-700) / )',
23 | 800: 'rgb(var(--tw-color-primary-800) / )',
24 | 900: 'rgb(var(--tw-color-primary-900) / )',
25 | },
26 | dark: '#222222',
27 | },
28 | keyframes: {
29 | flicker: {
30 | '0%, 19.999%, 22%, 62.999%, 64%, 64.999%, 70%, 100%': {
31 | opacity: 0.99,
32 | filter:
33 | 'drop-shadow(0 0 1px rgba(252, 211, 77)) drop-shadow(0 0 15px rgba(245, 158, 11)) drop-shadow(0 0 1px rgba(252, 211, 77))',
34 | },
35 | '20%, 21.999%, 63%, 63.999%, 65%, 69.999%': {
36 | opacity: 0.4,
37 | filter: 'none',
38 | },
39 | },
40 | shimmer: {
41 | '0%': {
42 | backgroundPosition: '-700px 0',
43 | },
44 | '100%': {
45 | backgroundPosition: '700px 0',
46 | },
47 | },
48 | },
49 | animation: {
50 | flicker: 'flicker 3s linear infinite',
51 | shimmer: 'shimmer 1.3s linear infinite',
52 | },
53 | },
54 | },
55 | plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
56 | };
57 |
--------------------------------------------------------------------------------
/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 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "baseUrl": ".",
17 | "paths": {
18 | "@/*": ["./src/*"],
19 | "~/*": ["./public/*"]
20 | },
21 | "incremental": true
22 | },
23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
24 | "exclude": ["node_modules"],
25 | "moduleResolution": ["node_modules", ".next", "node"]
26 | }
27 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "headers": [
3 | {
4 | "source": "/fonts/inter-var-latin.woff2",
5 | "headers": [
6 | {
7 | "key": "Cache-Control",
8 | "value": "public, max-age=31536000, immutable"
9 | }
10 | ]
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------