diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..9e4bd3e --- /dev/null +++ b/.env.sample @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL= diff --git a/.prettierrc b/.prettierrc index d1dfe67..4b388bd 100644 --- a/.prettierrc +++ b/.prettierrc @@ -6,5 +6,6 @@ "tabWidth": 2, "printWidth": 80, "arrowParens": "always", - "bracketSpacing": true + "bracketSpacing": true, + "proseWrap": "preserve" } diff --git a/eslint.config.mjs b/eslint.config.mjs index d65e81a..06e24d7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,10 +24,15 @@ export default [ { rules: { 'react/react-in-jsx-scope': 'off', - '@typescript-eslint/promise-function-async': 'warn', - '@typescript-eslint/explicit-function-return-type': 'warn', - '@typescript-eslint/consistent-type-assertions': 'warn', - '@typescript-eslint/naming-convention': 'warn', + '@typescript-eslint/promise-function-async': 'error', + '@typescript-eslint/consistent-type-assertions': 'error', + '@typescript-eslint/naming-convention': 'off', + }, + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, ]; diff --git a/jest.setup.ts b/jest.setup.ts index 7b0828b..8b19a3d 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1 +1,4 @@ import '@testing-library/jest-dom'; +import fetchMock from 'jest-fetch-mock'; + +fetchMock.enableMocks(); diff --git a/package.json b/package.json index a86843a..0c77bee 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,22 @@ "build": "next build", "start": "next start", "lint": "next lint", + "lintTest": "eslint ./src/__test__", "format": "prettier --check --ignore-path .gitignore --ignore-path pnpm-lock.yaml .", - "test": "jest", - "testLint": "eslint ./src/__test__" + "test": "jest" }, "dependencies": { + "@tanstack/react-query": "^5.61.3", + "@testing-library/user-event": "^14.5.2", + "@types/js-cookie": "^3.0.6", + "jest-fetch-mock": "^3.0.3", + "js-cookie": "^3.0.5", "next": "14.2.18", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "react-hook-form": "^7.53.2", + "react-toastify": "^10.0.6", + "return-fetch": "^0.4.6" }, "devDependencies": { "@eslint/js": "^9.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be90e67..3b5ca77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,21 @@ settings: importers: .: dependencies: + '@tanstack/react-query': + specifier: ^5.61.3 + version: 5.61.3(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.5.2(@testing-library/dom@10.4.0) + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 + jest-fetch-mock: + specifier: ^3.0.3 + version: 3.0.3 + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 next: specifier: 14.2.18 version: 14.2.18(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -16,6 +31,15 @@ importers: react-dom: specifier: ^18 version: 18.3.1(react@18.3.1) + react-hook-form: + specifier: ^7.53.2 + version: 7.53.2(react@18.3.1) + react-toastify: + specifier: ^10.0.6 + version: 10.0.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + return-fetch: + specifier: ^0.4.6 + version: 0.4.6 devDependencies: '@eslint/js': specifier: ^9.15.0 @@ -792,6 +816,19 @@ packages: integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==, } + '@tanstack/query-core@5.60.6': + resolution: + { + integrity: sha512-tI+k0KyCo1EBJ54vxK1kY24LWj673ujTydCZmzEZKAew4NqZzTaVQJEuaG1qKj2M03kUHN46rchLRd+TxVq/zQ==, + } + + '@tanstack/react-query@5.61.3': + resolution: + { + integrity: sha512-c3Oz9KaCBapGkRewu7AJLhxE9BVqpMcHsd3KtFxSd7FSCu2qGwqfIN37zbSGoyk6Ix9LGZBNHQDPI6GpWABnmA==, + } + peerDependencies: + react: ^18 || ^19 '@testing-library/dom@10.4.0': resolution: { @@ -824,6 +861,14 @@ packages: '@types/react-dom': optional: true + '@testing-library/user-event@14.5.2': + resolution: + { + integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==, + } + engines: { node: '>=12', npm: '>=6' } + peerDependencies: + '@testing-library/dom': '>=7.21.4' '@tootallnate/once@2.0.0': resolution: { @@ -915,6 +960,12 @@ packages: integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==, } + '@types/js-cookie@3.0.6': + resolution: + { + integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==, + } + '@types/jsdom@20.0.1': resolution: { @@ -1552,6 +1603,13 @@ packages: } engines: { node: '>=12' } + clsx@2.1.1: + resolution: + { + integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==, + } + engines: { node: '>=6' } + co@4.6.0: resolution: { @@ -1618,6 +1676,12 @@ packages: integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==, } + cross-fetch@3.1.8: + resolution: + { + integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==, + } + cross-spawn@7.0.6: resolution: { @@ -3057,6 +3121,12 @@ packages: } engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + jest-fetch-mock@3.0.3: + resolution: + { + integrity: sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==, + } + jest-get-type@29.6.3: resolution: { @@ -3201,6 +3271,13 @@ packages: } hasBin: true + js-cookie@3.0.5: + resolution: + { + integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==, + } + engines: { node: '>=14' } + js-tokens@4.0.0: resolution: { @@ -3539,6 +3616,18 @@ packages: sass: optional: true + node-fetch@2.7.0: + resolution: + { + integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==, + } + engines: { node: 4.x || >=6.0.0 } + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-int64@0.4.0: resolution: { @@ -3902,6 +3991,12 @@ packages: } engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + promise-polyfill@8.3.0: + resolution: + { + integrity: sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==, + } + prompts@2.4.2: resolution: { @@ -3954,6 +4049,15 @@ packages: peerDependencies: react: ^18.3.1 + react-hook-form@7.53.2: + resolution: + { + integrity: sha512-YVel6fW5sOeedd1524pltpHX+jgU2u3DSDtXEaBORNdqiNrsX/nUI/iGXONegttg0mJVnfrIkiV0cmTU6Oo2xw==, + } + engines: { node: '>=18.0.0' } + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@16.13.1: resolution: { @@ -3972,6 +4076,15 @@ packages: integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==, } + react-toastify@10.0.6: + resolution: + { + integrity: sha512-yYjp+omCDf9lhZcrZHKbSq7YMuK0zcYkDFTzfRFgTXkTFHZ1ToxwAonzA4JI5CxA91JpjFLmwEsZEgfYfOqI1A==, + } + peerDependencies: + react: '>=18' + react-dom: '>=18' + react@18.3.1: resolution: { @@ -4087,6 +4200,12 @@ packages: } hasBin: true + return-fetch@0.4.6: + resolution: + { + integrity: sha512-uI0dmvEnVqX98/+s5VBRguLHofJE1Ot+Yi7DSGljc4pt2tkcfhfSXaom2W78IEnx8lYTjvBWfxh9BTHXF9MigA==, + } + reusify@1.0.4: resolution: { @@ -4486,6 +4605,11 @@ packages: } engines: { node: '>=6' } + tr46@0.0.3: + resolution: + { + integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==, + } tr46@3.0.0: resolution: { @@ -4701,6 +4825,12 @@ packages: integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==, } + webidl-conversions@3.0.1: + resolution: + { + integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==, + } + webidl-conversions@7.0.0: resolution: { @@ -4729,6 +4859,12 @@ packages: } engines: { node: '>=12' } + whatwg-url@5.0.0: + resolution: + { + integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==, + } + which-boxed-primitive@1.0.2: resolution: { @@ -5393,6 +5529,13 @@ snapshots: '@swc/counter': 0.1.3 tslib: 2.8.1 + '@tanstack/query-core@5.60.6': {} + + '@tanstack/react-query@5.61.3(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.60.6 + react: 18.3.1 + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.26.2 @@ -5424,6 +5567,10 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': + dependencies: + '@testing-library/dom': 10.4.0 + '@tootallnate/once@2.0.0': {} '@tsconfig/node10@1.0.11': {} @@ -5476,6 +5623,8 @@ snapshots: expect: 29.7.0 pretty-format: 29.7.0 + '@types/js-cookie@3.0.6': {} + '@types/jsdom@20.0.1': dependencies: '@types/node': 20.17.6 @@ -5929,6 +6078,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clsx@2.1.1: {} + co@4.6.0: {} collect-v8-coverage@1.0.2: {} @@ -5966,6 +6117,11 @@ snapshots: create-require@1.1.1: {} + cross-fetch@3.1.8: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -7022,6 +7178,13 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 + jest-fetch-mock@3.0.3: + dependencies: + cross-fetch: 3.1.8 + promise-polyfill: 8.3.0 + transitivePeerDependencies: + - encoding + jest-get-type@29.6.3: {} jest-haste-map@29.7.0: @@ -7223,6 +7386,8 @@ snapshots: jiti@1.21.6: {} + js-cookie@3.0.5: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -7417,6 +7582,10 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-int64@0.4.0: {} node-releases@2.0.18: {} @@ -7614,6 +7783,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + promise-polyfill@8.3.0: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -7643,12 +7814,22 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-hook-form@7.53.2(react@18.3.1): + dependencies: + react: 18.3.1 + react-is@16.13.1: {} react-is@17.0.2: {} react-is@18.3.1: {} + react-toastify@10.0.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -7715,6 +7896,7 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + return-fetch@0.4.6: {} reusify@1.0.4: {} rimraf@3.0.2: @@ -7980,6 +8162,8 @@ snapshots: universalify: 0.2.0 url-parse: 1.5.10 + tr46@0.0.3: {} + tr46@3.0.0: dependencies: punycode: 2.3.1 @@ -8123,6 +8307,8 @@ snapshots: dependencies: makeerror: 1.0.12 + webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} whatwg-encoding@2.0.0: @@ -8136,6 +8322,11 @@ snapshots: tr46: 3.0.0 webidl-conversions: 7.0.0 + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.0.2: dependencies: is-bigint: 1.0.4 diff --git a/readme.md b/readme.md index e215bc4..483ead0 100644 --- a/readme.md +++ b/readme.md @@ -1,36 +1,20 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Velog Dashboard -## Getting Started +![](https://cdn.jsdelivr.net/gh/five-standard/images@main/Back-VD.png) -First, run the development server: +## 실행 -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` +- `git clone https://github.com/Check-Data-Out/velog-dashboard-v2-fe.git` +- `cd velog-dashboard-v2-fe` +- `pnpm install` +- `pnpm dev` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## 린팅 -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +- `pnpm lint` (lint only pages) +- `pnpm lintTest` (lint only tests) +- `pnpm format` (prettier) -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## 테스팅 -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- `pnpm test` (test all pages & components) diff --git a/src/__test__/app.test.ts b/src/__test__/app.test.ts deleted file mode 100644 index 0150f07..0000000 --- a/src/__test__/app.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -test('1 === 1', () => { - expect(1).toBe(1); -}); diff --git a/src/__test__/login.test.tsx b/src/__test__/login.test.tsx new file mode 100644 index 0000000..c3a1cd2 --- /dev/null +++ b/src/__test__/login.test.tsx @@ -0,0 +1,112 @@ +import { act, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import Page from '@/app/(login)/page'; +import fetchMock from 'jest-fetch-mock'; +import { renderWithQueryClient } from '@/utils/test-util'; +import { ToastContainer } from 'react-toastify'; +import { useRouter } from 'next/navigation'; + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})); + +const getElements = () => { + const buttonEl = screen.getByRole('button'); + const accessInputEl = screen.getByPlaceholderText( + 'Access Token을 입력하세요', + ); + const refreshInputEl = screen.getByPlaceholderText( + 'Refresh Token을 입력하세요', + ); + + return { buttonEl, accessInputEl, refreshInputEl }; +}; + +const renderPage = () => { + renderWithQueryClient( + <> + + + , + ); +}; + +describe('로그인 화면에서', () => { + beforeEach(() => { + (useRouter as jest.Mock).mockImplementation(() => ({})); + }); + + it('입력 칸이 하나라도 비어있으면 버튼이 비활성화된다.', async () => { + renderPage(); + + const { buttonEl, accessInputEl } = getElements(); + + await userEvent.type(accessInputEl, 'access'); + + expect(buttonEl).toBeDisabled(); + }); + + it('액세스 토큰과 리프레시 토큰을 입력하면 버튼이 활성화된다', async () => { + renderPage(); + + const { buttonEl, accessInputEl, refreshInputEl } = getElements(); + + await userEvent.type(accessInputEl, 'access'); + await userEvent.type(refreshInputEl, 'refresh'); + + expect(buttonEl).toBeEnabled(); + }); + + describe('API 요청에서', () => { + // it('서버가 응답하지 않으면 오류 토스트가 표기된다', async () => { + // renderPage(); + + // fetchMock.mockAbortOnce(); + + // const { buttonEl, accessInputEl, refreshInputEl } = getElements(); + + // await userEvent.type(accessInputEl, 'invalid_access'); + // await userEvent.type(refreshInputEl, 'invalid_refresh'); + // await act(async () => buttonEl.click()); + + // const toastEl = screen.getByText('유효하지 않은 토큰 (404)'); + // expect(toastEl).toBeInTheDocument(); + // }); + + it('액세스 토큰이 비정상적이면 오류 토스트가 표기된다', async () => { + renderPage(); + + fetchMock.mockRejectOnce(new Error()); + + const { buttonEl, accessInputEl, refreshInputEl } = getElements(); + + await userEvent.type(accessInputEl, 'invalid_access'); + await userEvent.type(refreshInputEl, 'invalid_refresh'); + await act(async () => buttonEl.click()); + + const toastEl = screen.getByText('유효하지 않은 토큰 (404)'); + expect(toastEl).toBeInTheDocument(); + }); + + it('액세스 토큰이 정상적이면 페이지를 대시보드로 이동시킨다', async () => { + renderPage(); + + const replace = jest.fn(); + (useRouter as jest.Mock).mockImplementation(() => ({ replace })); + + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => ({}), + } as Response); + + const { buttonEl, accessInputEl, refreshInputEl } = getElements(); + + await userEvent.type(accessInputEl, 'access'); + await userEvent.type(refreshInputEl, 'refresh'); + await act(async () => buttonEl.click()); + + expect(replace).toHaveBeenCalledWith('/main'); + }); + }); +}); diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..c546001 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,18 @@ +import returnFetch from 'return-fetch'; + +const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL; + +if (!BASE_URL) { + throw new Error('BASE_URL가 ENV에서 설정되지 않았습니다.'); +} + +export const instance = returnFetch({ + baseUrl: BASE_URL, + headers: { Accept: 'application/json' }, + interceptors: { + response: async (response) => { + if (!response.ok) throw response; + return response; + }, + }, +}); diff --git a/src/app/(login)/Content.tsx b/src/app/(login)/Content.tsx new file mode 100644 index 0000000..18cd2e8 --- /dev/null +++ b/src/app/(login)/Content.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { Button, Input } from '@/components'; +import { useForm } from 'react-hook-form'; +import { useMutation } from '@tanstack/react-query'; +import { instance } from '../../api'; +import { useRouter } from 'next/navigation'; +import { toast } from 'react-toastify'; + +interface formVo { + access_token: string; + refresh_token: string; +} + +export const Content = () => { + const { replace } = useRouter(); + + const { + register, + handleSubmit, + formState: { isValid }, + } = useForm({ mode: 'onChange' }); + + const { mutate } = useMutation({ + mutationFn: async (data: formVo) => + await instance('/login', { + method: 'POST', + headers: { + cookie: `access_token=${data.access_token};refresh_token=${data.refresh_token}`, + }, + }), + onSuccess: () => replace('/main'), + onError: (res: Response) => { + toast.error(`${res.statusText} (${res.status})`); + }, + }); + + const onSubmit = (data: formVo) => { + mutate(data); + }; + + return ( +
+
+

+ Velog Dashboard +

+ + + +
+
+ ); +}; diff --git a/src/app/(login)/page.tsx b/src/app/(login)/page.tsx index 2b5d451..d76e16c 100644 --- a/src/app/(login)/page.tsx +++ b/src/app/(login)/page.tsx @@ -1,3 +1,11 @@ -export default function Home() { - return
; +import { Content } from './Content'; +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: '로그인', + description: '대시보드 페이지에 진입하기 전 표시되는 로그인 페이지', +}; + +export default function Page() { + return ; } diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx deleted file mode 100644 index 2b5d451..0000000 --- a/src/app/(main)/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Home() { - return
; -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8510c6e..763ebb5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,17 +1,9 @@ +import { ToastContainer } from 'react-toastify'; +import { Noto_Sans_KR } from 'next/font/google'; import type { Metadata } from 'next'; import './globals.css'; -// import localFont from 'next/font/local'; - -// const geistSans = localFont({ -// src: './fonts/GeistVF.woff', -// variable: '--font-geist-sans', -// weight: '100 900', -// }); -// const geistMono = localFont({ -// src: './fonts/GeistMonoVF.woff', -// variable: '--font-geist-mono', -// weight: '100 900', -// }); +import { QueryProvider } from '@/utils/QueryProvider'; +import 'react-toastify/dist/ReactToastify.css'; export const metadata: Metadata = { title: 'Velog Dashboard', @@ -21,6 +13,10 @@ export const metadata: Metadata = { }, }; +const NotoSansKr = Noto_Sans_KR({ + subsets: ['latin'], +}); + export default function RootLayout({ children, }: Readonly<{ @@ -28,7 +24,12 @@ export default function RootLayout({ }>) { return ( - {children} + + + + {children} + + ); } diff --git a/src/app/main/Content.tsx b/src/app/main/Content.tsx new file mode 100644 index 0000000..cbbddce --- /dev/null +++ b/src/app/main/Content.tsx @@ -0,0 +1,10 @@ +'use client'; + +export const Content = () => { + return ( +
+

대시보드

+

구현 예정입니다

+
+ ); +}; diff --git a/src/app/main/page.tsx b/src/app/main/page.tsx new file mode 100644 index 0000000..28bdaf4 --- /dev/null +++ b/src/app/main/page.tsx @@ -0,0 +1,11 @@ +import { Content } from './Content'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: '대시보드', + description: '각종 Velog 통계를 볼 수 있는 대시보드', +}; + +export default function Home() { + return ; +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 0000000..8fb73b0 --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,22 @@ +import { HTMLProps } from 'react'; +import { sizeStyle } from './size'; + +interface IProp extends Omit, 'size'> { + form?: 'large' | 'small'; + size: 'large' | 'medium' | 'small'; + type?: 'submit' | 'reset' | 'button'; +} + +const formStyle = { + large: 'h-[55px] rounded-sm', + small: 'pl-[20px] pr-[20px] w-fit h-8 rounded-[4px]', +}; + +export const Button = ({ form = 'small', size, children, ...rest }: IProp) => ( + +); diff --git a/src/components/Input.tsx b/src/components/Input.tsx new file mode 100644 index 0000000..fb90efa --- /dev/null +++ b/src/components/Input.tsx @@ -0,0 +1,28 @@ +import { HTMLProps } from 'react'; +import { sizeStyle } from './size'; +import { forwardRef, ForwardedRef } from 'react'; + +interface IProp extends Omit, 'size'> { + form?: 'large' | 'small'; + size: 'large' | 'medium' | 'small'; +} + +const formStyle = { + large: 'p-4 h-[48px] focus:border-primary-2 rounded-sm', + small: 'p-2 h-[38px] focus:border-border-3 rounded-[4px]', +}; + +export const Input = forwardRef( + ( + { form = 'large', size, ...rest }: IProp, + ref?: ForwardedRef | undefined, + ) => ( + + ), +); + +Input.displayName = 'Input'; diff --git a/src/components/index.ts b/src/components/index.ts index e69de29..49b7d5d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -0,0 +1,2 @@ +export * from './Input'; +export * from './Button'; diff --git a/src/components/size.ts b/src/components/size.ts new file mode 100644 index 0000000..ec9a9d5 --- /dev/null +++ b/src/components/size.ts @@ -0,0 +1,5 @@ +export const sizeStyle = { + large: 'w-[400px]', + medium: 'w-[200px]', + small: 'w-[100px]', +}; diff --git a/src/utils/QueryProvider.tsx b/src/utils/QueryProvider.tsx new file mode 100644 index 0000000..bd19f52 --- /dev/null +++ b/src/utils/QueryProvider.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactNode } from 'react'; + +const client = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +interface IProp { + children: React.ReactElement | React.ReactElement[] | ReactNode | ReactNode[]; +} + +export const QueryProvider = ({ children }: IProp) => { + return {children}; +}; diff --git a/src/utils/test-util.tsx b/src/utils/test-util.tsx new file mode 100644 index 0000000..3b7c37e --- /dev/null +++ b/src/utils/test-util.tsx @@ -0,0 +1,13 @@ +import { QueryClient } from '@tanstack/react-query'; +import { render, RenderResult } from '@testing-library/react'; +import { ReactElement } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +const newQueryClient = () => new QueryClient(); + +export const renderWithQueryClient = (element: ReactElement): RenderResult => { + return render( + + {element} + , + ); +}; diff --git a/tailwind.config.ts b/tailwind.config.ts index 4dcc94f..f54ac4f 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -7,13 +7,29 @@ const config: Config = { './src/app/**/*.{js,ts,jsx,tsx,mdx}', ], theme: { - extend: { - colors: { - background: 'var(--background)', - foreground: 'var(--foreground)', + colors: { + bg: { + main: '#121212', + sub: '#1E1E1E', + alt: '#252525', + }, + text: { + main: '#ECECEC', + sub: '#D9D9D9', + alt: '#ACACAC', + }, + border: { + main: '#2A2A2A', + sub: '#4D4D4D', + alt: '#E0E0E0', + }, + primary: { + main: '#96F2D7', + sub: '#63E6BE', }, }, }, plugins: [], }; + export default config; diff --git a/tsconfig.json b/tsconfig.json index 7b28589..0f0a25c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,12 @@ "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "eslint.config.mjs" + ], "exclude": ["node_modules"] }