From 98411d919d13737e3e41330dcfeecb03667b4a59 Mon Sep 17 00:00:00 2001 From: suha720 Date: Mon, 25 Aug 2025 11:11:31 +0900 Subject: [PATCH 01/13] =?UTF-8?q?[docs]=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EC=9D=98=20=EC=9D=B4=ED=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 396 ++++++++-------- package-lock.json | 1077 +++++++++++++++++++++++++++++++++++++++++++- package.json | 6 +- postcss.config.js | 6 + public/vite.svg | 1 - src/App.tsx | 26 +- src/index.css | 111 ++--- src/main.tsx | 13 +- tailwind.config.js | 9 + 9 files changed, 1372 insertions(+), 273 deletions(-) create mode 100644 postcss.config.js delete mode 100644 public/vite.svg create mode 100644 tailwind.config.js diff --git a/README.md b/README.md index 233d301..a3a13ae 100644 --- a/README.md +++ b/README.md @@ -1,237 +1,259 @@ -# Vite Typescript 프로젝트 세팅 +# 프로젝트 초기 기본 설정 -## 프로젝트 생성 +- main.tsx -```bash -npm create vite@latest . -> React 선택 -> TypeScript 선택 -``` - -## npm 설치 +```tsx +import { createRoot } from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; -```bash -npm i -npm run dev +createRoot(document.getElementById('root')!).render(); ``` -## React 18 마이그레이션 +- index.css (tailwind 설치 했을 때) -### 1. React 18 타입스크립트 +```css +@tailwind base; +@tailwind components; +@tailwind utilities; -```bash -npm i react@^18.3.1 react-dom@^18.3.1 -npm i -D @types/react@^18.3.5 @types/react-dom@^18.3.0 -``` +/* 글꼴 */ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&family=Noto+Sans:wght@400;500;700&display=swap'); +:root { + font-family: 'Noto Sans KR', 'Noto Sans', sans-serif; +} +body { + font-family: 'Noto Sans KR', 'Noto Sans', sans-serif; +} +:root { + --app-max-w: 720px; +} -### 2. ESLint 버전 8.x +/* 기본 html, body */ +html, +body, +#root { + height: 100%; +} -```bash -npm i -D eslint@^8.57.0 eslint-plugin-react@^7.37.5 eslint-plugin-react-hooks@^4.6.2 eslint-plugin-jsx-a11y@^6.10.0 eslint-plugin-import@^2.31.0 -``` +/* 전체 body 색상 지정해줌 (container) */ +body { + @apply bg-gray-50; + @apply transition-colors duration-300; /* 다크 모드 전환 부드럽게 */ +} -```bash -npm i -D @typescript-eslint/parser@^7.18.0 @typescript-eslint/eslint-plugin@^7.18.0 -``` +.container-app { + @apply mx-auto max-w-[var(--app-max-w)] px-4; +} -- 위 사항 설정 시 오류 발생 처리 (버전 충돌) +/* 테마 변수 */ +/* Light (기본) */ +:root { + --bg: 0 0% 98%; + --fg: 222 47% 11%; + --surface: 0 0% 100%; + --border: 220 13% 91%; + --primary: 245 83% 60%; /* 보라 */ + --primary-fg: 0 0% 100%; +} -```bash -npm remove typescript-eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser -``` +/* Dark */ +.theme-dark { + --bg: 222 47% 7%; + --fg: 210 40% 96%; + --surface: 222 47% 11%; + --border: 217 19% 27%; + --primary: 245 83% 60%; + --primary-fg: 0 0% 100%; +} -- 다시 ESLint 7 버전으로 다운그레이드 +/* Ocean */ +.theme-ocean { + --bg: 200 60% 97%; + --fg: 210 24% 20%; + --surface: 200 50% 99%; + --border: 206 15% 85%; + --primary: 200 90% 45%; /* 파랑 */ + --primary-fg: 0 0% 100%; +} -```bash -npm i -D eslint@^8.57.0 \ - @typescript-eslint/parser@^7.18.0 \ - @typescript-eslint/eslint-plugin@^7.18.0 +/* High Contrast */ +.theme-hc { + --bg: 0 0% 100%; + --fg: 0 0% 0%; + --surface: 0 0% 100%; + --border: 0 0% 0%; + --primary: 62 100% 50%; /* 노랑 */ + --primary-fg: 0 0% 0%; +} ``` -### 3. Prettier 안정된 버전 (3.x) +# 컴포넌트 생성 -```bash -npm i -D prettier@^3.3.3 eslint-config-prettier@^9.1.0 -``` +## 1. 함수 형태 -### 4. ESLint Prettier 설정 - -- `.eslintrc.json` 파일 생성 - -```json -{ - "root": true, - "env": { "browser": true, "es2022": true, "node": true }, - "parser": "@typescript-eslint/parser", - "parserOptions": { "ecmaVersion": "latest", "sourceType": "module" }, - "settings": { "react": { "version": "detect" } }, - "plugins": ["react", "react-hooks", "@typescript-eslint", "jsx-a11y", "import"], - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:react-hooks/recommended", - "plugin:@typescript-eslint/recommended", - "plugin:jsx-a11y/recommended", - "plugin:import/recommended", - "plugin:import/typescript", - "prettier" - ], - "rules": { - "react/react-in-jsx-scope": "off" - } -} -``` +- App.tsx `rfce` -- .prettierrc 파일 생성 - -```json -{ - "semi": true, - "singleQuote": true, - "trailingComma": "all", - "printWidth": 100, - "tabWidth": 2, - "arrowParens": "avoid" +```tsx +function App(): JSX.Element { + return
App
; } -``` - -- `eslint.config.js` 삭제 -- `.eslintignore` 생성 -``` -node_modules -build -dist +export default App; ``` -## VSCode 환경 설정 (팀이 공유) +## 2. 표현식 형태 -- `.vscode` 폴더 생성 -- `settings.json` 파일 생성 +- App.tsx `rafce` -```json -{ - "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.fixAll": "explicit" - }, - "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"] -} +```tsx +const App = (): JSX.Element => { + return
App
; +}; + +export default App; ``` -## npm 재설치 +- 컴포넌트 생성 및 활용 -- `pakage.lock.json`, `node_modules` 폴더 제거 후 +```tsx +const Sample = (): JSX.Element => { + return
샘플입니다.
; +}; + +const App = (): JSX.Element => { + return ( +
+

App

+ +
+ ); +}; -```bash -npm i +export default App; ``` -## VSCode 재실행 권장 - -## ESLint rules 및 tsconfig 환경 설정 +## 3. children 요소를 배치 시 오류 발생 -### 1. ESLint rules +- 문제 코드 (children 오류) -- `.eslintrc.json` rules 추가 +```tsx +// children : 타입이 없어서 오류가 발생함 +const Sample = ({ children }): JSX.Element => { + return
샘플입니다.
; +}; + +const App = (): JSX.Element => { + return ( +
+

App

+ +

자식입니다.

+
+
+ ); +}; -```json -"rules": { - "react/react-in-jsx-scope": "off", - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": "off" - } +export default App; ``` -### 2. tsconfig 에서는 `tsconfi.app.json` 관리 +- 문제 해결 (children 타입 없는 오류 해결 1) : ※ 추천하지 않음 ※ -```json -/* Linting */ - "noUnusedLocals": false, - "noUnusedParameters": false, -``` +```tsx +// React.FC 에 React 가 가지고 있는 children props 를 사용한다고 명시 +const Sample: React.FC = ({ children }): JSX.Element => { + return
샘플입니다.
; +}; + +const App = (): JSX.Element => { + return ( +
+

App

+ +

자식입니다.

+
+
+ ); +}; -### 3. 최종 세팅 결과물 - -- `.eslintrc.json` - -```json -{ - "root": true, - "env": { "browser": true, "es2022": true, "node": true }, - "parser": "@typescript-eslint/parser", - "parserOptions": { "ecmaVersion": "latest", "sourceType": "module" }, - "settings": { "react": { "version": "detect" } }, - "plugins": ["react", "react-hooks", "@typescript-eslint", "jsx-a11y", "import", "prettier"], - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:react-hooks/recommended", - "plugin:@typescript-eslint/recommended", - "plugin:jsx-a11y/recommended", - "plugin:import/recommended", - "plugin:import/typescript", - "prettier" - ], - "rules": { - "react/react-in-jsx-scope": "off", - "@typescript-eslint/no-unused-vars": "off", - "no-unused-vars": "off", - "prettier/prettier": "warn" - } -} +export default App; ``` -- `tsconfig.app.json` - -```json -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2022", - "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["src"] -} +- 문제 해결 (children 타입 없는 오류 해결 2) : ※ 적극 추천 - props 에 대해서 일관성 유지 ※ + +```tsx +type SampleProps = { + children?: React.ReactNode; +}; + +const Sample = ({ children }: SampleProps): JSX.Element => { + return
{children}
; +}; + +const App = (): JSX.Element => { + return ( +
+

App

+ +

자식입니다.

+
+
+ ); +}; + +export default App; ``` -- App.tsx 테스트 코드 +- 최종 모양 ( : JSX.Element 제거) ```tsx -function App() { - const nounuse = 1; - return
App
; -} +type SampleProps = { + children?: React.ReactNode; +}; + +const Sample = ({ children }: SampleProps) => { + return
{children}
; +}; + +const App = (): JSX.Element => { + return ( +
+

App

+ +

자식입니다.

+
+
+ ); +}; export default App; ``` -# Git 설정 +- 향후 컴포넌트는 JSX.Element 와 Props 타입을 작성하자 -```bash -git init -git remote add origin https://github.com/devyubi/til_vite_ts.git -git add . -git commit -m "[docs] 프로젝트 세팅" -git push origin main -``` +```tsx +type SampleProps = { + Children?: React.ReactNode; + age: number; + nickName: string; +}; + +const Sample = ({ age, nickName }: SampleProps) => { + return ( +
+ 나이는 {age}살, 별명이 {nickName} 인 샘플입니다. +
+ ); +}; + +const App = () => { + return ( +
+

App

+ +
+ ); +}; +export default App; +``` diff --git a/package-lock.json b/package-lock.json index e1578c6..3ebabb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "^5.0.0", + "autoprefixer": "^10.4.20", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.2", "eslint-plugin-import": "^2.32.0", @@ -26,11 +27,27 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", - "prettier": "^3.6.2", + "postcss": "^8.4.38", + "prettier": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.8", + "tailwindcss": "^3.4.10", "typescript": "~5.8.3", "vite": "^7.1.2" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -957,6 +974,53 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1034,6 +1098,17 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.32", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz", @@ -1702,6 +1777,34 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1906,6 +2009,44 @@ "node": ">= 0.4" } }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -1949,6 +2090,19 @@ "dev": true, "license": "MIT" }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -2065,6 +2219,16 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001737", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz", @@ -2103,6 +2267,44 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2123,6 +2325,16 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2152,6 +2364,19 @@ "node": ">= 8" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -2281,6 +2506,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2294,6 +2526,13 @@ "node": ">=8" } }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -2322,6 +2561,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.208", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.208.tgz", @@ -3250,6 +3496,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3729,6 +4006,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -3836,6 +4126,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", @@ -4108,6 +4408,32 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4234,6 +4560,23 @@ "node": ">= 0.8.0" } }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4339,6 +4682,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4346,6 +4699,18 @@ "dev": true, "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -4379,6 +4744,26 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4389,6 +4774,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -4580,6 +4975,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4630,16 +5032,40 @@ "dev": true, "license": "MIT" }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, "engines": { - "node": ">=8" - } - }, + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4660,6 +5086,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -4671,9 +5117,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", "dev": true, "funding": [ { @@ -4691,14 +5137,148 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4710,9 +5290,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, "license": "MIT", "bin": { @@ -4725,6 +5305,85 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.8.tgz", + "integrity": "sha512-dGu3kdm7SXPkiW4nzeWKCl3uoImdd5CTZEJGxyypEPL37Wj0HT2pLqjrvSei1nTeuQfO4PUfjeW5cTUNRLZ4sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig-melody": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig-melody": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4810,6 +5469,29 @@ "node": ">=0.10.0" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5202,6 +5884,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -5236,6 +5931,76 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -5362,6 +6127,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -5385,6 +6164,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5411,6 +6234,44 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwindcss": { + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", + "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -5418,6 +6279,29 @@ "dev": true, "license": "MIT" }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -5492,6 +6376,13 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -5696,6 +6587,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", @@ -5802,6 +6700,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5917,6 +6844,107 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -5931,6 +6959,19 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index c046e36..3648470 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "^5.0.0", + "autoprefixer": "^10.4.20", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.2", "eslint-plugin-import": "^2.32.0", @@ -28,7 +29,10 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", - "prettier": "^3.6.2", + "postcss": "^8.4.38", + "prettier": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.8", + "tailwindcss": "^3.4.10", "typescript": "~5.8.3", "vite": "^7.1.2" } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 3a95e69..5f4f98a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,24 @@ -function App() { - const nounuse = 1; - return
App
; -} +type SampleProps = { + Children?: React.ReactNode; + age: number; + nickName: string; +}; + +const Sample = ({ age, nickName }: SampleProps) => { + return ( +
+ 나이는 {age}살, 별명이 {nickName} 인 샘플입니다. +
+ ); +}; + +const App = () => { + return ( +
+

App

+ +
+ ); +}; export default App; diff --git a/src/index.css b/src/index.css index 08a3ac9..ff0452c 100644 --- a/src/index.css +++ b/src/index.css @@ -1,68 +1,73 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; +@tailwind base; +@tailwind components; +@tailwind utilities; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; +/* 글꼴 */ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&family=Noto+Sans:wght@400;500;700&display=swap'); +:root { + font-family: 'Noto Sans KR', 'Noto Sans', sans-serif; } - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; +body { + font-family: 'Noto Sans KR', 'Noto Sans', sans-serif; } -a:hover { - color: #535bf2; +:root { + --app-max-w: 720px; } +/* 기본 html, body */ +html, +body, +#root { + height: 100%; +} + +/* 전체 body 색상 지정해줌 (container) */ body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; + @apply bg-gray-50; + @apply transition-colors duration-300; /* 다크 모드 전환 부드럽게 */ } -h1 { - font-size: 3.2em; - line-height: 1.1; +.container-app { + @apply mx-auto max-w-[var(--app-max-w)] px-4; } -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; +/* 테마 변수 */ +/* Light (기본) */ +:root { + --bg: 0 0% 98%; + --fg: 222 47% 11%; + --surface: 0 0% 100%; + --border: 220 13% 91%; + --primary: 245 83% 60%; /* 보라 */ + --primary-fg: 0 0% 100%; } -button:hover { - border-color: #646cff; + +/* Dark */ +.theme-dark { + --bg: 222 47% 7%; + --fg: 210 40% 96%; + --surface: 222 47% 11%; + --border: 217 19% 27%; + --primary: 245 83% 60%; + --primary-fg: 0 0% 100%; } -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; + +/* Ocean */ +.theme-ocean { + --bg: 200 60% 97%; + --fg: 210 24% 20%; + --surface: 200 50% 99%; + --border: 206 15% 85%; + --primary: 200 90% 45%; /* 파랑 */ + --primary-fg: 0 0% 100%; } -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } +/* High Contrast */ +.theme-hc { + --bg: 0 0% 100%; + --fg: 0 0% 0%; + --surface: 0 0% 100%; + --border: 0 0% 0%; + --primary: 62 100% 50%; /* 노랑 */ + --primary-fg: 0 0% 0%; } diff --git a/src/main.tsx b/src/main.tsx index bef5202..35f6c8f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,5 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { createRoot } from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; -createRoot(document.getElementById('root')!).render( - - - , -) +createRoot(document.getElementById('root')!).render(); diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..c189a4a --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,9 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [], + theme: { + extend: {}, + }, + plugins: [], +} + From 0dd75a71a111caee60747872d73cc35fe18b373f Mon Sep 17 00:00:00 2001 From: suha720 Date: Mon, 25 Aug 2025 12:20:19 +0900 Subject: [PATCH 02/13] =?UTF-8?q?[docs]=20useState=20=EC=9D=98=20=EC=9D=B4?= =?UTF-8?q?=ED=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 370 ++++++++++++++-------------------- package-lock.json | 113 ++++------- package.json | 6 +- src/App.tsx | 22 +- src/components/Counter.tsx | 39 ++++ src/components/NameEditor.tsx | 51 +++++ tailwind.config.js | 5 +- tsconfig.app.json | 4 + 8 files changed, 296 insertions(+), 314 deletions(-) create mode 100644 src/components/Counter.tsx create mode 100644 src/components/NameEditor.tsx diff --git a/README.md b/README.md index a3a13ae..93a8d4b 100644 --- a/README.md +++ b/README.md @@ -1,259 +1,197 @@ -# 프로젝트 초기 기본 설정 - -- main.tsx - -```tsx -import { createRoot } from 'react-dom/client'; -import App from './App.tsx'; -import './index.css'; - -createRoot(document.getElementById('root')!).render(); -``` - -- index.css (tailwind 설치 했을 때) - -```css -@tailwind base; -@tailwind components; -@tailwind utilities; - -/* 글꼴 */ -@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&family=Noto+Sans:wght@400;500;700&display=swap'); -:root { - font-family: 'Noto Sans KR', 'Noto Sans', sans-serif; -} -body { - font-family: 'Noto Sans KR', 'Noto Sans', sans-serif; -} -:root { - --app-max-w: 720px; -} - -/* 기본 html, body */ -html, -body, -#root { - height: 100%; -} - -/* 전체 body 색상 지정해줌 (container) */ -body { - @apply bg-gray-50; - @apply transition-colors duration-300; /* 다크 모드 전환 부드럽게 */ -} - -.container-app { - @apply mx-auto max-w-[var(--app-max-w)] px-4; -} - -/* 테마 변수 */ -/* Light (기본) */ -:root { - --bg: 0 0% 98%; - --fg: 222 47% 11%; - --surface: 0 0% 100%; - --border: 220 13% 91%; - --primary: 245 83% 60%; /* 보라 */ - --primary-fg: 0 0% 100%; -} - -/* Dark */ -.theme-dark { - --bg: 222 47% 7%; - --fg: 210 40% 96%; - --surface: 222 47% 11%; - --border: 217 19% 27%; - --primary: 245 83% 60%; - --primary-fg: 0 0% 100%; -} - -/* Ocean */ -.theme-ocean { - --bg: 200 60% 97%; - --fg: 210 24% 20%; - --surface: 200 50% 99%; - --border: 206 15% 85%; - --primary: 200 90% 45%; /* 파랑 */ - --primary-fg: 0 0% 100%; -} - -/* High Contrast */ -.theme-hc { - --bg: 0 0% 100%; - --fg: 0 0% 0%; - --surface: 0 0% 100%; - --border: 0 0% 0%; - --primary: 62 100% 50%; /* 노랑 */ - --primary-fg: 0 0% 0%; +# useState + +## 기본 폴더 구조 생성 + +- /src/components 폴더 생성 +- /src/components/Counter.jsx 폴더 생성 +- 실제 프로젝트에서 tsx 가 어렵다면, jsx 로 작업 후 AI에게 변환 요청해도 무방함 (추천하진 않음...) + +### ts 프로젝트에서 jsx 사용하도록 설정하기 + +- `tsconfig.app.json` 수정 + +```json +{ + "compilerOptions": { + "composite": true, // ← 프로젝트 참조 사용 시 필요 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "allowJs": true, + "checkJs": false, + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] } ``` -# 컴포넌트 생성 - -## 1. 함수 형태 - -- App.tsx `rfce` - -```tsx -function App(): JSX.Element { - return
App
; +- `.vscode 폴더의 settings.json` 수정 + +```json +{ + "files.autoSave": "off", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit" + }, + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], + "typescript.suggest.autoImports": true, + "typescript.suggest.paths": true, + "javascript.suggest.autoImports": true, + "javascript.suggest.paths": true, + + // 워크스페이스 TS 사용(강력 권장) + "typescript.tsdk": "node_modules/typescript/lib" } - -export default App; ``` -## 2. 표현식 형태 - -- App.tsx `rafce` - -```tsx -const App = (): JSX.Element => { - return
App
; -}; - -export default App; -``` - -- 컴포넌트 생성 및 활용 - -```tsx -const Sample = (): JSX.Element => { - return
샘플입니다.
; -}; - -const App = (): JSX.Element => { +## useState 활용해 보기 + +```jsx +import { useState } from 'react'; + +const Counter = () => { + const [count, setCount] = useState(0); + const add = () => { + setCount(count + 1); + }; + const minus = () => { + setCount(count - 1); + }; + const reset = () => { + setCount(0); + }; return (
-

App

- +

Counter : {count}

+ + +
); }; -export default App; +export default Counter; ``` -## 3. children 요소를 배치 시 오류 발생 +- 위의 코드를 tsx 로 마이그레이션 진행 +- 확장자를 `tsx` 로 변경 -- 문제 코드 (children 오류) - -```tsx -// children : 타입이 없어서 오류가 발생함 -const Sample = ({ children }): JSX.Element => { - return
샘플입니다.
; -}; - -const App = (): JSX.Element => { - return ( -
-

App

- -

자식입니다.

-
-
- ); -}; - -export default App; ``` +const add: () => void = () => { setCount(count + 1); }; +// ↓ + +버튼의 onClick 안에서 **직접 setCount(count + 1)**를 쓰고 있음. -- 문제 해결 (children 타입 없는 오류 해결 1) : ※ 추천하지 않음 ※ - -```tsx -// React.FC 에 React 가 가지고 있는 children props 를 사용한다고 명시 -const Sample: React.FC = ({ children }): JSX.Element => { - return
샘플입니다.
; -}; +따라서 별도로 add(), minus(), reset() 함수를 만들어서 호출할 필요가 없는 거예요. -const App = (): JSX.Element => { - return ( -
-

App

- -

자식입니다.

-
-
- ); -}; - -export default App; +즉, 지금처럼 inline 함수를 써도 완전히 동일하게 동작합니다. ``` -- 문제 해결 (children 타입 없는 오류 해결 2) : ※ 적극 추천 - props 에 대해서 일관성 유지 ※ +### 요약 -```tsx -type SampleProps = { - children?: React.ReactNode; -}; +- ✅ inline으로 setCount(...) 써도 문제 없음. -const Sample = ({ children }: SampleProps): JSX.Element => { - return
{children}
; -}; +- ✅ 함수로 따로 만들어서 쓰는 건 가독성/재사용성 목적. -const App = (): JSX.Element => { - return ( -
-

App

- -

자식입니다.

-
-
- ); -}; - -export default App; -``` - -- 최종 모양 ( : JSX.Element 제거) +- 즉, 지금 코드에서는 기능상 필요 없어서 없어도 잘 돌아가는 것. ```tsx -type SampleProps = { - children?: React.ReactNode; -}; - -const Sample = ({ children }: SampleProps) => { - return
{children}
; -}; +import { useState } from 'react'; + +type CounterProps = {}; +type VoidFun = () => void; + +const Counter = ({}: CounterProps): JSX.Element => { + const [count, setCount] = useState(0); + const add: () => void = () => { + setCount(count + 1); + }; + const minus: () => void = () => { + setCount(count - 1); + }; + const reset: () => void = () => { + setCount(0); + }; -const App = (): JSX.Element => { return ( -
-

App

- -

자식입니다.

-
+
+

Counter: {count}

+
+ + + +
); }; -export default App; +export default Counter; ``` -- 향후 컴포넌트는 JSX.Element 와 Props 타입을 작성하자 +- 사용자 이름 편집 기능 예제 -```tsx -type SampleProps = { - Children?: React.ReactNode; - age: number; - nickName: string; -}; +- /src/components/NameEditor.jsx 폴더 생성 -const Sample = ({ age, nickName }: SampleProps) => { - return ( -
- 나이는 {age}살, 별명이 {nickName} 인 샘플입니다. -
- ); -}; +```jsx +import { useState } from 'react'; + +const NameEditor = () => { + const [name, setName] = useState(''); + const handleChange = e => { + setName(e.target.value); + }; + const handleClick = () => { + console.log('확인'); + setName(''); + }; -const App = () => { return (
-

App

- +

NameEditor : {name}

+
+ handleChange(e)} /> + +
); }; -export default App; +export default NameEditor; ``` + +- tsx 로 마이그레이션 : 확장자를 수정 diff --git a/package-lock.json b/package-lock.json index 3ebabb2..2aa5952 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "^5.0.0", - "autoprefixer": "^10.4.20", + "autoprefixer": "^10.4.21", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.2", "eslint-plugin-import": "^2.32.0", @@ -27,10 +27,10 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", - "postcss": "^8.4.38", + "postcss": "^8.5.6", "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.8", - "tailwindcss": "^3.4.10", + "tailwindcss": "^3.4.17", "typescript": "~5.8.3", "vite": "^7.1.2" } @@ -2010,9 +2010,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", "dev": true, "funding": [ { @@ -2030,11 +2030,11 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", + "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -4561,13 +4561,16 @@ } }, "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" } }, "node_modules/lines-and-columns": { @@ -5117,9 +5120,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -5137,9 +5140,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -5219,19 +5222,6 @@ } } }, - "node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, "node_modules/postcss-nested": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", @@ -6235,34 +6225,34 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", - "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", - "chokidar": "^3.5.3", + "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", - "fast-glob": "^3.3.0", + "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.21.0", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", @@ -6700,35 +6690,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vite/node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 3648470..46d4bfe 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "^5.0.0", - "autoprefixer": "^10.4.20", + "autoprefixer": "^10.4.21", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.2", "eslint-plugin-import": "^2.32.0", @@ -29,10 +29,10 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", - "postcss": "^8.4.38", + "postcss": "^8.5.6", "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.8", - "tailwindcss": "^3.4.10", + "tailwindcss": "^3.4.17", "typescript": "~5.8.3", "vite": "^7.1.2" } diff --git a/src/App.tsx b/src/App.tsx index 5f4f98a..927989a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,14 @@ -type SampleProps = { - Children?: React.ReactNode; - age: number; - nickName: string; -}; +import Counter from './components/Counter'; +import NameEditor from './components/NameEditor'; -const Sample = ({ age, nickName }: SampleProps) => { - return ( -
- 나이는 {age}살, 별명이 {nickName} 인 샘플입니다. -
- ); -}; - -const App = () => { +function App() { return (

App

- + +
); -}; +} export default App; diff --git a/src/components/Counter.tsx b/src/components/Counter.tsx new file mode 100644 index 0000000..471ad91 --- /dev/null +++ b/src/components/Counter.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; + +type CounterProps = {}; +type VoidFun = () => void; + +const Counter = ({}: CounterProps): JSX.Element => { + const [count, setCount] = useState(0); + const add: VoidFun = () => setCount(count + 1); + const minus: VoidFun = () => setCount(count - 1); + const reset: VoidFun = () => setCount(0); + + return ( +
+

Counter: {count}

+
+ + + +
+
+ ); +}; + +export default Counter; diff --git a/src/components/NameEditor.tsx b/src/components/NameEditor.tsx new file mode 100644 index 0000000..942eccb --- /dev/null +++ b/src/components/NameEditor.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; + +type NameEditorProps = { + children?: React.ReactNode; +}; + +const NameEditor = ({}: NameEditorProps) => { + const [name, setName] = useState(''); + + const handleChange = (e: React.ChangeEvent): void => { + setName(e.target.value); + }; + + const handleClick = (): void => { + console.log('확인:', name); + setName(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + console.log('Enter 입력함:', name); + setName(''); + } + }; + + return ( +
+

+ NameEditor: {name || '이름을 입력하세요'} +

+
+ + +
+
+ ); +}; + +export default NameEditor; diff --git a/tailwind.config.js b/tailwind.config.js index c189a4a..d21f1cd 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,9 +1,8 @@ /** @type {import('tailwindcss').Config} */ export default { - content: [], + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], theme: { extend: {}, }, plugins: [], -} - +}; diff --git a/tsconfig.app.json b/tsconfig.app.json index c98c841..e1cbe62 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "composite": true, // ← 프로젝트 참조 사용 시 필요 "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2022", "useDefineForClassFields": true, @@ -15,6 +16,9 @@ "noEmit": true, "jsx": "react-jsx", + "allowJs": true, + "checkJs": false, + /* Linting */ "strict": true, "noUnusedLocals": false, From cfb920399287c691063350990120d31043f82b4e Mon Sep 17 00:00:00 2001 From: suha720 Date: Tue, 26 Aug 2025 12:28:01 +0900 Subject: [PATCH 03/13] =?UTF-8?q?[docs]=20useState=EC=9D=98=20=EC=9D=B4?= =?UTF-8?q?=ED=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 548 +++++++++++++++++++++++++++++ src/App.tsx | 51 ++- src/components/User.tsx | 41 +++ src/components/todos/TodoItem.tsx | 58 +++ src/components/todos/TodoList.tsx | 30 ++ src/components/todos/TodoWrite.tsx | 47 +++ src/main.tsx | 2 +- src/types/todoType.ts | 6 + 8 files changed, 777 insertions(+), 6 deletions(-) create mode 100644 src/components/User.tsx create mode 100644 src/components/todos/TodoItem.tsx create mode 100644 src/components/todos/TodoList.tsx create mode 100644 src/components/todos/TodoWrite.tsx create mode 100644 src/types/todoType.ts diff --git a/README.md b/README.md index 93a8d4b..4e651df 100644 --- a/README.md +++ b/README.md @@ -195,3 +195,551 @@ export default NameEditor; ``` - tsx 로 마이그레이션 : 확장자를 수정 + +```tsx +import { useState } from 'react'; + +type NameEditorProps = { + children?: React.ReactNode; +}; +const NameEditor = ({}: NameEditorProps): JSX.Element => { + const [name, setName] = useState(''); + + const handleChange = (e: React.ChangeEvent): void => { + setName(e.target.value); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + console.log('Enter 입력함.'); + setName(''); + } + }; + const handleClick = (): void => { + console.log('확인'); + setName(''); + }; + return ( +
+

NameEditor : {name}

+
+ handleChange(e)} + onKeyDown={e => handleKeyDown(e)} + />{' '} + +
+
+ ); +}; + +export default NameEditor; +``` + +- /src/components/User.jsx 생성 + +```jsx +import { useState } from 'react'; + +const User = () => { + const [user, setUser] = useState({ name: '홍길동', age: 10 }); + const handleClick = () => { + setUser({ ...user, age: user.age + 1 }); + }; + return ( +
+

+ {' '} + User : {user.name}님의 나이는 {user.age}살입니다. +

+
+ +
+
+ ); +}; + +export default User; +``` + +- tsx 로 마이그레이션 + +```tsx +import { useEffect, useState } from 'react'; +type UserProps = { + children?: React.ReactNode; + name: string; + age: number; +}; +export type UserType = { + name: string; + age: number; +}; + +const User = ({ name, age }: UserProps): JSX.Element => { + const [user, setUser] = useState(null); + const handleClick = (): void => { + if (user) { + setUser({ ...user, age: user.age + 1 }); + } + }; + useEffect(() => { + setUser({ name, age }); + }, []); + return ( +
+

+ User :{' '} + {user ? ( + + {user.name}님의 나이는 {user.age}살 입니다. + + ) : ( + '사용자 정보가 없습니다.' + )} +

+
+ +
+
+ ); +}; + +export default User; +``` + +- App.tsx + +```tsx +import Counter from './components/Counter'; +import NameEditor from './components/NameEditor'; +import User from './components/User'; + +function App() { + return ( +
+

App

+ + + +
+ ); +} + +export default App; +``` + +## todos 만들기 + +### 1. 파일 구조 + +- src/component/todos 폴더 생성 +- src/component/todos/TodoList.jsx 파일 생성 + +```jsx +import TodoItem from './TodoItem'; + +const TodoList = ({ todos, toggleTodo, editTodo, deleteTodo }) => { + return ( +
+

TodoList

+
    + {todos.map(item => ( + + ))} +
+
+ ); +}; + +export default TodoList; +``` + +- src/component/todos/TodoWrite.jsx 파일 생성 + +```jsx +import { useState } from 'react'; + +const TodoWrite = ({ addTodo }) => { + const [title, setTitle] = useState(''); + + const handleChange = e => { + setTitle(e.target.value); + }; + const handleKeyDown = e => { + if (e.key === 'Enter') { + // 저장 + handleSave(); + } + }; + const handleSave = () => { + if (title.trim()) { + // 업데이트 + const newTodo = { id: Date.now.toString(), title: title, completed: false }; + addTodo(newTodo); + setTitle(''); + } + }; + + return ( +
+

할 일 작성

+
+ handleChange(e)} + onKeyDown={e => handleKeyDown(e)} + /> + +
+
+ ); +}; + +export default TodoWrite; +``` + +- src/component/todos/TododItem.jsx 파일 생성 + +```jsx +import { useEffect, useState } from 'react'; + +const TodoItem = ({ todo, toggleTodo, editTodo, deleteTodo }) => { + // 수정중인지 + const [isEdit, setIsEdit] = useState(false); + const [editTitle, setEditTitle] = useState(todo.title); + const handleChangeTitle = e => { + setEditTitle(e.target.value); + }; + const handleKeyDown = e => { + if (e.key === 'Enter') { + handleEditSave(); + } + }; + const handleEditSave = () => { + if (editTitle.trim()) { + editTodo(todo.id, editTitle); + setEditTitle(''); + setIsEdit(false); + } + }; + const handleEditCancel = () => { + setEditTitle(todo.title); + setIsEdit(false); + }; + return ( +
  • + {isEdit ? ( + <> + handleChangeTitle(e)} + onKeyDown={e => handleKeyDown(e)} + /> + + + + ) : ( + <> + toggleTodo(todo.id)} /> + {todo.title} + + + + )} +
  • + ); +}; + +export default TodoItem; +``` + +- App.jsx + +```jsx +import { useState } from 'react'; +import TodoList from './components/todos/TodoList'; +import TodoWrite from './components/todos/TodoWrite'; + +// 초기 값 +const initialTodos = [ + { id: '1', title: '할일 1', completed: false }, + { id: '2', title: '할일 2', completed: true }, + { id: '3', title: '할일 3', completed: false }, +]; + +function App() { + const [todos, setTodos] = useState(initialTodos); + // todos 업데이트 하기 + const addTodo = newTodo => { + setTodos([newTodo, ...todos]); + }; + // todo completed 토글하기 + const toggleTodo = id => { + const arr = todos.map(item => + item.id === id ? { ...item, completed: !item.completed } : item, + ); + setTodos(arr); + }; + // todo 삭제하기 + const deleteTodo = id => { + const arr = todos.filter(item => item.id !== id); + setTodos(arr); + }; + // todo 수정하기 + const editTodo = (id, editTitle) => { + const arr = todos.map(item => (item.id === id ? { ...item, title: editTitle } : item)); + setTodos(arr); + }; + + return ( +
    +

    할일 웹서비스

    +
    + + +
    +
    + ); +} + +export default App; +``` + +### 2. ts 마이그레이션 + +- /src/types 폴더 생성 +- /src/types/TodoTypes.ts 폴더 생성 + +```ts +// newTodoType = todos +export type NewTodoType = { + id: string; + title: string; + completed: boolean; +}; +``` + +- App.tsx (main.tsx에서 다시 import 하고 새로고침해야함) + +```tsx +import { useState } from 'react'; +import TodoWrite from './components/todos/TodoWrite'; +import TodoList from './components/todos/TodoList'; +import type { NewTodoType } from './types/todoType'; + +// 초기 값 +const initialTodos: NewTodoType[] = [ + { id: '1', title: '할일 1', completed: false }, + { id: '2', title: '할일 2', completed: true }, + { id: '3', title: '할일 3', completed: false }, +]; + +// todos 에 마우스 커서 올려보고 타입 안맞으면 useState(initialTodos) 적어주기 +// 맞으면 안적어도 됨. +function App() { + const [todos, setTodos] = useState(initialTodos); + // todos 업데이트 하기 + const addTodo = (newTodo: NewTodoType) => { + setTodos([newTodo, ...todos]); + }; + // todo completed 토글하기 + const toggleTodo = (id: string) => { + const arr = todos.map(item => + item.id === id ? { ...item, completed: !item.completed } : item, + ); + setTodos(arr); + }; + // todo 삭제하기 + const deleteTodo = (id: string) => { + const arr = todos.filter(item => item.id !== id); + setTodos(arr); + }; + // todo 수정하기 + const editTodo = (id: string, editTitle: string) => { + const arr = todos.map(item => (item.id === id ? { ...item, title: editTitle } : item)); + setTodos(arr); + }; + + return ( +
    +

    할일 웹서비스

    +
    + + +
    +
    + ); +} + +export default App; +``` + +- TodoItem.tsx + +```tsx +import { useEffect, useState } from 'react'; +import type { NewTodoType } from '../../types/todoType'; + +type TodoItemProps = { + todo: NewTodoType; + toggleTodo: (id: string) => void; + editTodo: (id: string, editTitle: string) => void; + deleteTodo: (id: string) => void; +}; + +const TodoItem = ({ todo, toggleTodo, editTodo, deleteTodo }: TodoItemProps) => { + // 수정중인지 + const [isEdit, setIsEdit] = useState(false); + const [editTitle, setEditTitle] = useState(todo.title); + const handleChangeTitle = (e: React.ChangeEvent) => { + setEditTitle(e.target.value); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleEditSave(); + } + }; + const handleEditSave = () => { + if (editTitle.trim()) { + editTodo(todo.id, editTitle); + setIsEdit(false); + } + }; + const handleEditCancel = () => { + setEditTitle(todo.title); + setIsEdit(false); + }; + return ( +
  • + {isEdit ? ( + <> + handleChangeTitle(e)} + onKeyDown={e => handleKeyDown(e)} + /> + + + + ) : ( + <> + toggleTodo(todo.id)} /> + {todo.title} + + + + )} +
  • + ); +}; + +export default TodoItem; +``` + +- TodoList.tsx + +```tsx +import type { NewTodoType } from '../../types/todoType'; +import TodoItem from './TodoItem'; + +export type TodoListProps = { + todos: NewTodoType[]; + toggleTodo: (id: string) => void; + editTodo: (id: string, editTitle: string) => void; + deleteTodo: (id: string) => void; +}; + +const TodoList = ({ todos, toggleTodo, editTodo, deleteTodo }: TodoListProps) => { + return ( +
    +

    TodoList

    +
      + {todos.map((item: any) => ( + + ))} +
    +
    + ); +}; + +export default TodoList; +``` + +- TodoWrite.tsx + +```tsx +import { useState } from 'react'; +import type { NewTodoType } from '../../types/todoType'; + +type TodoWriteProps = { + // children 이 있을 경우는 적지만, 없을 경우 굳이 안적어도 됨. (수업이라 적음) + children?: React.ReactNode; + addTodo: (newTodo: NewTodoType) => void; +}; + +const TodoWrite = ({ addTodo }: TodoWriteProps) => { + const [title, setTitle] = useState(''); + + const handleChange = (e: React.ChangeEvent) => { + setTitle(e.target.value); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + // 저장 + handleSave(); + } + }; + const handleSave = () => { + if (title.trim()) { + // 업데이트 + const newTodo = { id: Date.now().toString(), title: title, completed: false }; + addTodo(newTodo); + setTitle(''); + } + }; + + return ( +
    +

    할 일 작성

    +
    + handleChange(e)} + onKeyDown={e => handleKeyDown(e)} + /> + +
    +
    + ); +}; + +export default TodoWrite; +``` diff --git a/src/App.tsx b/src/App.tsx index 927989a..8552c76 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,53 @@ -import Counter from './components/Counter'; -import NameEditor from './components/NameEditor'; +import { useState } from 'react'; +import TodoWrite from './components/todos/TodoWrite'; +import TodoList from './components/todos/TodoList'; +import type { NewTodoType } from './types/todoType'; +// 초기 값 +const initialTodos: NewTodoType[] = [ + { id: '1', title: '할일 1', completed: false }, + { id: '2', title: '할일 2', completed: true }, + { id: '3', title: '할일 3', completed: false }, +]; + +// todos 에 마우스 커서 올려보고 타입 안맞으면 useState(initialTodos) 적어주기 +// 맞으면 안적어도 됨. function App() { + const [todos, setTodos] = useState(initialTodos); + // todos 업데이트 하기 + const addTodo = (newTodo: NewTodoType) => { + setTodos([newTodo, ...todos]); + }; + // todo completed 토글하기 + const toggleTodo = (id: string) => { + const arr = todos.map(item => + item.id === id ? { ...item, completed: !item.completed } : item, + ); + setTodos(arr); + }; + // todo 삭제하기 + const deleteTodo = (id: string) => { + const arr = todos.filter(item => item.id !== id); + setTodos(arr); + }; + // todo 수정하기 + const editTodo = (id: string, editTitle: string) => { + const arr = todos.map(item => (item.id === id ? { ...item, title: editTitle } : item)); + setTodos(arr); + }; + return (
    -

    App

    - - +

    할일 웹서비스

    +
    + + +
    ); } diff --git a/src/components/User.tsx b/src/components/User.tsx new file mode 100644 index 0000000..bfa586e --- /dev/null +++ b/src/components/User.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react'; +type UserProps = { + children?: React.ReactNode; + name: string; + age: number; +}; +export type UserType = { + name: string; + age: number; +}; + +const User = ({ name, age }: UserProps): JSX.Element => { + const [user, setUser] = useState(null); + const handleClick = (): void => { + if (user) { + setUser({ ...user, age: user.age + 1 }); + } + }; + useEffect(() => { + setUser({ name, age }); + }, []); + return ( +
    +

    + User :{' '} + {user ? ( + + {user.name}님의 나이는 {user.age}살 입니다. + + ) : ( + '사용자 정보가 없습니다.' + )} +

    +
    + +
    +
    + ); +}; + +export default User; diff --git a/src/components/todos/TodoItem.tsx b/src/components/todos/TodoItem.tsx new file mode 100644 index 0000000..8b65958 --- /dev/null +++ b/src/components/todos/TodoItem.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; +import type { NewTodoType } from '../../types/todoType'; + +type TodoItemProps = { + todo: NewTodoType; + toggleTodo: (id: string) => void; + editTodo: (id: string, editTitle: string) => void; + deleteTodo: (id: string) => void; +}; + +const TodoItem = ({ todo, toggleTodo, editTodo, deleteTodo }: TodoItemProps) => { + // 수정중인지 + const [isEdit, setIsEdit] = useState(false); + const [editTitle, setEditTitle] = useState(todo.title); + const handleChangeTitle = (e: React.ChangeEvent) => { + setEditTitle(e.target.value); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleEditSave(); + } + }; + const handleEditSave = () => { + if (editTitle.trim()) { + editTodo(todo.id, editTitle); + setIsEdit(false); + } + }; + const handleEditCancel = () => { + setEditTitle(todo.title); + setIsEdit(false); + }; + return ( +
  • + {isEdit ? ( + <> + handleChangeTitle(e)} + onKeyDown={e => handleKeyDown(e)} + /> + + + + ) : ( + <> + toggleTodo(todo.id)} /> + {todo.title} + + + + )} +
  • + ); +}; + +export default TodoItem; diff --git a/src/components/todos/TodoList.tsx b/src/components/todos/TodoList.tsx new file mode 100644 index 0000000..99b31e4 --- /dev/null +++ b/src/components/todos/TodoList.tsx @@ -0,0 +1,30 @@ +import type { NewTodoType } from '../../types/todoType'; +import TodoItem from './TodoItem'; + +export type TodoListProps = { + todos: NewTodoType[]; + toggleTodo: (id: string) => void; + editTodo: (id: string, editTitle: string) => void; + deleteTodo: (id: string) => void; +}; + +const TodoList = ({ todos, toggleTodo, editTodo, deleteTodo }: TodoListProps) => { + return ( +
    +

    TodoList

    +
      + {todos.map((item: any) => ( + + ))} +
    +
    + ); +}; + +export default TodoList; diff --git a/src/components/todos/TodoWrite.tsx b/src/components/todos/TodoWrite.tsx new file mode 100644 index 0000000..43da663 --- /dev/null +++ b/src/components/todos/TodoWrite.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import type { NewTodoType } from '../../types/todoType'; + +type TodoWriteProps = { + // children 이 있을 경우는 적지만, 없을 경우 굳이 안적어도 됨. (수업이라 적음) + children?: React.ReactNode; + addTodo: (newTodo: NewTodoType) => void; +}; + +const TodoWrite = ({ addTodo }: TodoWriteProps) => { + const [title, setTitle] = useState(''); + + const handleChange = (e: React.ChangeEvent) => { + setTitle(e.target.value); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + // 저장 + handleSave(); + } + }; + const handleSave = () => { + if (title.trim()) { + // 업데이트 + const newTodo = { id: Date.now().toString(), title: title, completed: false }; + addTodo(newTodo); + setTitle(''); + } + }; + + return ( +
    +

    할 일 작성

    +
    + handleChange(e)} + onKeyDown={e => handleKeyDown(e)} + /> + +
    +
    + ); +}; + +export default TodoWrite; diff --git a/src/main.tsx b/src/main.tsx index 35f6c8f..361e7cf 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,5 @@ import { createRoot } from 'react-dom/client'; -import App from './App.tsx'; import './index.css'; +import App from './App'; createRoot(document.getElementById('root')!).render(); diff --git a/src/types/todoType.ts b/src/types/todoType.ts new file mode 100644 index 0000000..e959a06 --- /dev/null +++ b/src/types/todoType.ts @@ -0,0 +1,6 @@ +// newTodoType +export type NewTodoType = { + id: string; + title: string; + completed: boolean; +}; From 7bd4721a7a39b7edbf4e04203671cfe98840055d Mon Sep 17 00:00:00 2001 From: suha720 Date: Wed, 27 Aug 2025 09:34:48 +0900 Subject: [PATCH 04/13] =?UTF-8?q?[docs]=20useState=20=EC=9D=98=20=EC=9D=B4?= =?UTF-8?q?=ED=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/todoType.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/todoType.ts b/src/types/todoType.ts index e959a06..6ad7a4a 100644 --- a/src/types/todoType.ts +++ b/src/types/todoType.ts @@ -1,4 +1,4 @@ -// newTodoType +// newTodoType = todos export type NewTodoType = { id: string; title: string; From 64f74b9af182e3c17c8aba6f07bd168c80a31e0a Mon Sep 17 00:00:00 2001 From: suha720 Date: Wed, 27 Aug 2025 12:03:23 +0900 Subject: [PATCH 05/13] =?UTF-8?q?[docs]=20context=20=EC=99=80=20useReducer?= =?UTF-8?q?=20=EC=9D=98=20=EC=9D=B4=ED=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 791 ++++++++--------------------- src/App.tsx | 52 +- src/components/todos/TodoItem.tsx | 11 +- src/components/todos/TodoList.tsx | 21 +- src/components/todos/TodoWrite.tsx | 7 +- src/contexts/TodoContext.tsx | 111 ++++ tsconfig.app.json | 4 +- 7 files changed, 345 insertions(+), 652 deletions(-) create mode 100644 src/contexts/TodoContext.tsx diff --git a/README.md b/README.md index 4e651df..ffd0240 100644 --- a/README.md +++ b/README.md @@ -1,328 +1,106 @@ -# useState - -## 기본 폴더 구조 생성 - -- /src/components 폴더 생성 -- /src/components/Counter.jsx 폴더 생성 -- 실제 프로젝트에서 tsx 가 어렵다면, jsx 로 작업 후 AI에게 변환 요청해도 무방함 (추천하진 않음...) - -### ts 프로젝트에서 jsx 사용하도록 설정하기 - -- `tsconfig.app.json` 수정 - -```json -{ - "compilerOptions": { - "composite": true, // ← 프로젝트 참조 사용 시 필요 - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2022", - "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - "allowJs": true, - "checkJs": false, - - /* Linting */ - "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["src"] -} -``` - -- `.vscode 폴더의 settings.json` 수정 - -```json -{ - "files.autoSave": "off", - "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.fixAll": "explicit" - }, - "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], - "typescript.suggest.autoImports": true, - "typescript.suggest.paths": true, - "javascript.suggest.autoImports": true, - "javascript.suggest.paths": true, - - // 워크스페이스 TS 사용(강력 권장) - "typescript.tsdk": "node_modules/typescript/lib" -} -``` - -## useState 활용해 보기 - -```jsx -import { useState } from 'react'; - -const Counter = () => { - const [count, setCount] = useState(0); - const add = () => { - setCount(count + 1); - }; - const minus = () => { - setCount(count - 1); - }; - const reset = () => { - setCount(0); - }; - return ( -
    -

    Counter : {count}

    - - - -
    - ); -}; - -export default Counter; -``` - -- 위의 코드를 tsx 로 마이그레이션 진행 -- 확장자를 `tsx` 로 변경 - -``` -const add: () => void = () => { setCount(count + 1); }; -// ↓ - -버튼의 onClick 안에서 **직접 setCount(count + 1)**를 쓰고 있음. - -따라서 별도로 add(), minus(), reset() 함수를 만들어서 호출할 필요가 없는 거예요. - -즉, 지금처럼 inline 함수를 써도 완전히 동일하게 동작합니다. -``` - -### 요약 - -- ✅ inline으로 setCount(...) 써도 문제 없음. - -- ✅ 함수로 따로 만들어서 쓰는 건 가독성/재사용성 목적. - -- 즉, 지금 코드에서는 기능상 필요 없어서 없어도 잘 돌아가는 것. - -```tsx -import { useState } from 'react'; - -type CounterProps = {}; -type VoidFun = () => void; - -const Counter = ({}: CounterProps): JSX.Element => { - const [count, setCount] = useState(0); - const add: () => void = () => { - setCount(count + 1); - }; - const minus: () => void = () => { - setCount(count - 1); - }; - const reset: () => void = () => { - setCount(0); - }; - - return ( -
    -

    Counter: {count}

    -
    - - - -
    -
    - ); -}; +# Context API 와 useReducer -export default Counter; -``` +- useState 를 대체하고, props 를 줄여보자 -- 사용자 이름 편집 기능 예제 +## 1. 기본 폴더 구성 및 파일 구조 -- /src/components/NameEditor.jsx 폴더 생성 +- /src/contexts 폴더 생성 +- /src/contexts/TodoContext.jsx 생성 ```jsx -import { useState } from 'react'; - -const NameEditor = () => { - const [name, setName] = useState(''); - const handleChange = e => { - setName(e.target.value); - }; - const handleClick = () => { - console.log('확인'); - setName(''); - }; - - return ( -
    -

    NameEditor : {name}

    -
    - handleChange(e)} /> - -
    -
    - ); -}; - -export default NameEditor; -``` - -- tsx 로 마이그레이션 : 확장자를 수정 +import { createContext, useContext, useReducer } from 'react'; -```tsx -import { useState } from 'react'; - -type NameEditorProps = { - children?: React.ReactNode; +// 1. 초기값 +const initialState = { + todos: [], }; -const NameEditor = ({}: NameEditorProps): JSX.Element => { - const [name, setName] = useState(''); +// 2. 리듀서 +// action 은 {type:"문자열", payload: 재료 } 형태 +function reducer(state, action) { + switch (action.type) { + case 'ADD': { + const { todo } = action.payload; + return { ...state, todos: [todo, ...state.todos] }; + } + case 'TOGGLE': { + const { id } = action.payload; + const arr = state.todos.map(item => + item.id === id ? { ...item, completed: !item.completed } : item, + ); + return { ...state, todos: arr }; + } + case 'DELETE': { + const { id } = action.payload; + const arr = state.todos.filter(item => item.id !== id); + return { ...state, todos: arr }; + } + case 'EDIT': { + const { id, title } = action.payload; + const arr = state.todos.map(item => (item.id === id ? { ...item, title } : item)); + return { ...state, todos: arr }; + } + default: + return state; + } +} +// 3. context 생성 +const TodoContext = createContext(); +// 4. provider 생성 +export const TodoProvider = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); - const handleChange = (e: React.ChangeEvent): void => { - setName(e.target.value); + // dispatch 를 위한 함수 표현식 모음 + const addTodo = newTodo => { + dispatch({ type: 'ADD', payload: { todo: newTodo } }); }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - console.log('Enter 입력함.'); - setName(''); - } + const toggleTodo = id => { + dispatch({ type: 'TOGGLE', payload: { id } }); }; - const handleClick = (): void => { - console.log('확인'); - setName(''); + const deleteTodo = id => { + dispatch({ type: 'DELETE', payload: { id } }); }; - return ( -
    -

    NameEditor : {name}

    -
    - handleChange(e)} - onKeyDown={e => handleKeyDown(e)} - />{' '} - -
    -
    - ); -}; - -export default NameEditor; -``` - -- /src/components/User.jsx 생성 - -```jsx -import { useState } from 'react'; - -const User = () => { - const [user, setUser] = useState({ name: '홍길동', age: 10 }); - const handleClick = () => { - setUser({ ...user, age: user.age + 1 }); + const editTodo = (id, editTitle) => { + dispatch({ type: 'EDIT', payload: { id, title: editTitle } }); }; - return ( -
    -

    - {' '} - User : {user.name}님의 나이는 {user.age}살입니다. -

    -
    - -
    -
    - ); -}; - -export default User; -``` -- tsx 로 마이그레이션 - -```tsx -import { useEffect, useState } from 'react'; -type UserProps = { - children?: React.ReactNode; - name: string; - age: number; -}; -export type UserType = { - name: string; - age: number; -}; - -const User = ({ name, age }: UserProps): JSX.Element => { - const [user, setUser] = useState(null); - const handleClick = (): void => { - if (user) { - setUser({ ...user, age: user.age + 1 }); - } + // value 전달할 값 + const value = { + todos: state.todos, + addTodo, + toggleTodo, + deleteTodo, + editTodo, }; - useEffect(() => { - setUser({ name, age }); - }, []); - return ( -
    -

    - User :{' '} - {user ? ( - - {user.name}님의 나이는 {user.age}살 입니다. - - ) : ( - '사용자 정보가 없습니다.' - )} -

    -
    - -
    -
    - ); + return {children}; }; -export default User; +// 5. custom hook 생성 +export function useTodos() { + const ctx = useContext(TodoContext); + if (!ctx) { + throw new Error('context를 찾을 수 없습니다.'); + } + return ctx; +} ``` - App.tsx ```tsx -import Counter from './components/Counter'; -import NameEditor from './components/NameEditor'; -import User from './components/User'; +import TodoList from './components/todos/TodoList'; +import TodoWrite from './components/todos/TodoWrite'; +import { TodoProvider } from './contexts/TodoContext'; function App() { return (
    -

    App

    - - - +

    할일 웹서비스

    + +
    + + +
    +
    ); } @@ -330,50 +108,26 @@ function App() { export default App; ``` -## todos 만들기 - -### 1. 파일 구조 - -- src/component/todos 폴더 생성 -- src/component/todos/TodoList.jsx 파일 생성 +- TodoWrite.tsx -```jsx -import TodoItem from './TodoItem'; +```tsx +import { useState } from 'react'; +import { useTodos } from '../../contexts/TodoContext'; -const TodoList = ({ todos, toggleTodo, editTodo, deleteTodo }) => { - return ( -
    -

    TodoList

    -
      - {todos.map(item => ( - - ))} -
    -
    - ); +type TodoWriteProps = { + // children 이 있을 경우는 적지만, 없을 경우 굳이 안적어도 됨. (수업이라 적음) + children?: React.ReactNode; }; -export default TodoList; -``` - -- src/component/todos/TodoWrite.jsx 파일 생성 - -```jsx -import { useState } from 'react'; - -const TodoWrite = ({ addTodo }) => { +const TodoWrite = ({}: TodoWriteProps) => { const [title, setTitle] = useState(''); + // context 사용 + const { addTodo } = useTodos(); - const handleChange = e => { + const handleChange = (e: React.ChangeEvent) => { setTitle(e.target.value); }; - const handleKeyDown = e => { + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { // 저장 handleSave(); @@ -382,7 +136,7 @@ const TodoWrite = ({ addTodo }) => { const handleSave = () => { if (title.trim()) { // 업데이트 - const newTodo = { id: Date.now.toString(), title: title, completed: false }; + const newTodo = { id: Date.now().toString(), title: title, completed: false }; addTodo(newTodo); setTitle(''); } @@ -407,208 +161,48 @@ const TodoWrite = ({ addTodo }) => { export default TodoWrite; ``` -- src/component/todos/TododItem.jsx 파일 생성 - -```jsx -import { useEffect, useState } from 'react'; - -const TodoItem = ({ todo, toggleTodo, editTodo, deleteTodo }) => { - // 수정중인지 - const [isEdit, setIsEdit] = useState(false); - const [editTitle, setEditTitle] = useState(todo.title); - const handleChangeTitle = e => { - setEditTitle(e.target.value); - }; - const handleKeyDown = e => { - if (e.key === 'Enter') { - handleEditSave(); - } - }; - const handleEditSave = () => { - if (editTitle.trim()) { - editTodo(todo.id, editTitle); - setEditTitle(''); - setIsEdit(false); - } - }; - const handleEditCancel = () => { - setEditTitle(todo.title); - setIsEdit(false); - }; - return ( -
  • - {isEdit ? ( - <> - handleChangeTitle(e)} - onKeyDown={e => handleKeyDown(e)} - /> - - - - ) : ( - <> - toggleTodo(todo.id)} /> - {todo.title} - - - - )} -
  • - ); -}; - -export default TodoItem; -``` - -- App.jsx - -```jsx -import { useState } from 'react'; -import TodoList from './components/todos/TodoList'; -import TodoWrite from './components/todos/TodoWrite'; - -// 초기 값 -const initialTodos = [ - { id: '1', title: '할일 1', completed: false }, - { id: '2', title: '할일 2', completed: true }, - { id: '3', title: '할일 3', completed: false }, -]; - -function App() { - const [todos, setTodos] = useState(initialTodos); - // todos 업데이트 하기 - const addTodo = newTodo => { - setTodos([newTodo, ...todos]); - }; - // todo completed 토글하기 - const toggleTodo = id => { - const arr = todos.map(item => - item.id === id ? { ...item, completed: !item.completed } : item, - ); - setTodos(arr); - }; - // todo 삭제하기 - const deleteTodo = id => { - const arr = todos.filter(item => item.id !== id); - setTodos(arr); - }; - // todo 수정하기 - const editTodo = (id, editTitle) => { - const arr = todos.map(item => (item.id === id ? { ...item, title: editTitle } : item)); - setTodos(arr); - }; - - return ( -
    -

    할일 웹서비스

    -
    - - -
    -
    - ); -} - -export default App; -``` - -### 2. ts 마이그레이션 - -- /src/types 폴더 생성 -- /src/types/TodoTypes.ts 폴더 생성 - -```ts -// newTodoType = todos -export type NewTodoType = { - id: string; - title: string; - completed: boolean; -}; -``` - -- App.tsx (main.tsx에서 다시 import 하고 새로고침해야함) +- TodoList.tsx ```tsx -import { useState } from 'react'; -import TodoWrite from './components/todos/TodoWrite'; -import TodoList from './components/todos/TodoList'; -import type { NewTodoType } from './types/todoType'; +import { useTodos } from '../../contexts/TodoContext'; +import TodoItem from './TodoItem'; -// 초기 값 -const initialTodos: NewTodoType[] = [ - { id: '1', title: '할일 1', completed: false }, - { id: '2', title: '할일 2', completed: true }, - { id: '3', title: '할일 3', completed: false }, -]; +export type TodoListProps = {}; -// todos 에 마우스 커서 올려보고 타입 안맞으면 useState(initialTodos) 적어주기 -// 맞으면 안적어도 됨. -function App() { - const [todos, setTodos] = useState(initialTodos); - // todos 업데이트 하기 - const addTodo = (newTodo: NewTodoType) => { - setTodos([newTodo, ...todos]); - }; - // todo completed 토글하기 - const toggleTodo = (id: string) => { - const arr = todos.map(item => - item.id === id ? { ...item, completed: !item.completed } : item, - ); - setTodos(arr); - }; - // todo 삭제하기 - const deleteTodo = (id: string) => { - const arr = todos.filter(item => item.id !== id); - setTodos(arr); - }; - // todo 수정하기 - const editTodo = (id: string, editTitle: string) => { - const arr = todos.map(item => (item.id === id ? { ...item, title: editTitle } : item)); - setTodos(arr); - }; +const TodoList = ({}: TodoListProps) => { + const { todos } = useTodos(); return (
    -

    할일 웹서비스

    -
    - - -
    +

    TodoList

    +
      + {todos.map((item: any) => ( + + ))} +
    ); -} +}; -export default App; +export default TodoList; ``` - TodoItem.tsx ```tsx -import { useEffect, useState } from 'react'; +import { useState } from 'react'; +import { useTodos } from '../../contexts/TodoContext'; import type { NewTodoType } from '../../types/todoType'; type TodoItemProps = { todo: NewTodoType; - toggleTodo: (id: string) => void; - editTodo: (id: string, editTitle: string) => void; - deleteTodo: (id: string) => void; }; -const TodoItem = ({ todo, toggleTodo, editTodo, deleteTodo }: TodoItemProps) => { +const TodoItem = ({ todo }: TodoItemProps) => { + const { toggleTodo, editTodo, deleteTodo } = useTodos(); + // 수정중인지 + const [isEdit, setIsEdit] = useState(false); const [editTitle, setEditTitle] = useState(todo.title); const handleChangeTitle = (e: React.ChangeEvent) => { @@ -657,89 +251,120 @@ const TodoItem = ({ todo, toggleTodo, editTodo, deleteTodo }: TodoItemProps) => export default TodoItem; ``` -- TodoList.tsx +## 2. TodoContext.jsx => ts 마이그레이션 + +- 확장자 `tsx` 로 변경 ( import 다시 실행 ) ```tsx -import type { NewTodoType } from '../../types/todoType'; -import TodoItem from './TodoItem'; +import React, { createContext, useContext, useReducer, type PropsWithChildren } from 'react'; +import type { NewTodoType } from '../types/todoType'; -export type TodoListProps = { +type TodosState = { todos: NewTodoType[]; - toggleTodo: (id: string) => void; - editTodo: (id: string, editTitle: string) => void; - deleteTodo: (id: string) => void; }; -const TodoList = ({ todos, toggleTodo, editTodo, deleteTodo }: TodoListProps) => { - return ( -
    -

    TodoList

    -
      - {todos.map((item: any) => ( - - ))} -
    -
    - ); +// 1. 초기값 +const initialState: TodosState = { + todos: [], }; -export default TodoList; -``` +enum TodoActionType { + ADD = 'ADD', + TOGGLE = 'TOGGLE', + DELETE = 'DELETE', + EDIT = 'EDIT', +} -- TodoWrite.tsx +// action type 정의 +type AddAction = { type: 'ADD'; payload: { todo: NewTodoType } }; +type ToggleAction = { type: 'TOGGLE'; payload: { id: string } }; +type DeleteAction = { type: 'DELETE'; payload: { id: string } }; +type EditAction = { type: 'EDIT'; payload: { id: string; title: string } }; +type TodoAction = AddAction | ToggleAction | DeleteAction | EditAction; + +// 2. 리듀서 +// action 은 {type:"문자열", payload: 재료 } 형태 +function reducer(state: TodosState, action: TodoAction) { + switch (action.type) { + case TodoActionType.ADD: { + const { todo } = action.payload; + return { ...state, todos: [todo, ...state.todos] }; + } + case TodoActionType.TOGGLE: { + const { id } = action.payload; + const arr = state.todos.map(item => + item.id === id ? { ...item, completed: !item.completed } : item, + ); + return { ...state, todos: arr }; + } + case TodoActionType.DELETE: { + const { id } = action.payload; + const arr = state.todos.filter(item => item.id !== id); + return { ...state, todos: arr }; + } + case TodoActionType.EDIT: { + const { id, title } = action.payload; + const arr = state.todos.map(item => (item.id === id ? { ...item, title } : item)); + return { ...state, todos: arr }; + } + default: + return state; + } +} +// 3. context 생성 +// 만들어진 context 가 관리하는 value 의 모양 +type TodoContextValue = { + todos: NewTodoType[]; + addTodo: (todo: NewTodoType) => void; + toggleTodo: (id: string) => void; + deleteTodo: (id: string) => void; + editTodo: (id: string, editTitle: string) => void; +}; -```tsx -import { useState } from 'react'; -import type { NewTodoType } from '../../types/todoType'; +const TodoContext = createContext(null); -type TodoWriteProps = { - // children 이 있을 경우는 적지만, 없을 경우 굳이 안적어도 됨. (수업이라 적음) - children?: React.ReactNode; - addTodo: (newTodo: NewTodoType) => void; -}; +// 4. provider 생성 +// type TodoProviderProps = { +// children: React.ReactNode; +// }; +// export const TodoProvider = ({ children }: TodoProviderProps) => { -const TodoWrite = ({ addTodo }: TodoWriteProps) => { - const [title, setTitle] = useState(''); +// export const TodoProvider = ({ children }: React.PropsWithChildren) => { - const handleChange = (e: React.ChangeEvent) => { - setTitle(e.target.value); +export const TodoProvider: React.FC = ({ children }): JSX.Element => { + const [state, dispatch] = useReducer(reducer, initialState); + + // dispatch 를 위한 함수 표현식 모음 + const addTodo = (newTodo: NewTodoType) => { + dispatch({ type: TodoActionType.ADD, payload: { todo: newTodo } }); }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - // 저장 - handleSave(); - } + const toggleTodo = (id: string) => { + dispatch({ type: TodoActionType.TOGGLE, payload: { id } }); }; - const handleSave = () => { - if (title.trim()) { - // 업데이트 - const newTodo = { id: Date.now().toString(), title: title, completed: false }; - addTodo(newTodo); - setTitle(''); - } + const deleteTodo = (id: string) => { + dispatch({ type: TodoActionType.DELETE, payload: { id } }); + }; + const editTodo = (id: string, editTitle: string) => { + dispatch({ type: TodoActionType.EDIT, payload: { id, title: editTitle } }); }; - return ( -
    -

    할 일 작성

    -
    - handleChange(e)} - onKeyDown={e => handleKeyDown(e)} - /> - -
    -
    - ); + // value 전달할 값 + const value: TodoContextValue = { + todos: state.todos, + addTodo, + toggleTodo, + deleteTodo, + editTodo, + }; + return {children}; }; -export default TodoWrite; +// 5. custom hook 생성 +export function useTodos() { + const ctx = useContext(TodoContext); + if (!ctx) { + throw new Error('context를 찾을 수 없습니다.'); + } + return ctx; // value 를 리턴함 +} ``` diff --git a/src/App.tsx b/src/App.tsx index 8552c76..49374c3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,53 +1,17 @@ -import { useState } from 'react'; -import TodoWrite from './components/todos/TodoWrite'; import TodoList from './components/todos/TodoList'; -import type { NewTodoType } from './types/todoType'; - -// 초기 값 -const initialTodos: NewTodoType[] = [ - { id: '1', title: '할일 1', completed: false }, - { id: '2', title: '할일 2', completed: true }, - { id: '3', title: '할일 3', completed: false }, -]; +import TodoWrite from './components/todos/TodoWrite'; +import { TodoProvider } from './contexts/TodoContext'; -// todos 에 마우스 커서 올려보고 타입 안맞으면 useState(initialTodos) 적어주기 -// 맞으면 안적어도 됨. function App() { - const [todos, setTodos] = useState(initialTodos); - // todos 업데이트 하기 - const addTodo = (newTodo: NewTodoType) => { - setTodos([newTodo, ...todos]); - }; - // todo completed 토글하기 - const toggleTodo = (id: string) => { - const arr = todos.map(item => - item.id === id ? { ...item, completed: !item.completed } : item, - ); - setTodos(arr); - }; - // todo 삭제하기 - const deleteTodo = (id: string) => { - const arr = todos.filter(item => item.id !== id); - setTodos(arr); - }; - // todo 수정하기 - const editTodo = (id: string, editTitle: string) => { - const arr = todos.map(item => (item.id === id ? { ...item, title: editTitle } : item)); - setTodos(arr); - }; - return (

    할일 웹서비스

    -
    - - -
    + +
    + + +
    +
    ); } diff --git a/src/components/todos/TodoItem.tsx b/src/components/todos/TodoItem.tsx index 8b65958..4bbe2e6 100644 --- a/src/components/todos/TodoItem.tsx +++ b/src/components/todos/TodoItem.tsx @@ -1,15 +1,16 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; +import { useTodos } from '../../contexts/TodoContext'; import type { NewTodoType } from '../../types/todoType'; type TodoItemProps = { todo: NewTodoType; - toggleTodo: (id: string) => void; - editTodo: (id: string, editTitle: string) => void; - deleteTodo: (id: string) => void; }; -const TodoItem = ({ todo, toggleTodo, editTodo, deleteTodo }: TodoItemProps) => { +const TodoItem = ({ todo }: TodoItemProps) => { + const { toggleTodo, editTodo, deleteTodo } = useTodos(); + // 수정중인지 + const [isEdit, setIsEdit] = useState(false); const [editTitle, setEditTitle] = useState(todo.title); const handleChangeTitle = (e: React.ChangeEvent) => { diff --git a/src/components/todos/TodoList.tsx b/src/components/todos/TodoList.tsx index 99b31e4..c3e7e72 100644 --- a/src/components/todos/TodoList.tsx +++ b/src/components/todos/TodoList.tsx @@ -1,26 +1,17 @@ -import type { NewTodoType } from '../../types/todoType'; +import { useTodos } from '../../contexts/TodoContext'; import TodoItem from './TodoItem'; -export type TodoListProps = { - todos: NewTodoType[]; - toggleTodo: (id: string) => void; - editTodo: (id: string, editTitle: string) => void; - deleteTodo: (id: string) => void; -}; +export type TodoListProps = {}; + +const TodoList = ({}: TodoListProps) => { + const { todos } = useTodos(); -const TodoList = ({ todos, toggleTodo, editTodo, deleteTodo }: TodoListProps) => { return (

    TodoList

      {todos.map((item: any) => ( - + ))}
    diff --git a/src/components/todos/TodoWrite.tsx b/src/components/todos/TodoWrite.tsx index 43da663..7dba486 100644 --- a/src/components/todos/TodoWrite.tsx +++ b/src/components/todos/TodoWrite.tsx @@ -1,14 +1,15 @@ import { useState } from 'react'; -import type { NewTodoType } from '../../types/todoType'; +import { useTodos } from '../../contexts/TodoContext'; type TodoWriteProps = { // children 이 있을 경우는 적지만, 없을 경우 굳이 안적어도 됨. (수업이라 적음) children?: React.ReactNode; - addTodo: (newTodo: NewTodoType) => void; }; -const TodoWrite = ({ addTodo }: TodoWriteProps) => { +const TodoWrite = ({}: TodoWriteProps) => { const [title, setTitle] = useState(''); + // context 사용 + const { addTodo } = useTodos(); const handleChange = (e: React.ChangeEvent) => { setTitle(e.target.value); diff --git a/src/contexts/TodoContext.tsx b/src/contexts/TodoContext.tsx new file mode 100644 index 0000000..eeb9e51 --- /dev/null +++ b/src/contexts/TodoContext.tsx @@ -0,0 +1,111 @@ +import React, { createContext, useContext, useReducer, type PropsWithChildren } from 'react'; +import type { NewTodoType } from '../types/todoType'; + +type TodosState = { + todos: NewTodoType[]; +}; + +// 1. 초기값 +const initialState: TodosState = { + todos: [], +}; + +enum TodoActionType { + ADD = 'ADD', + TOGGLE = 'TOGGLE', + DELETE = 'DELETE', + EDIT = 'EDIT', +} + +// action type 정의 +type AddAction = { type: 'ADD'; payload: { todo: NewTodoType } }; +type ToggleAction = { type: 'TOGGLE'; payload: { id: string } }; +type DeleteAction = { type: 'DELETE'; payload: { id: string } }; +type EditAction = { type: 'EDIT'; payload: { id: string; title: string } }; +type TodoAction = AddAction | ToggleAction | DeleteAction | EditAction; + +// 2. 리듀서 +// action 은 {type:"문자열", payload: 재료 } 형태 +function reducer(state: TodosState, action: TodoAction) { + switch (action.type) { + case TodoActionType.ADD: { + const { todo } = action.payload; + return { ...state, todos: [todo, ...state.todos] }; + } + case TodoActionType.TOGGLE: { + const { id } = action.payload; + const arr = state.todos.map(item => + item.id === id ? { ...item, completed: !item.completed } : item, + ); + return { ...state, todos: arr }; + } + case TodoActionType.DELETE: { + const { id } = action.payload; + const arr = state.todos.filter(item => item.id !== id); + return { ...state, todos: arr }; + } + case TodoActionType.EDIT: { + const { id, title } = action.payload; + const arr = state.todos.map(item => (item.id === id ? { ...item, title } : item)); + return { ...state, todos: arr }; + } + default: + return state; + } +} +// 3. context 생성 +// 만들어진 context 가 관리하는 value 의 모양 +type TodoContextValue = { + todos: NewTodoType[]; + addTodo: (todo: NewTodoType) => void; + toggleTodo: (id: string) => void; + deleteTodo: (id: string) => void; + editTodo: (id: string, editTitle: string) => void; +}; + +const TodoContext = createContext(null); + +// 4. provider 생성 +// type TodoProviderProps = { +// children: React.ReactNode; +// }; +// export const TodoProvider = ({ children }: TodoProviderProps) => { + +// export const TodoProvider = ({ children }: React.PropsWithChildren) => { + +export const TodoProvider: React.FC = ({ children }): JSX.Element => { + const [state, dispatch] = useReducer(reducer, initialState); + + // dispatch 를 위한 함수 표현식 모음 + const addTodo = (newTodo: NewTodoType) => { + dispatch({ type: TodoActionType.ADD, payload: { todo: newTodo } }); + }; + const toggleTodo = (id: string) => { + dispatch({ type: TodoActionType.TOGGLE, payload: { id } }); + }; + const deleteTodo = (id: string) => { + dispatch({ type: TodoActionType.DELETE, payload: { id } }); + }; + const editTodo = (id: string, editTitle: string) => { + dispatch({ type: TodoActionType.EDIT, payload: { id, title: editTitle } }); + }; + + // value 전달할 값 + const value: TodoContextValue = { + todos: state.todos, + addTodo, + toggleTodo, + deleteTodo, + editTodo, + }; + return {children}; +}; + +// 5. custom hook 생성 +export function useTodos() { + const ctx = useContext(TodoContext); + if (!ctx) { + throw new Error('context를 찾을 수 없습니다.'); + } + return ctx; // value 를 리턴함 +} diff --git a/tsconfig.app.json b/tsconfig.app.json index e1cbe62..7008a4b 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -14,7 +14,7 @@ "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, - "jsx": "react-jsx", + "jsx": "react", "allowJs": true, "checkJs": false, @@ -23,7 +23,7 @@ "strict": true, "noUnusedLocals": false, "noUnusedParameters": false, - "erasableSyntaxOnly": true, + // "erasableSyntaxOnly": true, // 안씀. "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, From 90aaaa49fadeb8c59533851f27bcb7eccdd69666 Mon Sep 17 00:00:00 2001 From: suha720 Date: Wed, 27 Aug 2025 21:16:25 +0900 Subject: [PATCH 06/13] =?UTF-8?q?[docs]=20context=20=EC=9E=A5=EB=B0=94?= =?UTF-8?q?=EA=B5=AC=EB=8B=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 375 +----------------------------- src/App.tsx | 19 +- src/components/shop/Cart.tsx | 25 ++ src/components/shop/GoodList.tsx | 22 ++ src/components/shop/Wallet.tsx | 9 + src/components/todos/TodoItem.tsx | 2 +- src/contexts/shop/ShopContext.tsx | 186 +++++++++++++++ 7 files changed, 263 insertions(+), 375 deletions(-) create mode 100644 src/components/shop/Cart.tsx create mode 100644 src/components/shop/GoodList.tsx create mode 100644 src/components/shop/Wallet.tsx create mode 100644 src/contexts/shop/ShopContext.tsx diff --git a/README.md b/README.md index ffd0240..dd02819 100644 --- a/README.md +++ b/README.md @@ -1,370 +1,13 @@ -# Context API 와 useReducer +# Context API / useReducer 예제 -- useState 를 대체하고, props 를 줄여보자 +- 쇼핑몰 장바구니, 잔액 관리 -## 1. 기본 폴더 구성 및 파일 구조 +## 1. 폴더 및 파일 구조 -- /src/contexts 폴더 생성 -- /src/contexts/TodoContext.jsx 생성 +- /src/contexts/shop 폴더 생성 +- /src/contexts/shop/ShopContext.tsx 파일 생성 -```jsx -import { createContext, useContext, useReducer } from 'react'; - -// 1. 초기값 -const initialState = { - todos: [], -}; -// 2. 리듀서 -// action 은 {type:"문자열", payload: 재료 } 형태 -function reducer(state, action) { - switch (action.type) { - case 'ADD': { - const { todo } = action.payload; - return { ...state, todos: [todo, ...state.todos] }; - } - case 'TOGGLE': { - const { id } = action.payload; - const arr = state.todos.map(item => - item.id === id ? { ...item, completed: !item.completed } : item, - ); - return { ...state, todos: arr }; - } - case 'DELETE': { - const { id } = action.payload; - const arr = state.todos.filter(item => item.id !== id); - return { ...state, todos: arr }; - } - case 'EDIT': { - const { id, title } = action.payload; - const arr = state.todos.map(item => (item.id === id ? { ...item, title } : item)); - return { ...state, todos: arr }; - } - default: - return state; - } -} -// 3. context 생성 -const TodoContext = createContext(); -// 4. provider 생성 -export const TodoProvider = ({ children }) => { - const [state, dispatch] = useReducer(reducer, initialState); - - // dispatch 를 위한 함수 표현식 모음 - const addTodo = newTodo => { - dispatch({ type: 'ADD', payload: { todo: newTodo } }); - }; - const toggleTodo = id => { - dispatch({ type: 'TOGGLE', payload: { id } }); - }; - const deleteTodo = id => { - dispatch({ type: 'DELETE', payload: { id } }); - }; - const editTodo = (id, editTitle) => { - dispatch({ type: 'EDIT', payload: { id, title: editTitle } }); - }; - - // value 전달할 값 - const value = { - todos: state.todos, - addTodo, - toggleTodo, - deleteTodo, - editTodo, - }; - return {children}; -}; - -// 5. custom hook 생성 -export function useTodos() { - const ctx = useContext(TodoContext); - if (!ctx) { - throw new Error('context를 찾을 수 없습니다.'); - } - return ctx; -} -``` - -- App.tsx - -```tsx -import TodoList from './components/todos/TodoList'; -import TodoWrite from './components/todos/TodoWrite'; -import { TodoProvider } from './contexts/TodoContext'; - -function App() { - return ( -
    -

    할일 웹서비스

    - -
    - - -
    -
    -
    - ); -} - -export default App; -``` - -- TodoWrite.tsx - -```tsx -import { useState } from 'react'; -import { useTodos } from '../../contexts/TodoContext'; - -type TodoWriteProps = { - // children 이 있을 경우는 적지만, 없을 경우 굳이 안적어도 됨. (수업이라 적음) - children?: React.ReactNode; -}; - -const TodoWrite = ({}: TodoWriteProps) => { - const [title, setTitle] = useState(''); - // context 사용 - const { addTodo } = useTodos(); - - const handleChange = (e: React.ChangeEvent) => { - setTitle(e.target.value); - }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - // 저장 - handleSave(); - } - }; - const handleSave = () => { - if (title.trim()) { - // 업데이트 - const newTodo = { id: Date.now().toString(), title: title, completed: false }; - addTodo(newTodo); - setTitle(''); - } - }; - - return ( -
    -

    할 일 작성

    -
    - handleChange(e)} - onKeyDown={e => handleKeyDown(e)} - /> - -
    -
    - ); -}; - -export default TodoWrite; -``` - -- TodoList.tsx - -```tsx -import { useTodos } from '../../contexts/TodoContext'; -import TodoItem from './TodoItem'; - -export type TodoListProps = {}; - -const TodoList = ({}: TodoListProps) => { - const { todos } = useTodos(); - - return ( -
    -

    TodoList

    -
      - {todos.map((item: any) => ( - - ))} -
    -
    - ); -}; - -export default TodoList; -``` - -- TodoItem.tsx - -```tsx -import { useState } from 'react'; -import { useTodos } from '../../contexts/TodoContext'; -import type { NewTodoType } from '../../types/todoType'; - -type TodoItemProps = { - todo: NewTodoType; -}; - -const TodoItem = ({ todo }: TodoItemProps) => { - const { toggleTodo, editTodo, deleteTodo } = useTodos(); - - // 수정중인지 - - const [isEdit, setIsEdit] = useState(false); - const [editTitle, setEditTitle] = useState(todo.title); - const handleChangeTitle = (e: React.ChangeEvent) => { - setEditTitle(e.target.value); - }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleEditSave(); - } - }; - const handleEditSave = () => { - if (editTitle.trim()) { - editTodo(todo.id, editTitle); - setIsEdit(false); - } - }; - const handleEditCancel = () => { - setEditTitle(todo.title); - setIsEdit(false); - }; - return ( -
  • - {isEdit ? ( - <> - handleChangeTitle(e)} - onKeyDown={e => handleKeyDown(e)} - /> - - - - ) : ( - <> - toggleTodo(todo.id)} /> - {todo.title} - - - - )} -
  • - ); -}; - -export default TodoItem; -``` - -## 2. TodoContext.jsx => ts 마이그레이션 - -- 확장자 `tsx` 로 변경 ( import 다시 실행 ) - -```tsx -import React, { createContext, useContext, useReducer, type PropsWithChildren } from 'react'; -import type { NewTodoType } from '../types/todoType'; - -type TodosState = { - todos: NewTodoType[]; -}; - -// 1. 초기값 -const initialState: TodosState = { - todos: [], -}; - -enum TodoActionType { - ADD = 'ADD', - TOGGLE = 'TOGGLE', - DELETE = 'DELETE', - EDIT = 'EDIT', -} - -// action type 정의 -type AddAction = { type: 'ADD'; payload: { todo: NewTodoType } }; -type ToggleAction = { type: 'TOGGLE'; payload: { id: string } }; -type DeleteAction = { type: 'DELETE'; payload: { id: string } }; -type EditAction = { type: 'EDIT'; payload: { id: string; title: string } }; -type TodoAction = AddAction | ToggleAction | DeleteAction | EditAction; - -// 2. 리듀서 -// action 은 {type:"문자열", payload: 재료 } 형태 -function reducer(state: TodosState, action: TodoAction) { - switch (action.type) { - case TodoActionType.ADD: { - const { todo } = action.payload; - return { ...state, todos: [todo, ...state.todos] }; - } - case TodoActionType.TOGGLE: { - const { id } = action.payload; - const arr = state.todos.map(item => - item.id === id ? { ...item, completed: !item.completed } : item, - ); - return { ...state, todos: arr }; - } - case TodoActionType.DELETE: { - const { id } = action.payload; - const arr = state.todos.filter(item => item.id !== id); - return { ...state, todos: arr }; - } - case TodoActionType.EDIT: { - const { id, title } = action.payload; - const arr = state.todos.map(item => (item.id === id ? { ...item, title } : item)); - return { ...state, todos: arr }; - } - default: - return state; - } -} -// 3. context 생성 -// 만들어진 context 가 관리하는 value 의 모양 -type TodoContextValue = { - todos: NewTodoType[]; - addTodo: (todo: NewTodoType) => void; - toggleTodo: (id: string) => void; - deleteTodo: (id: string) => void; - editTodo: (id: string, editTitle: string) => void; -}; - -const TodoContext = createContext(null); - -// 4. provider 생성 -// type TodoProviderProps = { -// children: React.ReactNode; -// }; -// export const TodoProvider = ({ children }: TodoProviderProps) => { - -// export const TodoProvider = ({ children }: React.PropsWithChildren) => { - -export const TodoProvider: React.FC = ({ children }): JSX.Element => { - const [state, dispatch] = useReducer(reducer, initialState); - - // dispatch 를 위한 함수 표현식 모음 - const addTodo = (newTodo: NewTodoType) => { - dispatch({ type: TodoActionType.ADD, payload: { todo: newTodo } }); - }; - const toggleTodo = (id: string) => { - dispatch({ type: TodoActionType.TOGGLE, payload: { id } }); - }; - const deleteTodo = (id: string) => { - dispatch({ type: TodoActionType.DELETE, payload: { id } }); - }; - const editTodo = (id: string, editTitle: string) => { - dispatch({ type: TodoActionType.EDIT, payload: { id, title: editTitle } }); - }; - - // value 전달할 값 - const value: TodoContextValue = { - todos: state.todos, - addTodo, - toggleTodo, - deleteTodo, - editTodo, - }; - return {children}; -}; - -// 5. custom hook 생성 -export function useTodos() { - const ctx = useContext(TodoContext); - if (!ctx) { - throw new Error('context를 찾을 수 없습니다.'); - } - return ctx; // value 를 리턴함 -} -``` +- /src/components/shop 폴더 생성 +- /src/components/shop/GoodList.tsx 파일 생성 +- /src/components/shop/Cart.tsx 파일 생성 +- /src/components/shop/Wallet.tsx 파일 생성 diff --git a/src/App.tsx b/src/App.tsx index 49374c3..55a7f4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,17 +1,20 @@ -import TodoList from './components/todos/TodoList'; -import TodoWrite from './components/todos/TodoWrite'; -import { TodoProvider } from './contexts/TodoContext'; +import React from 'react'; +import GoodList from './components/shop/GoodList'; +import Cart from './components/shop/Cart'; +import Wallet from './components/shop/Wallet'; +import { ShopProvider } from './contexts/shop/ShopContext'; function App() { return (
    -

    할일 웹서비스

    - +

    나의 가게

    +
    - - + + +
    -
    +
    ); } diff --git a/src/components/shop/Cart.tsx b/src/components/shop/Cart.tsx new file mode 100644 index 0000000..6dee1cb --- /dev/null +++ b/src/components/shop/Cart.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { useShop } from '../../contexts/shop/ShopContext'; + +const Cart = () => { + const { balance, cart, removeCartOne, resetCart, clearCart, buyAll } = useShop(); + return ( +
    +

    장바구니

    +
      + {cart.map(item => ( +
    • + 제품명:생략 + 구매수:{item.qty} + + +
    • + ))} +
    + + +
    + ); +}; + +export default Cart; diff --git a/src/components/shop/GoodList.tsx b/src/components/shop/GoodList.tsx new file mode 100644 index 0000000..006d561 --- /dev/null +++ b/src/components/shop/GoodList.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { useShop } from '../../contexts/shop/ShopContext'; + +const GoodList = () => { + const { goods, addCart } = useShop(); + return ( +
    +

    GoodList

    +
      + {goods.map(item => ( +
    • + 제품명 : {item.name} + 가격 : {item.price} 원 + +
    • + ))} +
    +
    + ); +}; + +export default GoodList; diff --git a/src/components/shop/Wallet.tsx b/src/components/shop/Wallet.tsx new file mode 100644 index 0000000..ff734bc --- /dev/null +++ b/src/components/shop/Wallet.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { useShop } from '../../contexts/shop/ShopContext'; + +const Wallet = () => { + const { balance } = useShop(); + return
    Wallet : {balance}
    ; +}; + +export default Wallet; diff --git a/src/components/todos/TodoItem.tsx b/src/components/todos/TodoItem.tsx index 4bbe2e6..5c2020b 100644 --- a/src/components/todos/TodoItem.tsx +++ b/src/components/todos/TodoItem.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { useTodos } from '../../contexts/TodoContext'; import type { NewTodoType } from '../../types/todoType'; +import { useTodos } from '../../contexts/TodoContext'; type TodoItemProps = { todo: NewTodoType; diff --git a/src/contexts/shop/ShopContext.tsx b/src/contexts/shop/ShopContext.tsx new file mode 100644 index 0000000..832f208 --- /dev/null +++ b/src/contexts/shop/ShopContext.tsx @@ -0,0 +1,186 @@ +import React, { createContext, useContext, useReducer } from 'react'; + +// 1-1 +type CartType = { id: number; name?: string; qty: number }; // qty = quantity : 몇개를 담았는지 + +type GoodType = { + id: number; + name: string; + price: number; +}; + +type ShopStateType = { + balance: number; + cart: CartType[]; + goods: GoodType[]; +}; + +// 1. 초기값 +const initialState: ShopStateType = { + balance: 100000, + cart: [], + goods: [ + { id: 1, name: '사과', price: 1300 }, + { id: 2, name: '딸기', price: 30000 }, + { id: 3, name: '바나나', price: 5000 }, + { id: 4, name: '쪼꼬', price: 1000 }, + ], +}; + +// 2-1 enum 을 활용하여 Shop 의 Action Type 를 정의함 +enum ShopActionType { + ADD_CART = 'ADD_CART', + REMOVE_CART_ONE = 'REMOVE_CART', + CLEAR_CART_ITEM = 'CLEAR_CART', + BUY_ALL = 'BUY_ALL', + RESET = 'RESET', +} + +// 2-2 Action type 정의 +type ShopActionAddCart = { type: ShopActionType.ADD_CART; payload: { id: number } }; +type ShopActionRemoveCart = { type: ShopActionType.REMOVE_CART_ONE; payload: { id: number } }; +type ShopActionClearCart = { type: ShopActionType.CLEAR_CART_ITEM; payload: { id: number } }; +type ShopActionBuyAll = { type: ShopActionType.BUY_ALL }; // payload 필요 없음 +type ShopActionReset = { type: ShopActionType.RESET }; // payload 필요 없음 +type ShopAction = + | ShopActionAddCart + | ShopActionRemoveCart + | ShopActionClearCart + | ShopActionBuyAll + | ShopActionReset; + +// 2-3 장바구니 전체 금액 계산하기 (calcCart) - 하단 함수가 `순수 함수` +function calcCart(nowState: ShopStateType): number { + // useReducer (값을 누적해주는 함수) 아님. react 의 reduce 함수임. + const total = nowState.cart.reduce((sum, 장바구니제품) => { + // id를 이용해서 제품 상세 정보 찾기 + const good = nowState.goods.find(g => g.id === 장바구니제품.id); + if (good) { + return sum + good.price * 장바구니제품.qty; // 반드시 return + } + return sum; // good이 없으면 그대로 반환 + }, 0); + + return total; +} + +// 2. reducer +function reducer(state: ShopStateType, action: ShopAction): ShopStateType { + switch (action.type) { + case ShopActionType.ADD_CART: { + const { id } = action.payload; // { id } = 제품의 ID + const existGood = state.cart.find(item => item.id === id); + let arr: CartType[] = []; + if (existGood) { + // qty 증가 + arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)); + } else { + // state.cart 에 새 제품 추가, qty 는 1개 + arr = [...state.cart, { id, qty: 1 }]; + } + return { ...state, cart: arr }; + } + + case ShopActionType.REMOVE_CART_ONE: { + const { id } = action.payload; // 1개 빼줄 제품의 ID + const existGood = state.cart.find(item => item.id === id); + if (!existGood) { + // 제품이 없을 경우 + return state; + } + let arr: CartType[] = []; + if (existGood.qty > 1) { + // 제품이 2개 이상이면 수량 -1 + arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty - 1 } : item)); + } else { + // 제품이 1개 담겼음 → 장바구니에서 삭제 + arr = state.cart.filter(item => item.id !== id); + } + return { ...state, cart: arr }; + } + + case ShopActionType.CLEAR_CART_ITEM: { + // 담겨진 제품 중에 장바구니에서 제거하기 + const { id } = action.payload; + const arr = state.cart.filter(item => item.id !== id); + return { ...state, cart: arr }; + } + + case ShopActionType.BUY_ALL: { + // 총 금액 계산 + const total = calcCart(state); + if (total > state.balance) { + alert('잔액이 부족합니다. 잔액을 확인 해주세요'); + return state; + } + return { ...state, balance: state.balance - total, cart: [] }; + } + + case ShopActionType.RESET: { + return initialState; // initialState 에 값이 비어있어서 이렇게 넣어줘도 됨 + } + + default: + return state; + } +} + +// 3-1 +type ShopValueType = { + cart: CartType[]; + goods: GoodType[]; + balance: number; + addCart: (id: number) => void; + removeCartOne: (id: number) => void; + clearCart: (id: number) => void; + buyAll: () => void; + resetCart: () => void; +}; + +// 3. context +const ShopContext = createContext(null); + +// 4. provider +// export const ShopProvider = ({ children }: React.PropsWithChildren) => { +export const ShopProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + // 4-1. dispatch 용 함수 표현식 + const addCart = (id: number) => { + dispatch({ type: ShopActionType.ADD_CART, payload: { id } }); + }; + const removeCartOne = (id: number) => { + dispatch({ type: ShopActionType.REMOVE_CART_ONE, payload: { id } }); + }; + const clearCart = (id: number) => { + dispatch({ type: ShopActionType.CLEAR_CART_ITEM, payload: { id } }); + }; + const buyAll = () => { + dispatch({ type: ShopActionType.BUY_ALL }); + }; + const resetCart = () => { + dispatch({ type: ShopActionType.RESET }); + }; + + const value: ShopValueType = { + cart: state.cart, + goods: state.goods, + balance: state.balance, + addCart, + removeCartOne, + clearCart, + buyAll, + resetCart, + }; + + return {children}; +}; + +// 5. custom hook +export function useShop() { + const ctx = useContext(ShopContext); + if (!ctx) { + throw new Error('Shop context 가 생성되지 않았습니다.'); + } + return ctx; +} From 45831f49a5318ce8c73ac4cfffc6dfc40af9ad75 Mon Sep 17 00:00:00 2001 From: suha720 Date: Thu, 28 Aug 2025 12:11:05 +0900 Subject: [PATCH 07/13] =?UTF-8?q?[docs]=20context=20=EC=9D=91=EC=9A=A9=20?= =?UTF-8?q?=EC=98=88=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1280 ++++++++++++++++++++++++ src/App.tsx | 28 +- src/components/shop/Cart.tsx | 130 ++- src/components/shop/GoodList.tsx | 35 +- src/components/shop/Wallet.tsx | 26 +- src/contexts/shop/ShopContext.tsx | 186 ---- src/features/hooks/useShop.ts | 10 + src/features/hooks/useShopSelectors.ts | 12 + src/features/index.ts | 9 + src/features/shop/ShopContext.tsx | 42 + src/features/shop/reducer.ts | 70 ++ src/features/shop/state.ts | 13 + src/features/shop/types.ts | 52 + src/features/shop/utils.ts | 22 + 14 files changed, 1697 insertions(+), 218 deletions(-) delete mode 100644 src/contexts/shop/ShopContext.tsx create mode 100644 src/features/hooks/useShop.ts create mode 100644 src/features/hooks/useShopSelectors.ts create mode 100644 src/features/index.ts create mode 100644 src/features/shop/ShopContext.tsx create mode 100644 src/features/shop/reducer.ts create mode 100644 src/features/shop/state.ts create mode 100644 src/features/shop/types.ts create mode 100644 src/features/shop/utils.ts diff --git a/README.md b/README.md index dd02819..032d14b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,1287 @@ - /src/contexts/shop 폴더 생성 - /src/contexts/shop/ShopContext.tsx 파일 생성 +```tsx +import React, { createContext, useContext, useReducer } from 'react'; + +// 1-1 +type CartType = { id: number; name?: string; qty: number }; // qty = quantity : 몇개를 담았는지 + +type GoodType = { + id: number; + name: string; + price: number; +}; + +type ShopStateType = { + balance: number; + cart: CartType[]; + goods: GoodType[]; +}; + +// 1. 초기값 +const initialState: ShopStateType = { + balance: 100000, + cart: [], + goods: [ + { id: 1, name: '사과', price: 1300 }, + { id: 2, name: '딸기', price: 30000 }, + { id: 3, name: '바나나', price: 5000 }, + { id: 4, name: '쪼꼬', price: 1000 }, + ], +}; + +// 2-1 enum 을 활용하여 Shop 의 Action Type 를 정의함 +enum ShopActionType { + ADD_CART = 'ADD_CART', + REMOVE_CART_ONE = 'REMOVE_CART', + CLEAR_CART_ITEM = 'CLEAR_CART', + BUY_ALL = 'BUY_ALL', + RESET = 'RESET', +} + +// 2-2 Action type 정의 +type ShopActionAddCart = { type: ShopActionType.ADD_CART; payload: { id: number } }; +type ShopActionRemoveCart = { type: ShopActionType.REMOVE_CART_ONE; payload: { id: number } }; +type ShopActionClearCart = { type: ShopActionType.CLEAR_CART_ITEM; payload: { id: number } }; +type ShopActionBuyAll = { type: ShopActionType.BUY_ALL }; // payload 필요 없음 +type ShopActionReset = { type: ShopActionType.RESET }; // payload 필요 없음 +type ShopAction = + | ShopActionAddCart + | ShopActionRemoveCart + | ShopActionClearCart + | ShopActionBuyAll + | ShopActionReset; + +// 2-3 장바구니 전체 금액 계산하기 (calcCart) - 하단 함수가 `순수 함수` +function calcCart(nowState: ShopStateType): number { + // useReducer (값을 누적해주는 함수) 아님. react 의 reduce 함수임. + const total = nowState.cart.reduce((sum, 장바구니제품) => { + // id를 이용해서 제품 상세 정보 찾기 + const good = nowState.goods.find(g => g.id === 장바구니제품.id); + if (good) { + return sum + good.price * 장바구니제품.qty; // 반드시 return + } + return sum; // good이 없으면 그대로 반환 + }, 0); + + return total; +} + +// 2. reducer +function reducer(state: ShopStateType, action: ShopAction): ShopStateType { + switch (action.type) { + case ShopActionType.ADD_CART: { + const { id } = action.payload; // { id } = 제품의 ID + const existGood = state.cart.find(item => item.id === id); + let arr: CartType[] = []; + if (existGood) { + // qty 증가 + arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)); + } else { + // state.cart 에 새 제품 추가, qty 는 1개 + arr = [...state.cart, { id, qty: 1 }]; + } + return { ...state, cart: arr }; + } + + case ShopActionType.REMOVE_CART_ONE: { + const { id } = action.payload; // 1개 빼줄 제품의 ID + const existGood = state.cart.find(item => item.id === id); + if (!existGood) { + // 제품이 없을 경우 + return state; + } + let arr: CartType[] = []; + if (existGood.qty > 1) { + // 제품이 2개 이상이면 수량 -1 + arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty - 1 } : item)); + } else { + // 제품이 1개 담겼음 → 장바구니에서 삭제 + arr = state.cart.filter(item => item.id !== id); + } + return { ...state, cart: arr }; + } + + case ShopActionType.CLEAR_CART_ITEM: { + // 담겨진 제품 중에 장바구니에서 제거하기 + const { id } = action.payload; + const arr = state.cart.filter(item => item.id !== id); + return { ...state, cart: arr }; + } + + case ShopActionType.BUY_ALL: { + // 총 금액 계산 + const total = calcCart(state); + if (total > state.balance) { + alert('잔액이 부족합니다. 잔액을 확인 해주세요'); + return state; + } + return { ...state, balance: state.balance - total, cart: [] }; + } + + case ShopActionType.RESET: { + return initialState; // initialState 에 값이 비어있어서 이렇게 넣어줘도 됨 + } + + default: + return state; + } +} + +// 3-1 +type ShopValueType = { + cart: CartType[]; + goods: GoodType[]; + balance: number; + addCart: (id: number) => void; + removeCartOne: (id: number) => void; + clearCart: (id: number) => void; + buyAll: () => void; + resetCart: () => void; +}; + +// 3. context +const ShopContext = createContext(null); + +// 4. provider +// export const ShopProvider = ({ children }: React.PropsWithChildren) => { +export const ShopProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + // 4-1. dispatch 용 함수 표현식 + const addCart = (id: number) => { + dispatch({ type: ShopActionType.ADD_CART, payload: { id } }); + }; + const removeCartOne = (id: number) => { + dispatch({ type: ShopActionType.REMOVE_CART_ONE, payload: { id } }); + }; + const clearCart = (id: number) => { + dispatch({ type: ShopActionType.CLEAR_CART_ITEM, payload: { id } }); + }; + const buyAll = () => { + dispatch({ type: ShopActionType.BUY_ALL }); + }; + const resetCart = () => { + dispatch({ type: ShopActionType.RESET }); + }; + + const value: ShopValueType = { + cart: state.cart, + goods: state.goods, + balance: state.balance, + addCart, + removeCartOne, + clearCart, + buyAll, + resetCart, + }; + + return {children}; +}; + +// 5. custom hook +export function useShop() { + const ctx = useContext(ShopContext); + if (!ctx) { + throw new Error('Shop context 가 생성되지 않았습니다.'); + } + return ctx; +} +``` + - /src/components/shop 폴더 생성 - /src/components/shop/GoodList.tsx 파일 생성 + +```tsx +import React from 'react'; +import { useShop } from '../../contexts/shop/ShopContext'; + +const GoodList = () => { + const { goods, addCart } = useShop(); + return ( +
    +

    GoodList

    +
      + {goods.map(item => ( +
    • + 제품명 : {item.name} + 가격 : {item.price} 원 + +
    • + ))} +
    +
    + ); +}; + +export default GoodList; +``` + - /src/components/shop/Cart.tsx 파일 생성 + +```tsx +import React from 'react'; +import { useShop } from '../../contexts/shop/ShopContext'; + +const Cart = () => { + const { balance, cart, removeCartOne, resetCart, clearCart, buyAll } = useShop(); + return ( +
    +

    장바구니

    +
      + {cart.map(item => ( +
    • + 제품명:생략 + 구매수:{item.qty} + + +
    • + ))} +
    + + +
    + ); +}; + +export default Cart; +``` + - /src/components/shop/Wallet.tsx 파일 생성 + +```tsx +import React from 'react'; +import { useShop } from '../../contexts/shop/ShopContext'; + +const Wallet = () => { + const { balance } = useShop(); + return
    Wallet : {balance}
    ; +}; + +export default Wallet; +``` + +- App.tsx + +```tsx +import React from 'react'; +import GoodList from './components/shop/GoodList'; +import Cart from './components/shop/Cart'; +import Wallet from './components/shop/Wallet'; +import { ShopProvider } from './contexts/shop/ShopContext'; + +function App() { + return ( +
    +

    나의 가게

    + +
    + + + +
    +
    +
    + ); +} + +export default App; +``` + +## 최종 css + 기능 수정 버전 + +- App.tsx + +```tsx +import React from 'react'; +import GoodList from './components/shop/GoodList'; +import Cart from './components/shop/Cart'; +import Wallet from './components/shop/Wallet'; +import { ShopProvider } from './contexts/shop/ShopContext'; + +function App() { + return ( +
    + {/* 상단 헤더 */} +
    +

    유비두비's 쇼핑몰

    +
    + + {/* 컨텐츠 */} + +
    + {/* 상품 리스트 */} +
    + +
    + + {/* 장바구니 + 지갑 */} + +
    +
    +
    + ); +} + +export default App; +``` + +- Cart.tsx + +```tsx +import React from 'react'; +import { useShop, useShopSelectors } from '../../contexts/shop/ShopContext'; + +const Cart = () => { + const { cart, addCart, removeCartOne, clearCart, resetCart, buyAll } = useShop(); + const { getGood, total } = useShopSelectors(); + + // 수량 직접 입력 함수 + const handleQtyChange = (id: number, value: string) => { + const qty = Number(value); + + // 빈 값이나 NaN이면 0 처리 + const newQty = isNaN(qty) ? 0 : qty; + + // 현재 장바구니 아이템 찾기 + const existItem = cart.find(item => item.id === id); + if (!existItem) return; + + const diff = newQty - existItem.qty; + + if (diff > 0) { + for (let i = 0; i < diff; i++) addCart(id); + } else if (diff < 0) { + for (let i = 0; i < Math.abs(diff); i++) removeCartOne(id); + } + + // 0 입력 시 삭제하지 않고 그대로 0 표시 + }; + + return ( +
    +

    내 카트 🛒

    + +
      + {cart.length === 0 ? ( +
    • + 장바구니가 비어있습니다. +
    • + ) : ( + cart.map(item => { + const good = getGood(item.id); + return ( +
    • +
      + {good?.name} + + 가격: {(good?.price! * item.qty).toLocaleString()} 원 + +
      + + {/* 수량 컨트롤 */} +
      + {/* - 버튼 */} + + + {/* 수량 입력 */} + handleQtyChange(item.id, e.target.value)} + className=" + w-12 text-center border rounded-lg bg-white text-gray-800 + appearance-none + [&::-webkit-inner-spin-button]:appearance-none + [&::-webkit-outer-spin-button]:appearance-none + -moz-appearance:textfield + " + /> + + {/* + 버튼 */} + + + {/* 삭제 버튼 */} + +
      +
    • + ); + }) + )} +
    + + {/* 총 금액 표시 */} + {cart.length > 0 && ( +
    + 총 합계: {total.toLocaleString()} 원 +
    + )} + + {/* 하단 버튼 */} +
    + + +
    +
    + ); +}; + +export default Cart; +``` + +- ShopContext.tsx + +```tsx +import React, { createContext, useContext, useReducer } from 'react'; + +// 1-1 +type CartType = { id: number; name?: string; qty: number }; // qty = quantity : 몇개를 담았는지 + +type GoodType = { + id: number; + name: string; + price: number; +}; + +type ShopStateType = { + balance: number; + cart: CartType[]; + goods: GoodType[]; +}; + +// 1. 초기값 +const initialState: ShopStateType = { + balance: 100000, + cart: [], + goods: [ + { id: 1, name: '사과', price: 1300 }, + { id: 2, name: '딸기', price: 30000 }, + { id: 3, name: '바나나', price: 5000 }, + { id: 4, name: '초콜릿', price: 1000 }, + ], +}; + +// 2-1 enum 을 활용하여 Shop 의 Action Type 를 정의함 +enum ShopActionType { + ADD_CART = 'ADD_CART', + REMOVE_CART_ONE = 'REMOVE_CART', + CLEAR_CART_ITEM = 'CLEAR_CART', + BUY_ALL = 'BUY_ALL', + RESET = 'RESET', +} + +// 2-2 Action type 정의 +type ShopActionAddCart = { type: ShopActionType.ADD_CART; payload: { id: number } }; +type ShopActionRemoveCart = { type: ShopActionType.REMOVE_CART_ONE; payload: { id: number } }; +type ShopActionClearCart = { type: ShopActionType.CLEAR_CART_ITEM; payload: { id: number } }; +type ShopActionBuyAll = { type: ShopActionType.BUY_ALL }; // payload 필요 없음 +type ShopActionReset = { type: ShopActionType.RESET }; // payload 필요 없음 +type ShopAction = + | ShopActionAddCart + | ShopActionRemoveCart + | ShopActionClearCart + | ShopActionBuyAll + | ShopActionReset; + +// 2-3 장바구니 전체 금액 계산하기 (calcCart) - 하단 함수가 `순수 함수` +// function calcCart(nowState: ShopStateType): number { +// useReducer (값을 누적해주는 함수) 아님. react 의 reduce 함수임. +// const total = nowState.cart.reduce((sum, 장바구니제품) => { +// id를 이용해서 제품 상세 정보 찾기 +// const good = nowState.goods.find(g => g.id === 장바구니제품.id); +// if (good) { +// return sum + good.price * 장바구니제품.qty; // 반드시 return +// } +// return sum; // good이 없으면 그대로 반환 +// }, 0); + +// return total; +// } + +// cart, goods 만 필요하므로 타입을 좁힘 +function calcTotal(cart: CartType[], goods: GoodType[]): number { + return cart.reduce((sum, c) => { + const good = goods.find(g => g.id === c.id); + return good ? sum + good.price * c.qty : sum; + }, 0); +} + +// 2. reducer +function reducer(state: ShopStateType, action: ShopAction): ShopStateType { + switch (action.type) { + case ShopActionType.ADD_CART: { + const { id } = action.payload; // { id } = 제품의 ID, 1개 빼줄 제품의 ID + const existGood = state.cart.find(item => item.id === id); + let arr: CartType[] = []; + if (existGood) { + // qty 증가 + arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)); + } else { + // state.cart 에 새 제품 추가, qty 는 1개 + arr = [...state.cart, { id, qty: 1 }]; + } + return { ...state, cart: arr }; + } + + case ShopActionType.REMOVE_CART_ONE: { + const { id } = action.payload; // 1개 빼줄 제품의 ID + const existGood = state.cart.find(item => item.id === id); + if (!existGood) { + // 제품이 없을 경우 + return state; + } + let arr: CartType[] = []; + if (existGood.qty > 1) { + // 제품이 2개 이상이면 수량 -1 + arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty - 1 } : item)); + } else { + // 제품이 1개 담겼음 → 장바구니에서 삭제 + arr = state.cart.filter(item => item.id !== id); + } + return { ...state, cart: arr }; + } + + // 장바구니 추가/삭제 만약, 0이 되어버리면 삭제 버튼 외엔 삭제 되지 않게끔. 0으로 출력(?) + case ShopActionType.REMOVE_CART_ONE: { + const { id } = action.payload; + const existItem = state.cart.find(item => item.id === id); + if (!existItem) return state; + + // 수량 -1, 단 0 이하로는 떨어지지 않음 + const arr = state.cart.map(item => + item.id === id ? { ...item, qty: Math.max(item.qty - 1, 0) } : item, + ); + + return { ...state, cart: arr }; + } + + case ShopActionType.BUY_ALL: { + // 총 금액 계산 + const total = calcTotal(state.cart, state.goods); + if (total > state.balance) { + alert('잔액이 부족합니다. 잔액을 확인 해주세요'); + return state; + } + return { ...state, balance: state.balance - total, cart: [] }; + } + + case ShopActionType.RESET: { + return initialState; // initialState 에 값이 비어있어서 이렇게 넣어줘도 됨 + } + + default: + return state; + } +} + +// 3-1 +type ShopValueType = { + cart: CartType[]; + goods: GoodType[]; + balance: number; + addCart: (id: number) => void; + removeCartOne: (id: number) => void; + clearCart: (id: number) => void; + buyAll: () => void; + resetCart: () => void; +}; + +// 3. context +const ShopContext = createContext(null); + +// 4. provider +// export const ShopProvider = ({ children }: React.PropsWithChildren) => { +export const ShopProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + // 4-1. dispatch 용 함수 표현식 + const addCart = (id: number) => { + dispatch({ type: ShopActionType.ADD_CART, payload: { id } }); + }; + const removeCartOne = (id: number) => { + dispatch({ type: ShopActionType.REMOVE_CART_ONE, payload: { id } }); + }; + const clearCart = (id: number) => { + dispatch({ type: ShopActionType.CLEAR_CART_ITEM, payload: { id } }); + }; + const buyAll = () => { + dispatch({ type: ShopActionType.BUY_ALL }); + }; + const resetCart = () => { + dispatch({ type: ShopActionType.RESET }); + }; + + const value: ShopValueType = { + cart: state.cart, + goods: state.goods, + balance: state.balance, + addCart, + removeCartOne, + clearCart, + buyAll, + resetCart, + }; + + return {children}; +}; + +// 5. custom hook +export function useShop() { + const ctx = useContext(ShopContext); + if (!ctx) { + throw new Error('Shop context 가 생성되지 않았습니다.'); + } + return ctx; +} + +// 6. 추가 custom hook + +export function useShopSelectors() { + const { cart, goods } = useShop(); + // 제품 한개 정보 찾기 + const getGood = (id: number) => goods.find(item => item.id === id); + // 총 금액 + const total = calcTotal(cart, goods); + // 되돌려줌 + return { getGood, total }; +} +``` + +- GoodList.tsx + +```tsx +import React from 'react'; +import { useShop } from '../../contexts/shop/ShopContext'; + +const GoodList = () => { + const { goods, addCart } = useShop(); + + return ( +
    + {/* 제목 */} +

    상품 리스트 📦

    + + {/* 상품 그리드 */} +
      + {goods.map(item => ( +
    • + {/* 상품 정보 */} +
      + {item.name} + + 가격: {item.price.toLocaleString()} 원 + +
      + + {/* 담기 버튼 */} + +
    • + ))} +
    +
    + ); +}; + +export default GoodList; +``` + +- Wallet.tsx + +```tsx +import React from 'react'; +import { useShop } from '../../contexts/shop/ShopContext'; + +const Wallet = () => { + const { balance } = useShop(); + + return ( +
    + {/* 상단 */} +
    +

    내 지갑

    + 💳 Wallet +
    + + {/* 잔액 */} +

    사용 가능한 잔액

    +

    {balance.toLocaleString()} 원

    +
    + ); +}; + +export default Wallet; +``` + +## 2. 실전 파일 분리하기 + +### 2.1. 폴더 및 파일 구조 + +- 기능별로 분리한다면 contexts 말고 `features (기능)` 폴더로 +- `/src/features` 폴더 생성 +- `/src/features/shop` 폴더 생성 +- `/src/features/shop/types.ts` 파일 생성 + +```ts +// 장바구니 아이템 Type +export type CartType = { id: number; name?: string; qty: number }; // qty = quantity : 몇개를 담았는지 + +// 제품 아이템 Type +export type GoodType = { + id: number; + name: string; + price: number; +}; + +// ShopStateType +export type ShopStateType = { + balance: number; + cart: CartType[]; + goods: GoodType[]; +}; + +// Action Type (constant.ts - 상수 타입으로 옮겨줘도 됨.) +export enum ShopActionType { + ADD_CART = 'ADD_CART', + REMOVE_CART_ONE = 'REMOVE_CART', + CLEAR_CART_ITEM = 'CLEAR_CART', + BUY_ALL = 'BUY_ALL', + RESET = 'RESET', +} + +export type ShopActionAddCart = { type: ShopActionType.ADD_CART; payload: { id: number } }; +export type ShopActionRemoveCart = { + type: ShopActionType.REMOVE_CART_ONE; + payload: { id: number }; +}; +export type ShopActionClearCart = { type: ShopActionType.CLEAR_CART_ITEM; payload: { id: number } }; +export type ShopActionBuyAll = { type: ShopActionType.BUY_ALL }; // payload 필요 없음 +export type ShopActionReset = { type: ShopActionType.RESET }; // payload 필요 없음 +export type ShopAction = + | ShopActionAddCart + | ShopActionRemoveCart + | ShopActionClearCart + | ShopActionBuyAll + | ShopActionReset; + +// Context 의 value Type +export type ShopValueType = { + cart: CartType[]; + goods: GoodType[]; + balance: number; + addCart: (id: number) => void; + removeCartOne: (id: number) => void; + clearCart: (id: number) => void; + buyAll: () => void; + resetCart: () => void; +}; +``` + +- `/src/features/shop/state.ts` 파일 생성 + +```ts +import type { ShopStateType } from './types'; + +// 초기값 상태 +export const initialState: ShopStateType = { + balance: 100000, + cart: [], + goods: [ + { id: 1, name: '사과', price: 1300 }, + { id: 2, name: '딸기', price: 30000 }, + { id: 3, name: '바나나', price: 5000 }, + { id: 4, name: '초콜릿', price: 1000 }, + ], +}; +``` + +- `/src/features/shop/utils.ts` 파일 생성 + +```ts +import type { CartType, GoodType } from './types'; + +// 2-3 장바구니 전체 금액 계산하기 (calcCart) - 하단 함수가 `순수 함수` +// function calcCart(nowState: ShopStateType): number { +// useReducer (값을 누적해주는 함수) 아님. react 의 reduce 함수임. +// const total = nowState.cart.reduce((sum, 장바구니제품) => { +// id를 이용해서 제품 상세 정보 찾기 +// const good = nowState.goods.find(g => g.id === 장바구니제품.id); +// if (good) { +// return sum + good.price * 장바구니제품.qty; // 반드시 return +// } +// return sum; // good이 없으면 그대로 반환 +// }, 0); +// return total; +// } +// cart, goods 만 필요하므로 타입을 좁힘 +export function calcTotal(cart: CartType[], goods: GoodType[]): number { + return cart.reduce((sum, c) => { + const good = goods.find(g => g.id === c.id); + return good ? sum + good.price * c.qty : sum; + }, 0); +} +``` + +- `/src/features/shop/reducer.ts` 파일 생성 + +```ts +import { initialState } from './state'; +import { ShopActionType, type CartType, type ShopAction, type ShopStateType } from './types'; +import { calcTotal } from './utils'; + +export function reducer(state: ShopStateType, action: ShopAction): ShopStateType { + switch (action.type) { + case ShopActionType.ADD_CART: { + const { id } = action.payload; // { id } = 제품의 ID, 1개 빼줄 제품의 ID + const existGood = state.cart.find(item => item.id === id); + let arr: CartType[] = []; + if (existGood) { + // qty 증가 + arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)); + } else { + // state.cart 에 새 제품 추가, qty 는 1개 + arr = [...state.cart, { id, qty: 1 }]; + } + return { ...state, cart: arr }; + } + + case ShopActionType.REMOVE_CART_ONE: { + const { id } = action.payload; // 1개 빼줄 제품의 ID + const existGood = state.cart.find(item => item.id === id); + if (!existGood) { + // 제품이 없을 경우 + return state; + } + let arr: CartType[] = []; + if (existGood.qty > 1) { + // 제품이 2개 이상이면 수량 -1 + arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty - 1 } : item)); + } else { + // 제품이 1개 담겼음 → 장바구니에서 삭제 + arr = state.cart.filter(item => item.id !== id); + } + return { ...state, cart: arr }; + } + + // 장바구니 추가/삭제 만약, 0이 되어버리면 삭제 버튼 외엔 삭제 되지 않게끔. 0으로 출력(?) + case ShopActionType.REMOVE_CART_ONE: { + const { id } = action.payload; + const existItem = state.cart.find(item => item.id === id); + if (!existItem) return state; + + // 수량 -1, 단 0 이하로는 떨어지지 않음 + const arr = state.cart.map(item => + item.id === id ? { ...item, qty: Math.max(item.qty - 1, 0) } : item, + ); + + return { ...state, cart: arr }; + } + + case ShopActionType.BUY_ALL: { + // 총 금액 계산 + const total = calcTotal(state.cart, state.goods); + if (total > state.balance) { + alert('잔액이 부족합니다. 잔액을 확인 해주세요'); + return state; + } + return { ...state, balance: state.balance - total, cart: [] }; + } + + case ShopActionType.RESET: { + return initialState; // initialState 에 값이 비어있어서 이렇게 넣어줘도 됨 + } + + default: + return state; + } +} +``` + +- `/src/features/shop/ShopContext.tsx` 파일 생성 + +```tsx +import React, { createContext, useReducer } from 'react'; +import { ShopActionType, type ShopValueType } from './types'; +import { reducer } from './reducer'; +import { initialState } from './state'; + +export const ShopContext = createContext(null); + +// 4. provider +// export const ShopProvider = ({ children }: React.PropsWithChildren) => { +export const ShopProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + // 4-1. dispatch 용 함수 표현식 + const addCart = (id: number) => { + dispatch({ type: ShopActionType.ADD_CART, payload: { id } }); + }; + const removeCartOne = (id: number) => { + dispatch({ type: ShopActionType.REMOVE_CART_ONE, payload: { id } }); + }; + const clearCart = (id: number) => { + dispatch({ type: ShopActionType.CLEAR_CART_ITEM, payload: { id } }); + }; + const buyAll = () => { + dispatch({ type: ShopActionType.BUY_ALL }); + }; + const resetCart = () => { + dispatch({ type: ShopActionType.RESET }); + }; + + const value: ShopValueType = { + cart: state.cart, + goods: state.goods, + balance: state.balance, + addCart, + removeCartOne, + clearCart, + buyAll, + resetCart, + }; + + return {children}; +}; +``` + +- `/src/features/shop/useShopSelectors.ts` 파일 생성 + +```ts +import { calcTotal } from '../shop/utils'; +import { useShop } from './useShop'; + +export function useShopSelectors() { + const { cart, goods } = useShop(); + // 제품 한개 정보 찾기 + const getGood = (id: number) => goods.find(item => item.id === id); + // 총 금액 + const total = calcTotal(cart, goods); + // 되돌려줌 + return { getGood, total }; +} +``` + +- App.tsx + +```tsx +import React from 'react'; +import GoodList from './components/shop/GoodList'; +import Cart from './components/shop/Cart'; +import Wallet from './components/shop/Wallet'; +import { ShopProvider } from './features/shop/ShopContext'; + +function App() { + return ( +
    + {/* 상단 헤더 */} +
    +

    유비두비's 쇼핑몰

    +
    + + {/* 컨텐츠 */} + +
    + {/* 상품 리스트 */} +
    + +
    + + {/* 장바구니 + 지갑 */} + +
    +
    +
    + ); +} + +export default App; +``` + +- GoodList.tsx + +```tsx +import React from 'react'; +import { useShop } from '../../features/hooks/useShop'; + +const GoodList = () => { + const { goods, addCart } = useShop(); + + return ( +
    + {/* 제목 */} +

    상품 리스트 📦

    + + {/* 상품 그리드 */} +
      + {goods.map(item => ( +
    • + {/* 상품 정보 */} +
      + {item.name} + + 가격: {item.price.toLocaleString()} 원 + +
      + + {/* 담기 버튼 */} + +
    • + ))} +
    +
    + ); +}; + +export default GoodList; +``` + +- Cart.tsx + +```tsx +import React from 'react'; +import { useShopSelectors } from '../../features/hooks/useShopSelectors'; +import { useShop } from '../../features/hooks/useShop'; + +const Cart = () => { + const { cart, addCart, removeCartOne, clearCart, resetCart, buyAll } = useShop(); + const { getGood, total } = useShopSelectors(); + + // 수량 직접 입력 함수 + const handleQtyChange = (id: number, value: string) => { + const qty = Number(value); + + // 빈 값이나 NaN이면 0 처리 + const newQty = isNaN(qty) ? 0 : qty; + + // 현재 장바구니 아이템 찾기 + const existItem = cart.find(item => item.id === id); + if (!existItem) return; + + const diff = newQty - existItem.qty; + + if (diff > 0) { + for (let i = 0; i < diff; i++) addCart(id); + } else if (diff < 0) { + for (let i = 0; i < Math.abs(diff); i++) removeCartOne(id); + } + + // 0 입력 시 삭제하지 않고 그대로 0 표시 + }; + + return ( +
    +

    내 카트 🛒

    + +
      + {cart.length === 0 ? ( +
    • + 장바구니가 비어있습니다. +
    • + ) : ( + cart.map(item => { + const good = getGood(item.id); + return ( +
    • +
      + {good?.name} + + 가격: {(good?.price! * item.qty).toLocaleString()} 원 + +
      + + {/* 수량 컨트롤 */} +
      + {/* - 버튼 */} + + + {/* 수량 입력 */} + handleQtyChange(item.id, e.target.value)} + className=" + w-12 text-center border rounded-lg bg-white text-gray-800 + appearance-none + [&::-webkit-inner-spin-button]:appearance-none + [&::-webkit-outer-spin-button]:appearance-none + -moz-appearance:textfield + " + /> + + {/* + 버튼 */} + + + {/* 삭제 버튼 */} + +
      +
    • + ); + }) + )} +
    + + {/* 총 금액 표시 */} + {cart.length > 0 && ( +
    + 총 합계: {total.toLocaleString()} 원 +
    + )} + + {/* 하단 버튼 */} +
    + + +
    +
    + ); +}; + +export default Cart; +``` + +- Wallet.tsx + +```tsx +import React from 'react'; +import { useShop } from '../../features/hooks/useShop'; + +const Wallet = () => { + const { balance } = useShop(); + + return ( +
    + {/* 상단 */} +
    +

    내 지갑

    + 💳 Wallet +
    + + {/* 잔액 */} +

    사용 가능한 잔액

    +

    {balance.toLocaleString()} 원

    +
    + ); +}; + +export default Wallet; +``` + +### 2.2 `Barrel (배럴) 파일` 활용하기 + +- 여러 모듈에서 내보낸 것들을 모아서 하나의 파일에서 다시 내보내는 패턴 +- 주로` index.js`나 `index.ts`로 파일명을 정한다 +- 즉, `대표 파일`이라고 함 + +- /src/features/index.ts 파일 생성 + +```ts +export * from './shop/types'; +// 아래의 경우는 충돌 발생 소지 있음. +export { initialState } from './shop/state'; +export { calcTotal } from './shop/utils'; +// 아래의 경우 역시 충돌 발생 소지 있음. +export { reducer } from './shop/reducer'; +export { ShopContext, ShopProvider } from './shop/ShopContext'; +export { useShop } from './hooks/useShop'; +export { useShopSelectors } from './hooks/useShopSelectors'; +``` + +- 해당 파일에 export 모아두기 diff --git a/src/App.tsx b/src/App.tsx index 55a7f4c..90f21ea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,18 +2,30 @@ import React from 'react'; import GoodList from './components/shop/GoodList'; import Cart from './components/shop/Cart'; import Wallet from './components/shop/Wallet'; -import { ShopProvider } from './contexts/shop/ShopContext'; +import { ShopProvider } from './features'; function App() { return ( -
    -

    나의 가게

    +
    + {/* 상단 헤더 */} +
    +

    유비두비's 쇼핑몰

    +
    + + {/* 컨텐츠 */} -
    - - - -
    +
    + {/* 상품 리스트 */} +
    + +
    + + {/* 장바구니 + 지갑 */} + +
    ); diff --git a/src/components/shop/Cart.tsx b/src/components/shop/Cart.tsx index 6dee1cb..74da7ae 100644 --- a/src/components/shop/Cart.tsx +++ b/src/components/shop/Cart.tsx @@ -1,23 +1,125 @@ import React from 'react'; -import { useShop } from '../../contexts/shop/ShopContext'; +import { useShop, useShopSelectors } from '../../features'; const Cart = () => { - const { balance, cart, removeCartOne, resetCart, clearCart, buyAll } = useShop(); + const { cart, addCart, removeCartOne, clearCart, resetCart, buyAll } = useShop(); + const { getGood, total } = useShopSelectors(); + + // 수량 직접 입력 함수 + const handleQtyChange = (id: number, value: string) => { + const qty = Number(value); + + // 빈 값이나 NaN이면 0 처리 + const newQty = isNaN(qty) ? 0 : qty; + + // 현재 장바구니 아이템 찾기 + const existItem = cart.find(item => item.id === id); + if (!existItem) return; + + const diff = newQty - existItem.qty; + + if (diff > 0) { + for (let i = 0; i < diff; i++) addCart(id); + } else if (diff < 0) { + for (let i = 0; i < Math.abs(diff); i++) removeCartOne(id); + } + + // 0 입력 시 삭제하지 않고 그대로 0 표시 + }; + return ( -
    -

    장바구니

    -
      - {cart.map(item => ( -
    • - 제품명:생략 - 구매수:{item.qty} - - +
      +

      내 카트 🛒

      + +
        + {cart.length === 0 ? ( +
      • + 장바구니가 비어있습니다.
      • - ))} + ) : ( + cart.map(item => { + const good = getGood(item.id); + return ( +
      • +
        + {good?.name} + + 가격: {(good?.price! * item.qty).toLocaleString()} 원 + +
        + + {/* 수량 컨트롤 */} +
        + {/* - 버튼 */} + + + {/* 수량 입력 */} + handleQtyChange(item.id, e.target.value)} + className=" + w-12 text-center border rounded-lg bg-white text-gray-800 + appearance-none + [&::-webkit-inner-spin-button]:appearance-none + [&::-webkit-outer-spin-button]:appearance-none + -moz-appearance:textfield + " + /> + + {/* + 버튼 */} + + + {/* 삭제 버튼 */} + +
        +
      • + ); + }) + )}
      - - + + {/* 총 금액 표시 */} + {cart.length > 0 && ( +
      + 총 합계: {total.toLocaleString()} 원 +
      + )} + + {/* 하단 버튼 */} +
      + + +
      ); }; diff --git a/src/components/shop/GoodList.tsx b/src/components/shop/GoodList.tsx index 006d561..2c9c063 100644 --- a/src/components/shop/GoodList.tsx +++ b/src/components/shop/GoodList.tsx @@ -1,17 +1,36 @@ import React from 'react'; -import { useShop } from '../../contexts/shop/ShopContext'; +import { useShop } from '../../features/hooks/useShop'; const GoodList = () => { const { goods, addCart } = useShop(); + return ( -
      -

      GoodList

      -
        +
        + {/* 제목 */} +

        상품 리스트 📦

        + + {/* 상품 그리드 */} +
          {goods.map(item => ( -
        • - 제품명 : {item.name} - 가격 : {item.price} 원 - +
        • + {/* 상품 정보 */} +
          + {item.name} + + 가격: {item.price.toLocaleString()} 원 + +
          + + {/* 담기 버튼 */} +
        • ))}
        diff --git a/src/components/shop/Wallet.tsx b/src/components/shop/Wallet.tsx index ff734bc..e91e70b 100644 --- a/src/components/shop/Wallet.tsx +++ b/src/components/shop/Wallet.tsx @@ -1,9 +1,31 @@ import React from 'react'; -import { useShop } from '../../contexts/shop/ShopContext'; +import { useShop } from '../../features'; const Wallet = () => { const { balance } = useShop(); - return
        Wallet : {balance}
        ; + + return ( +
        + {/* 상단 */} +
        +

        내 지갑

        + 💳 Wallet +
        + + {/* 잔액 */} +

        사용 가능한 잔액

        +

        {balance.toLocaleString()} 원

        +
        + ); }; export default Wallet; diff --git a/src/contexts/shop/ShopContext.tsx b/src/contexts/shop/ShopContext.tsx deleted file mode 100644 index 832f208..0000000 --- a/src/contexts/shop/ShopContext.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import React, { createContext, useContext, useReducer } from 'react'; - -// 1-1 -type CartType = { id: number; name?: string; qty: number }; // qty = quantity : 몇개를 담았는지 - -type GoodType = { - id: number; - name: string; - price: number; -}; - -type ShopStateType = { - balance: number; - cart: CartType[]; - goods: GoodType[]; -}; - -// 1. 초기값 -const initialState: ShopStateType = { - balance: 100000, - cart: [], - goods: [ - { id: 1, name: '사과', price: 1300 }, - { id: 2, name: '딸기', price: 30000 }, - { id: 3, name: '바나나', price: 5000 }, - { id: 4, name: '쪼꼬', price: 1000 }, - ], -}; - -// 2-1 enum 을 활용하여 Shop 의 Action Type 를 정의함 -enum ShopActionType { - ADD_CART = 'ADD_CART', - REMOVE_CART_ONE = 'REMOVE_CART', - CLEAR_CART_ITEM = 'CLEAR_CART', - BUY_ALL = 'BUY_ALL', - RESET = 'RESET', -} - -// 2-2 Action type 정의 -type ShopActionAddCart = { type: ShopActionType.ADD_CART; payload: { id: number } }; -type ShopActionRemoveCart = { type: ShopActionType.REMOVE_CART_ONE; payload: { id: number } }; -type ShopActionClearCart = { type: ShopActionType.CLEAR_CART_ITEM; payload: { id: number } }; -type ShopActionBuyAll = { type: ShopActionType.BUY_ALL }; // payload 필요 없음 -type ShopActionReset = { type: ShopActionType.RESET }; // payload 필요 없음 -type ShopAction = - | ShopActionAddCart - | ShopActionRemoveCart - | ShopActionClearCart - | ShopActionBuyAll - | ShopActionReset; - -// 2-3 장바구니 전체 금액 계산하기 (calcCart) - 하단 함수가 `순수 함수` -function calcCart(nowState: ShopStateType): number { - // useReducer (값을 누적해주는 함수) 아님. react 의 reduce 함수임. - const total = nowState.cart.reduce((sum, 장바구니제품) => { - // id를 이용해서 제품 상세 정보 찾기 - const good = nowState.goods.find(g => g.id === 장바구니제품.id); - if (good) { - return sum + good.price * 장바구니제품.qty; // 반드시 return - } - return sum; // good이 없으면 그대로 반환 - }, 0); - - return total; -} - -// 2. reducer -function reducer(state: ShopStateType, action: ShopAction): ShopStateType { - switch (action.type) { - case ShopActionType.ADD_CART: { - const { id } = action.payload; // { id } = 제품의 ID - const existGood = state.cart.find(item => item.id === id); - let arr: CartType[] = []; - if (existGood) { - // qty 증가 - arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)); - } else { - // state.cart 에 새 제품 추가, qty 는 1개 - arr = [...state.cart, { id, qty: 1 }]; - } - return { ...state, cart: arr }; - } - - case ShopActionType.REMOVE_CART_ONE: { - const { id } = action.payload; // 1개 빼줄 제품의 ID - const existGood = state.cart.find(item => item.id === id); - if (!existGood) { - // 제품이 없을 경우 - return state; - } - let arr: CartType[] = []; - if (existGood.qty > 1) { - // 제품이 2개 이상이면 수량 -1 - arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty - 1 } : item)); - } else { - // 제품이 1개 담겼음 → 장바구니에서 삭제 - arr = state.cart.filter(item => item.id !== id); - } - return { ...state, cart: arr }; - } - - case ShopActionType.CLEAR_CART_ITEM: { - // 담겨진 제품 중에 장바구니에서 제거하기 - const { id } = action.payload; - const arr = state.cart.filter(item => item.id !== id); - return { ...state, cart: arr }; - } - - case ShopActionType.BUY_ALL: { - // 총 금액 계산 - const total = calcCart(state); - if (total > state.balance) { - alert('잔액이 부족합니다. 잔액을 확인 해주세요'); - return state; - } - return { ...state, balance: state.balance - total, cart: [] }; - } - - case ShopActionType.RESET: { - return initialState; // initialState 에 값이 비어있어서 이렇게 넣어줘도 됨 - } - - default: - return state; - } -} - -// 3-1 -type ShopValueType = { - cart: CartType[]; - goods: GoodType[]; - balance: number; - addCart: (id: number) => void; - removeCartOne: (id: number) => void; - clearCart: (id: number) => void; - buyAll: () => void; - resetCart: () => void; -}; - -// 3. context -const ShopContext = createContext(null); - -// 4. provider -// export const ShopProvider = ({ children }: React.PropsWithChildren) => { -export const ShopProvider: React.FC = ({ children }) => { - const [state, dispatch] = useReducer(reducer, initialState); - - // 4-1. dispatch 용 함수 표현식 - const addCart = (id: number) => { - dispatch({ type: ShopActionType.ADD_CART, payload: { id } }); - }; - const removeCartOne = (id: number) => { - dispatch({ type: ShopActionType.REMOVE_CART_ONE, payload: { id } }); - }; - const clearCart = (id: number) => { - dispatch({ type: ShopActionType.CLEAR_CART_ITEM, payload: { id } }); - }; - const buyAll = () => { - dispatch({ type: ShopActionType.BUY_ALL }); - }; - const resetCart = () => { - dispatch({ type: ShopActionType.RESET }); - }; - - const value: ShopValueType = { - cart: state.cart, - goods: state.goods, - balance: state.balance, - addCart, - removeCartOne, - clearCart, - buyAll, - resetCart, - }; - - return {children}; -}; - -// 5. custom hook -export function useShop() { - const ctx = useContext(ShopContext); - if (!ctx) { - throw new Error('Shop context 가 생성되지 않았습니다.'); - } - return ctx; -} diff --git a/src/features/hooks/useShop.ts b/src/features/hooks/useShop.ts new file mode 100644 index 0000000..babb022 --- /dev/null +++ b/src/features/hooks/useShop.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { ShopContext } from '../shop/ShopContext'; + +export function useShop() { + const ctx = useContext(ShopContext); + if (!ctx) { + throw new Error('Shop context 가 생성되지 않았습니다.'); + } + return ctx; +} diff --git a/src/features/hooks/useShopSelectors.ts b/src/features/hooks/useShopSelectors.ts new file mode 100644 index 0000000..98e9b79 --- /dev/null +++ b/src/features/hooks/useShopSelectors.ts @@ -0,0 +1,12 @@ +import { calcTotal } from '../shop/utils'; +import { useShop } from './useShop'; + +export function useShopSelectors() { + const { cart, goods } = useShop(); + // 제품 한개 정보 찾기 + const getGood = (id: number) => goods.find(item => item.id === id); + // 총 금액 + const total = calcTotal(cart, goods); + // 되돌려줌 + return { getGood, total }; +} diff --git a/src/features/index.ts b/src/features/index.ts new file mode 100644 index 0000000..3d050be --- /dev/null +++ b/src/features/index.ts @@ -0,0 +1,9 @@ +export * from './shop/types'; +// 아래의 경우는 충돌 발생 소지 있음. +export { initialState } from './shop/state'; +export { calcTotal } from './shop/utils'; +// 아래의 경우 역시 충돌 발생 소지 있음. +export { reducer } from './shop/reducer'; +export { ShopContext, ShopProvider } from './shop/ShopContext'; +export { useShop } from './hooks/useShop'; +export { useShopSelectors } from './hooks/useShopSelectors'; diff --git a/src/features/shop/ShopContext.tsx b/src/features/shop/ShopContext.tsx new file mode 100644 index 0000000..714bf9f --- /dev/null +++ b/src/features/shop/ShopContext.tsx @@ -0,0 +1,42 @@ +import React, { createContext, useReducer } from 'react'; +import { ShopActionType, type ShopValueType } from './types'; +import { reducer } from './reducer'; +import { initialState } from './state'; + +export const ShopContext = createContext(null); + +// 4. provider +// export const ShopProvider = ({ children }: React.PropsWithChildren) => { +export const ShopProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + // 4-1. dispatch 용 함수 표현식 + const addCart = (id: number) => { + dispatch({ type: ShopActionType.ADD_CART, payload: { id } }); + }; + const removeCartOne = (id: number) => { + dispatch({ type: ShopActionType.REMOVE_CART_ONE, payload: { id } }); + }; + const clearCart = (id: number) => { + dispatch({ type: ShopActionType.CLEAR_CART_ITEM, payload: { id } }); + }; + const buyAll = () => { + dispatch({ type: ShopActionType.BUY_ALL }); + }; + const resetCart = () => { + dispatch({ type: ShopActionType.RESET }); + }; + + const value: ShopValueType = { + cart: state.cart, + goods: state.goods, + balance: state.balance, + addCart, + removeCartOne, + clearCart, + buyAll, + resetCart, + }; + + return {children}; +}; diff --git a/src/features/shop/reducer.ts b/src/features/shop/reducer.ts new file mode 100644 index 0000000..bb1fdcf --- /dev/null +++ b/src/features/shop/reducer.ts @@ -0,0 +1,70 @@ +import { initialState } from './state'; +import { ShopActionType, type CartType, type ShopAction, type ShopStateType } from './types'; +import { calcTotal } from './utils'; + +export function reducer(state: ShopStateType, action: ShopAction): ShopStateType { + switch (action.type) { + case ShopActionType.ADD_CART: { + const { id } = action.payload; // { id } = 제품의 ID, 1개 빼줄 제품의 ID + const existGood = state.cart.find(item => item.id === id); + let arr: CartType[] = []; + if (existGood) { + // qty 증가 + arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)); + } else { + // state.cart 에 새 제품 추가, qty 는 1개 + arr = [...state.cart, { id, qty: 1 }]; + } + return { ...state, cart: arr }; + } + + case ShopActionType.REMOVE_CART_ONE: { + const { id } = action.payload; // 1개 빼줄 제품의 ID + const existGood = state.cart.find(item => item.id === id); + if (!existGood) { + // 제품이 없을 경우 + return state; + } + let arr: CartType[] = []; + if (existGood.qty > 1) { + // 제품이 2개 이상이면 수량 -1 + arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty - 1 } : item)); + } else { + // 제품이 1개 담겼음 → 장바구니에서 삭제 + arr = state.cart.filter(item => item.id !== id); + } + return { ...state, cart: arr }; + } + + // 장바구니 추가/삭제 만약, 0이 되어버리면 삭제 버튼 외엔 삭제 되지 않게끔. 0으로 출력(?) + case ShopActionType.REMOVE_CART_ONE: { + const { id } = action.payload; + const existItem = state.cart.find(item => item.id === id); + if (!existItem) return state; + + // 수량 -1, 단 0 이하로는 떨어지지 않음 + const arr = state.cart.map(item => + item.id === id ? { ...item, qty: Math.max(item.qty - 1, 0) } : item, + ); + + return { ...state, cart: arr }; + } + + case ShopActionType.BUY_ALL: { + // 총 금액 계산 + const total = calcTotal(state.cart, state.goods); + if (total > state.balance) { + alert('잔액이 부족합니다. 잔액을 확인 해주세요'); + return state; + } + return { ...state, balance: state.balance - total, cart: [] }; + } + + case ShopActionType.RESET: { + return initialState; // initialState 에 값이 비어있어서 이렇게 넣어줘도 됨 + } + + default: + return state; + } +} diff --git a/src/features/shop/state.ts b/src/features/shop/state.ts new file mode 100644 index 0000000..f314c16 --- /dev/null +++ b/src/features/shop/state.ts @@ -0,0 +1,13 @@ +import type { ShopStateType } from './types'; + +// 초기값 상태 +export const initialState: ShopStateType = { + balance: 100000, + cart: [], + goods: [ + { id: 1, name: '사과', price: 1300 }, + { id: 2, name: '딸기', price: 30000 }, + { id: 3, name: '바나나', price: 5000 }, + { id: 4, name: '초콜릿', price: 1000 }, + ], +}; diff --git a/src/features/shop/types.ts b/src/features/shop/types.ts new file mode 100644 index 0000000..afce779 --- /dev/null +++ b/src/features/shop/types.ts @@ -0,0 +1,52 @@ +// 장바구니 아이템 Type +export type CartType = { id: number; name?: string; qty: number }; // qty = quantity : 몇개를 담았는지 + +// 제품 아이템 Type +export type GoodType = { + id: number; + name: string; + price: number; +}; + +// ShopStateType +export type ShopStateType = { + balance: number; + cart: CartType[]; + goods: GoodType[]; +}; + +// Action Type (constant.ts - 상수 타입으로 옮겨줘도 됨.) +export enum ShopActionType { + ADD_CART = 'ADD_CART', + REMOVE_CART_ONE = 'REMOVE_CART', + CLEAR_CART_ITEM = 'CLEAR_CART', + BUY_ALL = 'BUY_ALL', + RESET = 'RESET', +} + +export type ShopActionAddCart = { type: ShopActionType.ADD_CART; payload: { id: number } }; +export type ShopActionRemoveCart = { + type: ShopActionType.REMOVE_CART_ONE; + payload: { id: number }; +}; +export type ShopActionClearCart = { type: ShopActionType.CLEAR_CART_ITEM; payload: { id: number } }; +export type ShopActionBuyAll = { type: ShopActionType.BUY_ALL }; // payload 필요 없음 +export type ShopActionReset = { type: ShopActionType.RESET }; // payload 필요 없음 +export type ShopAction = + | ShopActionAddCart + | ShopActionRemoveCart + | ShopActionClearCart + | ShopActionBuyAll + | ShopActionReset; + +// Context 의 value Type +export type ShopValueType = { + cart: CartType[]; + goods: GoodType[]; + balance: number; + addCart: (id: number) => void; + removeCartOne: (id: number) => void; + clearCart: (id: number) => void; + buyAll: () => void; + resetCart: () => void; +}; diff --git a/src/features/shop/utils.ts b/src/features/shop/utils.ts new file mode 100644 index 0000000..b63f037 --- /dev/null +++ b/src/features/shop/utils.ts @@ -0,0 +1,22 @@ +import type { CartType, GoodType } from './types'; + +// 2-3 장바구니 전체 금액 계산하기 (calcCart) - 하단 함수가 `순수 함수` +// function calcCart(nowState: ShopStateType): number { +// useReducer (값을 누적해주는 함수) 아님. react 의 reduce 함수임. +// const total = nowState.cart.reduce((sum, 장바구니제품) => { +// id를 이용해서 제품 상세 정보 찾기 +// const good = nowState.goods.find(g => g.id === 장바구니제품.id); +// if (good) { +// return sum + good.price * 장바구니제품.qty; // 반드시 return +// } +// return sum; // good이 없으면 그대로 반환 +// }, 0); +// return total; +// } +// cart, goods 만 필요하므로 타입을 좁힘 +export function calcTotal(cart: CartType[], goods: GoodType[]): number { + return cart.reduce((sum, c) => { + const good = goods.find(g => g.id === c.id); + return good ? sum + good.price * c.qty : sum; + }, 0); +} From dfce3fcc731127bee609f7e5196ed74b8c1cae5a Mon Sep 17 00:00:00 2001 From: suha720 Date: Fri, 29 Aug 2025 10:39:49 +0900 Subject: [PATCH 08/13] =?UTF-8?q?[docs]=20react-router-dom=20=EC=9D=B4?= =?UTF-8?q?=ED=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1376 +++++--------------------------------- package-lock.json | 44 +- package.json | 3 +- src/App.tsx | 100 ++- src/pages/CartPage.tsx | 14 + src/pages/GoodsPage.tsx | 14 + src/pages/HomePage.tsx | 38 ++ src/pages/NotFound.tsx | 29 + src/pages/WalletPage.tsx | 14 + 9 files changed, 390 insertions(+), 1242 deletions(-) create mode 100644 src/pages/CartPage.tsx create mode 100644 src/pages/GoodsPage.tsx create mode 100644 src/pages/HomePage.tsx create mode 100644 src/pages/NotFound.tsx create mode 100644 src/pages/WalletPage.tsx diff --git a/README.md b/README.md index 032d14b..0c53ed0 100644 --- a/README.md +++ b/README.md @@ -1,1293 +1,237 @@ -# Context API / useReducer 예제 +# react-router-dom -- 쇼핑몰 장바구니, 잔액 관리 +## 1. 설치 -## 1. 폴더 및 파일 구조 +- v7 은 조금 문제가 발생하여, v6 사용함 -- /src/contexts/shop 폴더 생성 -- /src/contexts/shop/ShopContext.tsx 파일 생성 - -```tsx -import React, { createContext, useContext, useReducer } from 'react'; - -// 1-1 -type CartType = { id: number; name?: string; qty: number }; // qty = quantity : 몇개를 담았는지 - -type GoodType = { - id: number; - name: string; - price: number; -}; - -type ShopStateType = { - balance: number; - cart: CartType[]; - goods: GoodType[]; -}; - -// 1. 초기값 -const initialState: ShopStateType = { - balance: 100000, - cart: [], - goods: [ - { id: 1, name: '사과', price: 1300 }, - { id: 2, name: '딸기', price: 30000 }, - { id: 3, name: '바나나', price: 5000 }, - { id: 4, name: '쪼꼬', price: 1000 }, - ], -}; - -// 2-1 enum 을 활용하여 Shop 의 Action Type 를 정의함 -enum ShopActionType { - ADD_CART = 'ADD_CART', - REMOVE_CART_ONE = 'REMOVE_CART', - CLEAR_CART_ITEM = 'CLEAR_CART', - BUY_ALL = 'BUY_ALL', - RESET = 'RESET', -} - -// 2-2 Action type 정의 -type ShopActionAddCart = { type: ShopActionType.ADD_CART; payload: { id: number } }; -type ShopActionRemoveCart = { type: ShopActionType.REMOVE_CART_ONE; payload: { id: number } }; -type ShopActionClearCart = { type: ShopActionType.CLEAR_CART_ITEM; payload: { id: number } }; -type ShopActionBuyAll = { type: ShopActionType.BUY_ALL }; // payload 필요 없음 -type ShopActionReset = { type: ShopActionType.RESET }; // payload 필요 없음 -type ShopAction = - | ShopActionAddCart - | ShopActionRemoveCart - | ShopActionClearCart - | ShopActionBuyAll - | ShopActionReset; - -// 2-3 장바구니 전체 금액 계산하기 (calcCart) - 하단 함수가 `순수 함수` -function calcCart(nowState: ShopStateType): number { - // useReducer (값을 누적해주는 함수) 아님. react 의 reduce 함수임. - const total = nowState.cart.reduce((sum, 장바구니제품) => { - // id를 이용해서 제품 상세 정보 찾기 - const good = nowState.goods.find(g => g.id === 장바구니제품.id); - if (good) { - return sum + good.price * 장바구니제품.qty; // 반드시 return - } - return sum; // good이 없으면 그대로 반환 - }, 0); - - return total; -} - -// 2. reducer -function reducer(state: ShopStateType, action: ShopAction): ShopStateType { - switch (action.type) { - case ShopActionType.ADD_CART: { - const { id } = action.payload; // { id } = 제품의 ID - const existGood = state.cart.find(item => item.id === id); - let arr: CartType[] = []; - if (existGood) { - // qty 증가 - arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)); - } else { - // state.cart 에 새 제품 추가, qty 는 1개 - arr = [...state.cart, { id, qty: 1 }]; - } - return { ...state, cart: arr }; - } - - case ShopActionType.REMOVE_CART_ONE: { - const { id } = action.payload; // 1개 빼줄 제품의 ID - const existGood = state.cart.find(item => item.id === id); - if (!existGood) { - // 제품이 없을 경우 - return state; - } - let arr: CartType[] = []; - if (existGood.qty > 1) { - // 제품이 2개 이상이면 수량 -1 - arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty - 1 } : item)); - } else { - // 제품이 1개 담겼음 → 장바구니에서 삭제 - arr = state.cart.filter(item => item.id !== id); - } - return { ...state, cart: arr }; - } - - case ShopActionType.CLEAR_CART_ITEM: { - // 담겨진 제품 중에 장바구니에서 제거하기 - const { id } = action.payload; - const arr = state.cart.filter(item => item.id !== id); - return { ...state, cart: arr }; - } - - case ShopActionType.BUY_ALL: { - // 총 금액 계산 - const total = calcCart(state); - if (total > state.balance) { - alert('잔액이 부족합니다. 잔액을 확인 해주세요'); - return state; - } - return { ...state, balance: state.balance - total, cart: [] }; - } - - case ShopActionType.RESET: { - return initialState; // initialState 에 값이 비어있어서 이렇게 넣어줘도 됨 - } - - default: - return state; - } -} - -// 3-1 -type ShopValueType = { - cart: CartType[]; - goods: GoodType[]; - balance: number; - addCart: (id: number) => void; - removeCartOne: (id: number) => void; - clearCart: (id: number) => void; - buyAll: () => void; - resetCart: () => void; -}; - -// 3. context -const ShopContext = createContext(null); - -// 4. provider -// export const ShopProvider = ({ children }: React.PropsWithChildren) => { -export const ShopProvider: React.FC = ({ children }) => { - const [state, dispatch] = useReducer(reducer, initialState); - - // 4-1. dispatch 용 함수 표현식 - const addCart = (id: number) => { - dispatch({ type: ShopActionType.ADD_CART, payload: { id } }); - }; - const removeCartOne = (id: number) => { - dispatch({ type: ShopActionType.REMOVE_CART_ONE, payload: { id } }); - }; - const clearCart = (id: number) => { - dispatch({ type: ShopActionType.CLEAR_CART_ITEM, payload: { id } }); - }; - const buyAll = () => { - dispatch({ type: ShopActionType.BUY_ALL }); - }; - const resetCart = () => { - dispatch({ type: ShopActionType.RESET }); - }; - - const value: ShopValueType = { - cart: state.cart, - goods: state.goods, - balance: state.balance, - addCart, - removeCartOne, - clearCart, - buyAll, - resetCart, - }; - - return {children}; -}; - -// 5. custom hook -export function useShop() { - const ctx = useContext(ShopContext); - if (!ctx) { - throw new Error('Shop context 가 생성되지 않았습니다.'); - } - return ctx; -} +```bash +npm i react-router-dom@6.30.1 ``` -- /src/components/shop 폴더 생성 -- /src/components/shop/GoodList.tsx 파일 생성 +## 2. 폴더 및 파일 구조 -```tsx -import React from 'react'; -import { useShop } from '../../contexts/shop/ShopContext'; - -const GoodList = () => { - const { goods, addCart } = useShop(); - return ( -
        -

        GoodList

        -
          - {goods.map(item => ( -
        • - 제품명 : {item.name} - 가격 : {item.price} 원 - -
        • - ))} -
        -
        - ); -}; - -export default GoodList; -``` - -- /src/components/shop/Cart.tsx 파일 생성 - -```tsx -import React from 'react'; -import { useShop } from '../../contexts/shop/ShopContext'; - -const Cart = () => { - const { balance, cart, removeCartOne, resetCart, clearCart, buyAll } = useShop(); - return ( -
        -

        장바구니

        -
          - {cart.map(item => ( -
        • - 제품명:생략 - 구매수:{item.qty} - - -
        • - ))} -
        - - -
        - ); -}; - -export default Cart; -``` - -- /src/components/shop/Wallet.tsx 파일 생성 +- /src/pages 폴더 생성 +- /src/pages/HomePage.tsx 파일 생성 ```tsx import React from 'react'; -import { useShop } from '../../contexts/shop/ShopContext'; - -const Wallet = () => { - const { balance } = useShop(); - return
        Wallet : {balance}
        ; -}; - -export default Wallet; -``` - -- App.tsx -```tsx -import React from 'react'; -import GoodList from './components/shop/GoodList'; -import Cart from './components/shop/Cart'; -import Wallet from './components/shop/Wallet'; -import { ShopProvider } from './contexts/shop/ShopContext'; - -function App() { +function HomePage() { return ( -
        -

        나의 가게

        - -
        - - - +
        + {/* Hero 영역 */} +
        +

        환영합니다!

        +

        이곳은 메인 홈 화면입니다. 상단 메뉴에서 쇼핑을 즐겨주세요!

        +
        + + {/* 소개 카드 */} +
        +
        +

        추천 상품

        +

        이번 주 가장 인기 있는 상품을 확인해보세요.

        - -
        - ); -} -export default App; -``` - -## 최종 css + 기능 수정 버전 - -- App.tsx - -```tsx -import React from 'react'; -import GoodList from './components/shop/GoodList'; -import Cart from './components/shop/Cart'; -import Wallet from './components/shop/Wallet'; -import { ShopProvider } from './contexts/shop/ShopContext'; - -function App() { - return ( -
        - {/* 상단 헤더 */} -
        -

        유비두비's 쇼핑몰

        -
        +
        +

        이벤트

        +

        다양한 할인 이벤트와 쿠폰을 만나보세요.

        +
        - {/* 컨텐츠 */} - -
        - {/* 상품 리스트 */} -
        - -
        +
        +

        회원 혜택

        +

        회원 전용 특별 혜택을 놓치지 마세요!

        +
        + - {/* 장바구니 + 지갑 */} - -
        -
        + {/* 푸터 */} +
        +

        © 2025 DDODO 쇼핑몰. All rights reserved.

        +
        ); } -export default App; +export default HomePage; ``` -- Cart.tsx +- /src/pages/GoodsPage.tsx 파일 생성 ```tsx import React from 'react'; -import { useShop, useShopSelectors } from '../../contexts/shop/ShopContext'; - -const Cart = () => { - const { cart, addCart, removeCartOne, clearCart, resetCart, buyAll } = useShop(); - const { getGood, total } = useShopSelectors(); - - // 수량 직접 입력 함수 - const handleQtyChange = (id: number, value: string) => { - const qty = Number(value); - - // 빈 값이나 NaN이면 0 처리 - const newQty = isNaN(qty) ? 0 : qty; - - // 현재 장바구니 아이템 찾기 - const existItem = cart.find(item => item.id === id); - if (!existItem) return; - - const diff = newQty - existItem.qty; - - if (diff > 0) { - for (let i = 0; i < diff; i++) addCart(id); - } else if (diff < 0) { - for (let i = 0; i < Math.abs(diff); i++) removeCartOne(id); - } - - // 0 입력 시 삭제하지 않고 그대로 0 표시 - }; +import GoodList from '../components/shop/GoodList'; +function GoodsPage() { return ( -
        -

        내 카트 🛒

        - -
          - {cart.length === 0 ? ( -
        • - 장바구니가 비어있습니다. -
        • - ) : ( - cart.map(item => { - const good = getGood(item.id); - return ( -
        • -
          - {good?.name} - - 가격: {(good?.price! * item.qty).toLocaleString()} 원 - -
          - - {/* 수량 컨트롤 */} -
          - {/* - 버튼 */} - - - {/* 수량 입력 */} - handleQtyChange(item.id, e.target.value)} - className=" - w-12 text-center border rounded-lg bg-white text-gray-800 - appearance-none - [&::-webkit-inner-spin-button]:appearance-none - [&::-webkit-outer-spin-button]:appearance-none - -moz-appearance:textfield - " - /> - - {/* + 버튼 */} - - - {/* 삭제 버튼 */} - -
          -
        • - ); - }) - )} -
        - - {/* 총 금액 표시 */} - {cart.length > 0 && ( -
        - 총 합계: {total.toLocaleString()} 원 -
        - )} - - {/* 하단 버튼 */} -
        - - +
        +
        +
        ); -}; - -export default Cart; -``` - -- ShopContext.tsx - -```tsx -import React, { createContext, useContext, useReducer } from 'react'; - -// 1-1 -type CartType = { id: number; name?: string; qty: number }; // qty = quantity : 몇개를 담았는지 - -type GoodType = { - id: number; - name: string; - price: number; -}; - -type ShopStateType = { - balance: number; - cart: CartType[]; - goods: GoodType[]; -}; - -// 1. 초기값 -const initialState: ShopStateType = { - balance: 100000, - cart: [], - goods: [ - { id: 1, name: '사과', price: 1300 }, - { id: 2, name: '딸기', price: 30000 }, - { id: 3, name: '바나나', price: 5000 }, - { id: 4, name: '초콜릿', price: 1000 }, - ], -}; - -// 2-1 enum 을 활용하여 Shop 의 Action Type 를 정의함 -enum ShopActionType { - ADD_CART = 'ADD_CART', - REMOVE_CART_ONE = 'REMOVE_CART', - CLEAR_CART_ITEM = 'CLEAR_CART', - BUY_ALL = 'BUY_ALL', - RESET = 'RESET', -} - -// 2-2 Action type 정의 -type ShopActionAddCart = { type: ShopActionType.ADD_CART; payload: { id: number } }; -type ShopActionRemoveCart = { type: ShopActionType.REMOVE_CART_ONE; payload: { id: number } }; -type ShopActionClearCart = { type: ShopActionType.CLEAR_CART_ITEM; payload: { id: number } }; -type ShopActionBuyAll = { type: ShopActionType.BUY_ALL }; // payload 필요 없음 -type ShopActionReset = { type: ShopActionType.RESET }; // payload 필요 없음 -type ShopAction = - | ShopActionAddCart - | ShopActionRemoveCart - | ShopActionClearCart - | ShopActionBuyAll - | ShopActionReset; - -// 2-3 장바구니 전체 금액 계산하기 (calcCart) - 하단 함수가 `순수 함수` -// function calcCart(nowState: ShopStateType): number { -// useReducer (값을 누적해주는 함수) 아님. react 의 reduce 함수임. -// const total = nowState.cart.reduce((sum, 장바구니제품) => { -// id를 이용해서 제품 상세 정보 찾기 -// const good = nowState.goods.find(g => g.id === 장바구니제품.id); -// if (good) { -// return sum + good.price * 장바구니제품.qty; // 반드시 return -// } -// return sum; // good이 없으면 그대로 반환 -// }, 0); - -// return total; -// } - -// cart, goods 만 필요하므로 타입을 좁힘 -function calcTotal(cart: CartType[], goods: GoodType[]): number { - return cart.reduce((sum, c) => { - const good = goods.find(g => g.id === c.id); - return good ? sum + good.price * c.qty : sum; - }, 0); -} - -// 2. reducer -function reducer(state: ShopStateType, action: ShopAction): ShopStateType { - switch (action.type) { - case ShopActionType.ADD_CART: { - const { id } = action.payload; // { id } = 제품의 ID, 1개 빼줄 제품의 ID - const existGood = state.cart.find(item => item.id === id); - let arr: CartType[] = []; - if (existGood) { - // qty 증가 - arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)); - } else { - // state.cart 에 새 제품 추가, qty 는 1개 - arr = [...state.cart, { id, qty: 1 }]; - } - return { ...state, cart: arr }; - } - - case ShopActionType.REMOVE_CART_ONE: { - const { id } = action.payload; // 1개 빼줄 제품의 ID - const existGood = state.cart.find(item => item.id === id); - if (!existGood) { - // 제품이 없을 경우 - return state; - } - let arr: CartType[] = []; - if (existGood.qty > 1) { - // 제품이 2개 이상이면 수량 -1 - arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty - 1 } : item)); - } else { - // 제품이 1개 담겼음 → 장바구니에서 삭제 - arr = state.cart.filter(item => item.id !== id); - } - return { ...state, cart: arr }; - } - - // 장바구니 추가/삭제 만약, 0이 되어버리면 삭제 버튼 외엔 삭제 되지 않게끔. 0으로 출력(?) - case ShopActionType.REMOVE_CART_ONE: { - const { id } = action.payload; - const existItem = state.cart.find(item => item.id === id); - if (!existItem) return state; - - // 수량 -1, 단 0 이하로는 떨어지지 않음 - const arr = state.cart.map(item => - item.id === id ? { ...item, qty: Math.max(item.qty - 1, 0) } : item, - ); - - return { ...state, cart: arr }; - } - - case ShopActionType.BUY_ALL: { - // 총 금액 계산 - const total = calcTotal(state.cart, state.goods); - if (total > state.balance) { - alert('잔액이 부족합니다. 잔액을 확인 해주세요'); - return state; - } - return { ...state, balance: state.balance - total, cart: [] }; - } - - case ShopActionType.RESET: { - return initialState; // initialState 에 값이 비어있어서 이렇게 넣어줘도 됨 - } - - default: - return state; - } -} - -// 3-1 -type ShopValueType = { - cart: CartType[]; - goods: GoodType[]; - balance: number; - addCart: (id: number) => void; - removeCartOne: (id: number) => void; - clearCart: (id: number) => void; - buyAll: () => void; - resetCart: () => void; -}; - -// 3. context -const ShopContext = createContext(null); - -// 4. provider -// export const ShopProvider = ({ children }: React.PropsWithChildren) => { -export const ShopProvider: React.FC = ({ children }) => { - const [state, dispatch] = useReducer(reducer, initialState); - - // 4-1. dispatch 용 함수 표현식 - const addCart = (id: number) => { - dispatch({ type: ShopActionType.ADD_CART, payload: { id } }); - }; - const removeCartOne = (id: number) => { - dispatch({ type: ShopActionType.REMOVE_CART_ONE, payload: { id } }); - }; - const clearCart = (id: number) => { - dispatch({ type: ShopActionType.CLEAR_CART_ITEM, payload: { id } }); - }; - const buyAll = () => { - dispatch({ type: ShopActionType.BUY_ALL }); - }; - const resetCart = () => { - dispatch({ type: ShopActionType.RESET }); - }; - - const value: ShopValueType = { - cart: state.cart, - goods: state.goods, - balance: state.balance, - addCart, - removeCartOne, - clearCart, - buyAll, - resetCart, - }; - - return {children}; -}; - -// 5. custom hook -export function useShop() { - const ctx = useContext(ShopContext); - if (!ctx) { - throw new Error('Shop context 가 생성되지 않았습니다.'); - } - return ctx; } -// 6. 추가 custom hook - -export function useShopSelectors() { - const { cart, goods } = useShop(); - // 제품 한개 정보 찾기 - const getGood = (id: number) => goods.find(item => item.id === id); - // 총 금액 - const total = calcTotal(cart, goods); - // 되돌려줌 - return { getGood, total }; -} +export default GoodsPage; ``` -- GoodList.tsx +- /src/pages/CartPage.tsx 파일 생성 ```tsx import React from 'react'; -import { useShop } from '../../contexts/shop/ShopContext'; - -const GoodList = () => { - const { goods, addCart } = useShop(); +import Cart from '../components/shop/Cart'; +function CartPage() { return ( -
        - {/* 제목 */} -

        상품 리스트 📦

        - - {/* 상품 그리드 */} -
          - {goods.map(item => ( -
        • - {/* 상품 정보 */} -
          - {item.name} - - 가격: {item.price.toLocaleString()} 원 - -
          - - {/* 담기 버튼 */} - -
        • - ))} -
        -
        - ); -}; - -export default GoodList; -``` - -- Wallet.tsx - -```tsx -import React from 'react'; -import { useShop } from '../../contexts/shop/ShopContext'; - -const Wallet = () => { - const { balance } = useShop(); - - return ( -
        - {/* 상단 */} -
        -

        내 지갑

        - 💳 Wallet +
        +
        +
        - - {/* 잔액 */} -

        사용 가능한 잔액

        -

        {balance.toLocaleString()} 원

        ); -}; - -export default Wallet; -``` - -## 2. 실전 파일 분리하기 - -### 2.1. 폴더 및 파일 구조 - -- 기능별로 분리한다면 contexts 말고 `features (기능)` 폴더로 -- `/src/features` 폴더 생성 -- `/src/features/shop` 폴더 생성 -- `/src/features/shop/types.ts` 파일 생성 - -```ts -// 장바구니 아이템 Type -export type CartType = { id: number; name?: string; qty: number }; // qty = quantity : 몇개를 담았는지 - -// 제품 아이템 Type -export type GoodType = { - id: number; - name: string; - price: number; -}; - -// ShopStateType -export type ShopStateType = { - balance: number; - cart: CartType[]; - goods: GoodType[]; -}; - -// Action Type (constant.ts - 상수 타입으로 옮겨줘도 됨.) -export enum ShopActionType { - ADD_CART = 'ADD_CART', - REMOVE_CART_ONE = 'REMOVE_CART', - CLEAR_CART_ITEM = 'CLEAR_CART', - BUY_ALL = 'BUY_ALL', - RESET = 'RESET', } -export type ShopActionAddCart = { type: ShopActionType.ADD_CART; payload: { id: number } }; -export type ShopActionRemoveCart = { - type: ShopActionType.REMOVE_CART_ONE; - payload: { id: number }; -}; -export type ShopActionClearCart = { type: ShopActionType.CLEAR_CART_ITEM; payload: { id: number } }; -export type ShopActionBuyAll = { type: ShopActionType.BUY_ALL }; // payload 필요 없음 -export type ShopActionReset = { type: ShopActionType.RESET }; // payload 필요 없음 -export type ShopAction = - | ShopActionAddCart - | ShopActionRemoveCart - | ShopActionClearCart - | ShopActionBuyAll - | ShopActionReset; - -// Context 의 value Type -export type ShopValueType = { - cart: CartType[]; - goods: GoodType[]; - balance: number; - addCart: (id: number) => void; - removeCartOne: (id: number) => void; - clearCart: (id: number) => void; - buyAll: () => void; - resetCart: () => void; -}; -``` - -- `/src/features/shop/state.ts` 파일 생성 - -```ts -import type { ShopStateType } from './types'; - -// 초기값 상태 -export const initialState: ShopStateType = { - balance: 100000, - cart: [], - goods: [ - { id: 1, name: '사과', price: 1300 }, - { id: 2, name: '딸기', price: 30000 }, - { id: 3, name: '바나나', price: 5000 }, - { id: 4, name: '초콜릿', price: 1000 }, - ], -}; -``` - -- `/src/features/shop/utils.ts` 파일 생성 - -```ts -import type { CartType, GoodType } from './types'; - -// 2-3 장바구니 전체 금액 계산하기 (calcCart) - 하단 함수가 `순수 함수` -// function calcCart(nowState: ShopStateType): number { -// useReducer (값을 누적해주는 함수) 아님. react 의 reduce 함수임. -// const total = nowState.cart.reduce((sum, 장바구니제품) => { -// id를 이용해서 제품 상세 정보 찾기 -// const good = nowState.goods.find(g => g.id === 장바구니제품.id); -// if (good) { -// return sum + good.price * 장바구니제품.qty; // 반드시 return -// } -// return sum; // good이 없으면 그대로 반환 -// }, 0); -// return total; -// } -// cart, goods 만 필요하므로 타입을 좁힘 -export function calcTotal(cart: CartType[], goods: GoodType[]): number { - return cart.reduce((sum, c) => { - const good = goods.find(g => g.id === c.id); - return good ? sum + good.price * c.qty : sum; - }, 0); -} -``` - -- `/src/features/shop/reducer.ts` 파일 생성 - -```ts -import { initialState } from './state'; -import { ShopActionType, type CartType, type ShopAction, type ShopStateType } from './types'; -import { calcTotal } from './utils'; - -export function reducer(state: ShopStateType, action: ShopAction): ShopStateType { - switch (action.type) { - case ShopActionType.ADD_CART: { - const { id } = action.payload; // { id } = 제품의 ID, 1개 빼줄 제품의 ID - const existGood = state.cart.find(item => item.id === id); - let arr: CartType[] = []; - if (existGood) { - // qty 증가 - arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)); - } else { - // state.cart 에 새 제품 추가, qty 는 1개 - arr = [...state.cart, { id, qty: 1 }]; - } - return { ...state, cart: arr }; - } - - case ShopActionType.REMOVE_CART_ONE: { - const { id } = action.payload; // 1개 빼줄 제품의 ID - const existGood = state.cart.find(item => item.id === id); - if (!existGood) { - // 제품이 없을 경우 - return state; - } - let arr: CartType[] = []; - if (existGood.qty > 1) { - // 제품이 2개 이상이면 수량 -1 - arr = state.cart.map(item => (item.id === id ? { ...item, qty: item.qty - 1 } : item)); - } else { - // 제품이 1개 담겼음 → 장바구니에서 삭제 - arr = state.cart.filter(item => item.id !== id); - } - return { ...state, cart: arr }; - } - - // 장바구니 추가/삭제 만약, 0이 되어버리면 삭제 버튼 외엔 삭제 되지 않게끔. 0으로 출력(?) - case ShopActionType.REMOVE_CART_ONE: { - const { id } = action.payload; - const existItem = state.cart.find(item => item.id === id); - if (!existItem) return state; - - // 수량 -1, 단 0 이하로는 떨어지지 않음 - const arr = state.cart.map(item => - item.id === id ? { ...item, qty: Math.max(item.qty - 1, 0) } : item, - ); - - return { ...state, cart: arr }; - } - - case ShopActionType.BUY_ALL: { - // 총 금액 계산 - const total = calcTotal(state.cart, state.goods); - if (total > state.balance) { - alert('잔액이 부족합니다. 잔액을 확인 해주세요'); - return state; - } - return { ...state, balance: state.balance - total, cart: [] }; - } - - case ShopActionType.RESET: { - return initialState; // initialState 에 값이 비어있어서 이렇게 넣어줘도 됨 - } - - default: - return state; - } -} -``` - -- `/src/features/shop/ShopContext.tsx` 파일 생성 - -```tsx -import React, { createContext, useReducer } from 'react'; -import { ShopActionType, type ShopValueType } from './types'; -import { reducer } from './reducer'; -import { initialState } from './state'; - -export const ShopContext = createContext(null); - -// 4. provider -// export const ShopProvider = ({ children }: React.PropsWithChildren) => { -export const ShopProvider: React.FC = ({ children }) => { - const [state, dispatch] = useReducer(reducer, initialState); - - // 4-1. dispatch 용 함수 표현식 - const addCart = (id: number) => { - dispatch({ type: ShopActionType.ADD_CART, payload: { id } }); - }; - const removeCartOne = (id: number) => { - dispatch({ type: ShopActionType.REMOVE_CART_ONE, payload: { id } }); - }; - const clearCart = (id: number) => { - dispatch({ type: ShopActionType.CLEAR_CART_ITEM, payload: { id } }); - }; - const buyAll = () => { - dispatch({ type: ShopActionType.BUY_ALL }); - }; - const resetCart = () => { - dispatch({ type: ShopActionType.RESET }); - }; - - const value: ShopValueType = { - cart: state.cart, - goods: state.goods, - balance: state.balance, - addCart, - removeCartOne, - clearCart, - buyAll, - resetCart, - }; - - return {children}; -}; -``` - -- `/src/features/shop/useShopSelectors.ts` 파일 생성 - -```ts -import { calcTotal } from '../shop/utils'; -import { useShop } from './useShop'; - -export function useShopSelectors() { - const { cart, goods } = useShop(); - // 제품 한개 정보 찾기 - const getGood = (id: number) => goods.find(item => item.id === id); - // 총 금액 - const total = calcTotal(cart, goods); - // 되돌려줌 - return { getGood, total }; -} +export default CartPage; ``` -- App.tsx +- /src/pages/WalletPage.tsx 파일 생성 ```tsx import React from 'react'; -import GoodList from './components/shop/GoodList'; -import Cart from './components/shop/Cart'; -import Wallet from './components/shop/Wallet'; -import { ShopProvider } from './features/shop/ShopContext'; +import Wallet from '../components/shop/Wallet'; -function App() { +function WalletPage() { return ( -
        - {/* 상단 헤더 */} -
        -

        유비두비's 쇼핑몰

        -
        - - {/* 컨텐츠 */} - -
        - {/* 상품 리스트 */} -
        - -
        - - {/* 장바구니 + 지갑 */} - -
        -
        +
        +
        + +
        ); } -export default App; +export default WalletPage; ``` -- GoodList.tsx +- /src/pages/NotFound.tsx 파일 생성 ```tsx import React from 'react'; -import { useShop } from '../../features/hooks/useShop'; - -const GoodList = () => { - const { goods, addCart } = useShop(); +import { Link } from 'react-router-dom'; +function NotFound() { return ( -
        - {/* 제목 */} -

        상품 리스트 📦

        - - {/* 상품 그리드 */} -
          - {goods.map(item => ( -
        • - {/* 상품 정보 */} -
          - {item.name} - - 가격: {item.price.toLocaleString()} 원 - -
          - - {/* 담기 버튼 */} - -
        • - ))} -
        -
        - ); -}; - -export default GoodList; -``` - -- Cart.tsx - -```tsx -import React from 'react'; -import { useShopSelectors } from '../../features/hooks/useShopSelectors'; -import { useShop } from '../../features/hooks/useShop'; - -const Cart = () => { - const { cart, addCart, removeCartOne, clearCart, resetCart, buyAll } = useShop(); - const { getGood, total } = useShopSelectors(); - - // 수량 직접 입력 함수 - const handleQtyChange = (id: number, value: string) => { - const qty = Number(value); - - // 빈 값이나 NaN이면 0 처리 - const newQty = isNaN(qty) ? 0 : qty; - - // 현재 장바구니 아이템 찾기 - const existItem = cart.find(item => item.id === id); - if (!existItem) return; - - const diff = newQty - existItem.qty; - - if (diff > 0) { - for (let i = 0; i < diff; i++) addCart(id); - } else if (diff < 0) { - for (let i = 0; i < Math.abs(diff); i++) removeCartOne(id); - } - - // 0 입력 시 삭제하지 않고 그대로 0 표시 - }; - - return ( -
        -

        내 카트 🛒

        - -
          - {cart.length === 0 ? ( -
        • - 장바구니가 비어있습니다. -
        • - ) : ( - cart.map(item => { - const good = getGood(item.id); - return ( -
        • -
          - {good?.name} - - 가격: {(good?.price! * item.qty).toLocaleString()} 원 - -
          - - {/* 수량 컨트롤 */} -
          - {/* - 버튼 */} - - - {/* 수량 입력 */} - handleQtyChange(item.id, e.target.value)} - className=" - w-12 text-center border rounded-lg bg-white text-gray-800 - appearance-none - [&::-webkit-inner-spin-button]:appearance-none - [&::-webkit-outer-spin-button]:appearance-none - -moz-appearance:textfield - " - /> - - {/* + 버튼 */} - - - {/* 삭제 버튼 */} - -
          -
        • - ); - }) - )} -
        - - {/* 총 금액 표시 */} - {cart.length > 0 && ( -
        - 총 합계: {total.toLocaleString()} 원 -
        - )} - - {/* 하단 버튼 */} -
        - - + 홈으로 돌아가기 +
        ); -}; +} -export default Cart; +export default NotFound; ``` -- Wallet.tsx +- App.tsx ```tsx import React from 'react'; -import { useShop } from '../../features/hooks/useShop'; - -const Wallet = () => { - const { balance } = useShop(); +import { NavLink, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; +import { ShopProvider } from './features'; +import CartPage from './pages/CartPage'; +import GoodsPage from './pages/GoodsPage'; +import HomePage from './pages/HomePage'; +import NotFound from './pages/NotFound'; +import WalletPage from './pages/WalletPage'; +function App() { return ( -
        - {/* 상단 */} -
        -

        내 지갑

        - 💳 Wallet + +
        + + {/* 상단 헤더 */} +
        +

        유비두비's 쇼핑몰

        +
        + + {/* 컨텐츠 */} + +
        + + } /> + } /> + } /> + } /> + } /> + +
        +
        - - {/* 잔액 */} -

        사용 가능한 잔액

        -

        {balance.toLocaleString()} 원

        -
        + ); -}; - -export default Wallet; -``` - -### 2.2 `Barrel (배럴) 파일` 활용하기 - -- 여러 모듈에서 내보낸 것들을 모아서 하나의 파일에서 다시 내보내는 패턴 -- 주로` index.js`나 `index.ts`로 파일명을 정한다 -- 즉, `대표 파일`이라고 함 - -- /src/features/index.ts 파일 생성 +} -```ts -export * from './shop/types'; -// 아래의 경우는 충돌 발생 소지 있음. -export { initialState } from './shop/state'; -export { calcTotal } from './shop/utils'; -// 아래의 경우 역시 충돌 발생 소지 있음. -export { reducer } from './shop/reducer'; -export { ShopContext, ShopProvider } from './shop/ShopContext'; -export { useShop } from './hooks/useShop'; -export { useShopSelectors } from './hooks/useShopSelectors'; +export default App; ``` - -- 해당 파일에 export 모아두기 diff --git a/package-lock.json b/package-lock.json index 2aa5952..fbc16fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1" }, "devDependencies": { "@eslint/js": "^9.33.0", @@ -1109,6 +1110,15 @@ "node": ">=14" } }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.32", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz", @@ -5459,6 +5469,38 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index 46d4bfe..c271126 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1" }, "devDependencies": { "@eslint/js": "^9.33.0", diff --git a/src/App.tsx b/src/App.tsx index 90f21ea..0f1ef09 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,33 +1,85 @@ import React from 'react'; -import GoodList from './components/shop/GoodList'; -import Cart from './components/shop/Cart'; -import Wallet from './components/shop/Wallet'; +import { NavLink, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; import { ShopProvider } from './features'; +import CartPage from './pages/CartPage'; +import GoodsPage from './pages/GoodsPage'; +import HomePage from './pages/HomePage'; +import NotFound from './pages/NotFound'; +import WalletPage from './pages/WalletPage'; function App() { return ( -
        - {/* 상단 헤더 */} -
        -

        유비두비's 쇼핑몰

        -
        + +
        + + {/* 상단 헤더 */} +
        +

        유비두비's 쇼핑몰

        +
        - {/* 컨텐츠 */} - -
        - {/* 상품 리스트 */} -
        - -
        - - {/* 장바구니 + 지갑 */} - -
        -
        -
        + {/* 컨텐츠 */} + +
        + + } /> + } /> + } /> + } /> + } /> + +
        +
        +
        + ); } diff --git a/src/pages/CartPage.tsx b/src/pages/CartPage.tsx new file mode 100644 index 0000000..f94a12f --- /dev/null +++ b/src/pages/CartPage.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import Cart from '../components/shop/Cart'; + +function CartPage() { + return ( +
        +
        + +
        +
        + ); +} + +export default CartPage; diff --git a/src/pages/GoodsPage.tsx b/src/pages/GoodsPage.tsx new file mode 100644 index 0000000..6cb9c7d --- /dev/null +++ b/src/pages/GoodsPage.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import GoodList from '../components/shop/GoodList'; + +function GoodsPage() { + return ( +
        +
        + +
        +
        + ); +} + +export default GoodsPage; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 0000000..2e47d6f --- /dev/null +++ b/src/pages/HomePage.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +function HomePage() { + return ( +
        + {/* Hero 영역 */} +
        +

        환영합니다!

        +

        이곳은 메인 홈 화면입니다. 상단 메뉴에서 쇼핑을 즐겨주세요!

        +
        + + {/* 소개 카드 */} +
        +
        +

        추천 상품

        +

        이번 주 가장 인기 있는 상품을 확인해보세요.

        +
        + +
        +

        이벤트

        +

        다양한 할인 이벤트와 쿠폰을 만나보세요.

        +
        + +
        +

        회원 혜택

        +

        회원 전용 특별 혜택을 놓치지 마세요!

        +
        +
        + + {/* 푸터 */} +
        +

        © 2025 DDODO 쇼핑몰. All rights reserved.

        +
        +
        + ); +} + +export default HomePage; diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx new file mode 100644 index 0000000..4d7828d --- /dev/null +++ b/src/pages/NotFound.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +function NotFound() { + return ( +
        + {/* 큰 에러 텍스트 */} +

        404

        +

        페이지를 찾을 수 없습니다

        + + {/* 안내 박스 */} +
        +

        + 요청하신 페이지가 존재하지 않거나 +
        + 주소가 잘못 입력된 것 같아요. +

        + + 홈으로 돌아가기 + +
        +
        + ); +} + +export default NotFound; diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx new file mode 100644 index 0000000..7582c4f --- /dev/null +++ b/src/pages/WalletPage.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import Wallet from '../components/shop/Wallet'; + +function WalletPage() { + return ( +
        +
        + +
        +
        + ); +} + +export default WalletPage; From 4a1d88e82b767c512f5fa89c9fffb1c4a169ade5 Mon Sep 17 00:00:00 2001 From: suha720 Date: Mon, 1 Sep 2025 11:09:36 +0900 Subject: [PATCH 09/13] =?UTF-8?q?[docs]=20full-calendar=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=ED=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 944 ++++++++++++++++++++++++++++++++++------- package-lock.json | 75 ++++ package.json | 6 + src/App.tsx | 3 +- src/index.css | 10 + src/pages/Calendar.tsx | 120 ++++++ 6 files changed, 997 insertions(+), 161 deletions(-) create mode 100644 src/pages/Calendar.tsx diff --git a/README.md b/README.md index 0c53ed0..9e1554f 100644 --- a/README.md +++ b/README.md @@ -1,237 +1,861 @@ -# react-router-dom +# full-calendar -## 1. 설치 +- https://fullcalendar.io/docs/getting-started -- v7 은 조금 문제가 발생하여, v6 사용함 +## 1. 설치 ```bash -npm i react-router-dom@6.30.1 +npm i @fullcalendar/react @fullcalendar/core \ + @fullcalendar/daygrid @fullcalendar/timegrid \ + @fullcalendar/interaction @fullcalendar/list ``` ## 2. 폴더 및 파일 구조 -- /src/pages 폴더 생성 -- /src/pages/HomePage.tsx 파일 생성 +- /src/pages/Calendar.tsx 파일 생성 +- 최초 월 달력 출력하기 ```tsx +import FullCalendar from '@fullcalendar/react'; +// full screen 관련 ( 직접 타이핑 ) +import DayGridPlugin from '@fullcalendar/daygrid'; import React from 'react'; -function HomePage() { +function Calendar() { return ( -
        - {/* Hero 영역 */} -
        -

        환영합니다!

        -

        이곳은 메인 홈 화면입니다. 상단 메뉴에서 쇼핑을 즐겨주세요!

        -
        - - {/* 소개 카드 */} -
        -
        -

        추천 상품

        -

        이번 주 가장 인기 있는 상품을 확인해보세요.

        -
        - -
        -

        이벤트

        -

        다양한 할인 이벤트와 쿠폰을 만나보세요.

        -
        - -
        -

        회원 혜택

        -

        회원 전용 특별 혜택을 놓치지 마세요!

        -
        -
        - - {/* 푸터 */} -
        -

        © 2025 DDODO 쇼핑몰. All rights reserved.

        -
        +
        +

        Full Calendar

        +
        + {/* daygridPlugin : 월 달력 플러그인, initialView : `월`로 보기 */} + +
        ); } -export default HomePage; +export default Calendar; ``` -- /src/pages/GoodsPage.tsx 파일 생성 +- 일정 출력 및 날짜 선택 시 상세 내용 보기 ```tsx -import React from 'react'; -import GoodList from '../components/shop/GoodList'; +import React, { useState } from 'react'; +// full screen 관련 +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import type { EventClickArg } from '@fullcalendar/core/index.js'; + +function Calendar() { + const [events, setEvents] = useState([ + { id: '1', title: '우리반 운동회', start: '2025-09-03', allDay: true }, + { id: '2', title: '과학 실험', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, + ]); + // 일정 상세 보기 + const handleClick = (info: EventClickArg) => { + // console.log(info.event.title); + alert(`제목 : ${info.event.title} 입니다.`); + }; + return ( +
        +

        Full Calendar

        +
        + {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} + handleClick(e)} + height={'auto'} + /> +
        +
        + ); +} + +export default Calendar; +``` + +- 일정 추가하기 + +```tsx +import React, { useState } from 'react'; +// full screen 관련 +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; +// full calendar 에 입력 시 들어오는 데이터 모양 +import type { EventInput } from '@fullcalendar/core/index.js'; + +function Calendar() { + const [events, setEvents] = useState([ + { id: '1', title: '사과 반짝 세일', start: '2025-09-03', allDay: true }, + { id: '2', title: '딸기 파격 세일', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, + ]); + // 일정 상세 보기 + const handleClick = (info: EventClickArg) => { + // console.log(info.event.title); + alert(`제목 : ${info.event.title} 입니다.`); + }; + // 빈 날짜 선택 처리 + const handleSelect = (e: DateSelectArg) => { + // 내용 입력 창을 만들어봄 + // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 + const title = prompt('일정의 제목을 입력하세요.') || ''; + const calendarData = e.view.calendar; + + if (!title?.trim()) { + alert('제목을 입력하세요.'); + return; + } + const newEvent = { + id: String(Date.now()), + title: title, + start: e.start, + allDay: e.allDay, + end: e.end, + }; + setEvents([...events, { newEvent }]); + }; -function GoodsPage() { return (
        +

        Full Calendar

        - + {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} + {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} + handleClick(e)} // 날짜 선택 내용 출력 + selectable={true} // 날짜를 선택할 수 있게 활성화 + selectMirror={true} + select={e => handleSelect(e)} + height={'auto'} + />
        ); } -export default GoodsPage; +export default Calendar; ``` -- /src/pages/CartPage.tsx 파일 생성 +- 드래그 해서 일정 수정하기 : `editable = { true / false }` + - editable={true} // 드래그로 일정 추가, 수정 ```tsx -import React from 'react'; -import Cart from '../components/shop/Cart'; +import React, { useState } from 'react'; +// full screen 관련 +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; +// full calendar 에 입력 시 들어오는 데이터 모양 +import type { EventInput } from '@fullcalendar/core/index.js'; + +function Calendar() { + const [events, setEvents] = useState([ + { id: '1', title: '사과 반짝 세일', start: '2025-09-03', allDay: true }, + { id: '2', title: '딸기 파격 세일', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, + ]); + // 일정 상세 보기 + const handleClick = (info: EventClickArg) => { + // console.log(info.event.title); + alert(`제목 : ${info.event.title} 입니다.`); + }; + // 빈 날짜 선택 처리 + const handleSelect = (e: DateSelectArg) => { + // 내용 입력 창을 만들어봄 + // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 + const title = prompt('일정의 제목을 입력하세요.') || ''; + const calendarData = e.view.calendar; + + if (!title?.trim()) { + alert('제목을 입력하세요.'); + return; + } + const newEvent = { + id: String(Date.now()), + title: title, + start: e.start, + allDay: e.allDay, + end: e.end, + }; + setEvents([...events, { newEvent }]); + }; -function CartPage() { return (
        +

        Full Calendar

        - + {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} + {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} + handleClick(e)} // 날짜 선택 내용 출력 + selectable={true} // 날짜를 선택할 수 있게 활성화 + selectMirror={true} + select={e => handleSelect(e)} + editable={true} // 드래그로 일정 추가, 수정 + height={'auto'} + />
        ); } -export default CartPage; +export default Calendar; ``` -- /src/pages/WalletPage.tsx 파일 생성 +- 주 / 일 버튼 처리하기 ( 도구 모음 ) ```tsx -import React from 'react'; -import Wallet from '../components/shop/Wallet'; +import React, { useState } from 'react'; +// full screen 관련 +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; +import timeGridPlugin from '@fullcalendar/timegrid'; +import listPlugin from '@fullcalendar/list'; +// full calendar 에 입력 시 들어오는 데이터 모양 +import type { EventInput } from '@fullcalendar/core/index.js'; + +function Calendar() { + const [events, setEvents] = useState([ + { id: '1', title: '사과 반짝 세일', start: '2025-09-03', allDay: true }, + { id: '2', title: '딸기 파격 세일', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, + ]); + // 일정 상세 보기 + const handleClick = (info: EventClickArg) => { + // console.log(info.event.title); + alert(`제목 : ${info.event.title} 입니다.`); + }; + // 빈 날짜 선택 처리 + const handleSelect = (e: DateSelectArg) => { + // 내용 입력 창을 만들어봄 + // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 + const title = prompt('일정의 제목을 입력하세요.') || ''; + const calendarData = e.view.calendar; + + if (!title?.trim()) { + alert('제목을 입력하세요.'); + return; + } + const newEvent = { + id: String(Date.now()), + title: title, + start: e.start, + allDay: e.allDay, + end: e.end, + }; + setEvents([...events, { newEvent }]); + }; + + // 헤더 도구 상자 + const headerToolbar = { + left: 'prev,next today', + center: 'title', + right: 'dayGridMonth, timeGridWeek, timeGridDay, listWeek', + }; -function WalletPage() { return (
        +

        Full Calendar

        - + {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} + {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} + {/* timeGridPlugin : 시간순 출력 관련 플러그인 */} + {/* listPlugin : 목록 출력 관련 플러그인 */} + handleClick(e)} // 날짜 선택 내용 출력 + selectable={true} // 날짜를 선택할 수 있게 활성화 + selectMirror={true} + select={e => handleSelect(e)} + editable={true} // 드래그로 일정 추가, 수정 + height={'auto'} + />
        ); } -export default WalletPage; +export default Calendar; ``` -- /src/pages/NotFound.tsx 파일 생성 +- 한국어 / 한국시간 처리하기 ```tsx -import React from 'react'; -import { Link } from 'react-router-dom'; +import React, { useState } from 'react'; +// full screen 관련 +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; +import timeGridPlugin from '@fullcalendar/timegrid'; +import listPlugin from '@fullcalendar/list'; +import koLocale from '@fullcalendar/core/locales/ko'; +// full calendar 에 입력 시 들어오는 데이터 모양 +import type { EventInput } from '@fullcalendar/core/index.js'; + +function Calendar() { + const [events, setEvents] = useState([ + { id: '1', title: '사과 반짝 세일', start: '2025-09-03', allDay: true }, + { id: '2', title: '딸기 파격 세일', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, + ]); + // 일정 상세 보기 + const handleClick = (info: EventClickArg) => { + // console.log(info.event.title); + alert(`제목 : ${info.event.title} 입니다.`); + }; + // 빈 날짜 선택 처리 + const handleSelect = (e: DateSelectArg) => { + console.log(e); + // 내용 입력 창을 만들어봄 + // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 + const title = prompt('일정의 제목을 입력하세요.') || ''; + const calendarData = e.view.calendar; + console.log(calendarData); + + if (!title?.trim()) { + alert('제목을 입력하세요.'); + return; + } + const newEvent = { + id: String(Date.now()), + title: title, + start: e.start, + allDay: e.allDay, + end: e.end, + }; + setEvents([...events, newEvent]); + }; + + // 헤더 도구 상자 + const headerToolbar = { + left: 'prev,next today', + center: 'title', + right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek', + }; -function NotFound() { return ( -
        - {/* 큰 에러 텍스트 */} -

        404

        -

        페이지를 찾을 수 없습니다

        - - {/* 안내 박스 */} -
        -

        - 요청하신 페이지가 존재하지 않거나 -
        - 주소가 잘못 입력된 것 같아요. -

        - - 홈으로 돌아가기 - +
        +

        Full Calendar

        +
        + {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} + {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} + {/* timeGridPlugin : 시간순 출력 관련 플러그인 */} + {/* listPlugin : 목록 출력 관련 플러그인 */} + handleClick(e)} // 날짜 선택 내용 출력 + selectable={true} // 날짜를 선택할 수 있게 활성화 + selectMirror={true} + select={e => handleSelect(e)} + editable={true} // 드래그로 일정 추가, 수정 + height={'auto'} + />
        ); } -export default NotFound; +export default Calendar; ``` -- App.tsx +- 하루에 최대 출력 가능 개수 : ( 더 많으면 more 출력 ) + - `dayMaxEvents={3} // 최대 미리보기 개수` 만 추가함 ```tsx -import React from 'react'; -import { NavLink, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; -import { ShopProvider } from './features'; -import CartPage from './pages/CartPage'; -import GoodsPage from './pages/GoodsPage'; -import HomePage from './pages/HomePage'; -import NotFound from './pages/NotFound'; -import WalletPage from './pages/WalletPage'; - -function App() { +import React, { useState } from 'react'; +// full screen 관련 +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; +import timeGridPlugin from '@fullcalendar/timegrid'; +import listPlugin from '@fullcalendar/list'; +import koLocale from '@fullcalendar/core/locales/ko'; +// full calendar 에 입력 시 들어오는 데이터 모양 +import type { EventInput } from '@fullcalendar/core/index.js'; + +function Calendar() { + const [events, setEvents] = useState([ + { id: '1', title: '사과 반짝 세일', start: '2025-09-03', allDay: true }, + { id: '2', title: '딸기 파격 세일', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, + ]); + // 일정 상세 보기 + const handleClick = (info: EventClickArg) => { + // console.log(info.event.title); + alert(`제목 : ${info.event.title} 입니다.`); + }; + // 빈 날짜 선택 처리 + const handleSelect = (e: DateSelectArg) => { + console.log(e); + // 내용 입력 창을 만들어봄 + // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 + const title = prompt('일정의 제목을 입력하세요.') || ''; + const calendarData = e.view.calendar; + console.log(calendarData); + + if (!title?.trim()) { + alert('제목을 입력하세요.'); + return; + } + const newEvent = { + id: String(Date.now()), + title: title, + start: e.start, + allDay: e.allDay, + end: e.end, + }; + setEvents([...events, newEvent]); + }; + + // 헤더 도구 상자 + const headerToolbar = { + left: 'prev,next today', + center: 'title', + right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek', + }; + + return ( +
        +

        Full Calendar

        +
        + {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} + {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} + {/* timeGridPlugin : 시간순 출력 관련 플러그인 */} + {/* listPlugin : 목록 출력 관련 플러그인 */} + handleClick(e)} // 날짜 선택 내용 출력 + selectable={true} // 날짜를 선택할 수 있게 활성화 + selectMirror={true} + select={e => handleSelect(e)} + editable={true} // 드래그로 일정 추가, 수정 + height={'auto'} + /> +
        +
        + ); +} + +export default Calendar; +``` + +- 일정 삭제하기 + +```tsx +import React, { useState } from 'react'; +// full screen 관련 +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; +import timeGridPlugin from '@fullcalendar/timegrid'; +import listPlugin from '@fullcalendar/list'; +import koLocale from '@fullcalendar/core/locales/ko'; +// full calendar 에 입력 시 들어오는 데이터 모양 +import type { EventInput } from '@fullcalendar/core/index.js'; + +function Calendar() { + const [events, setEvents] = useState([ + { id: '1', title: '사과 반짝 세일', start: '2025-09-03', allDay: true }, + { id: '2', title: '딸기 파격 세일', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, + ]); + // 일정 상세 보기 + const handleClick = (info: EventClickArg) => { + // id로 비교해서 삭제 + const arr = events.filter(item => item.id !== info.event.id); + setEvents(arr); + alert(`제목 : ${info.event.title} 이 삭제되었습니다.`); + }; + // 빈 날짜 선택 처리 + const handleSelect = (e: DateSelectArg) => { + console.log(e); + // 내용 입력 창을 만들어봄 + // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 + const title = prompt('일정의 제목을 입력하세요.') || ''; + const calendarData = e.view.calendar; + console.log(calendarData); + + if (!title?.trim()) { + alert('제목을 입력하세요.'); + return; + } + const newEvent = { + id: String(Date.now()), + title: title, + start: e.start, + allDay: e.allDay, + end: e.end, + }; + setEvents([...events, newEvent]); + }; + + // 헤더 도구 상자 + const headerToolbar = { + left: 'prev,next today', + center: 'title', + right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek', + }; + + return ( +
        +

        Full Calendar

        +
        + {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} + {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} + {/* timeGridPlugin : 시간순 출력 관련 플러그인 */} + {/* listPlugin : 목록 출력 관련 플러그인 */} + handleClick(e)} // 날짜 선택 내용 출력 + selectable={true} // 날짜를 선택할 수 있게 활성화 + selectMirror={true} + select={e => handleSelect(e)} + editable={true} // 드래그로 일정 추가, 수정 + height={'auto'} + /> +
        +
        + ); +} + +export default Calendar; +``` + +- 일정별로 색상을 다르게 표현하기 + +```tsx +import React, { useState } from 'react'; +// full screen 관련 +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; +import timeGridPlugin from '@fullcalendar/timegrid'; +import listPlugin from '@fullcalendar/list'; +import koLocale from '@fullcalendar/core/locales/ko'; +// full calendar 에 입력 시 들어오는 데이터 모양 +import type { EventInput } from '@fullcalendar/core/index.js'; + +function Calendar() { + const [events, setEvents] = useState([ + { id: '1', title: '사과 파격 세일', start: '2025-09-03', allDay: true }, + { id: '2', title: '딸기 반짝 세일', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, + { + id: '3', + title: '바나나 반짝 세일', + start: '2025-09-05T11:00:00', + end: '2025-09-05T13:00:00', + color: '#ff7f50', // 배경 및 글자 기본 색상 + textColor: '#f00', // 글자 색상 + borderColor: '#cc3300', // 테두리 색상 + }, + ]); + // 일정 상세 보기 + const handleClick = (info: EventClickArg) => { + // id로 비교해서 삭제 + const arr = events.filter(item => item.id !== info.event.id); + setEvents(arr); + alert(`제목 : ${info.event.title} 이 삭제되었습니다.`); + }; + // 빈 날짜 선택 처리 + const handleSelect = (e: DateSelectArg) => { + console.log(e); + // 내용 입력 창을 만들어봄 + // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 + const title = prompt('일정의 제목을 입력하세요.') || ''; + const calendarData = e.view.calendar; + console.log(calendarData); + + if (!title?.trim()) { + alert('제목을 입력하세요.'); + return; + } + const newEvent = { + id: String(Date.now()), + title: title, + start: e.start, + allDay: e.allDay, + end: e.end, + }; + setEvents([...events, newEvent]); + }; + + // 헤더 도구 상자 + const headerToolbar = { + left: 'prev,next today', + center: 'title', + right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek', + }; + return ( - -
        - - {/* 상단 헤더 */} -
        -

        유비두비's 쇼핑몰

        -
        - - {/* 컨텐츠 */} - -
        - - } /> - } /> - } /> - } /> - } /> - -
        -
        +
        +

        Full Calendar

        +
        + {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} + {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} + {/* timeGridPlugin : 시간순 출력 관련 플러그인 */} + {/* listPlugin : 목록 출력 관련 플러그인 */} + handleClick(e)} // 날짜 선택 내용 출력 + selectable={true} // 날짜를 선택할 수 있게 활성화 + selectMirror={true} + select={e => handleSelect(e)} + editable={true} // 드래그로 일정 추가, 수정 + height={'auto'} + />
        - +
        + ); +} + +export default Calendar; +``` + +- 일정 기본 색상을 지정하기 + +```tsx + handleClick(e)} // 날짜 선택 내용 출력 + selectable={true} // 날짜를 선택할 수 있게 활성화 + selectMirror={true} + select={e => handleSelect(e)} + editable={true} // 드래그로 일정 추가, 수정 + height={'auto'} + eventColor="#90ee90" // 기본 이벤트 배경색상 + eventTextColor="#000" // 기본 글자색상 + eventBorderColor="#008000" // 기본 테두리색상 +/> +``` + +- 클래스로 일정 색상 통일하기 : ( 카테고리별로 처리 ) + +- index.css + +```css +/* CSS */ +.sports-event { + background-color: #f08080 !important; + color: #fff !important; +} +.science-event { + background-color: #4682b4 !important; + color: #fff !important; +} +``` + +- Calendar.tsx + +```tsx + { + id: '1', + title: '사과 파격 세일', + start: '2025-09-03', + allDay: true, + classNames: ['sports-event'], + }, + { + id: '2', + title: '딸기 반짝 세일', + start: '2025-09-05T10:00:00', + end: '2025-09-05T11:00:00', + classNames: ['science-event'], + }, +``` + +- 아이콘 및 jsx 출력하기 + +```tsx +// jsx 출력하기 + eventContent={e => { + return ( + <> +
        + {e.event.title} +
        + +); +``` + +- 전체 Calendar.tsx 코드 + +```tsx +import React, { useState } from 'react'; +// full screen 관련 +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; +import timeGridPlugin from '@fullcalendar/timegrid'; +import listPlugin from '@fullcalendar/list'; +import koLocale from '@fullcalendar/core/locales/ko'; +// full calendar 에 입력 시 들어오는 데이터 모양 +import type { EventInput } from '@fullcalendar/core/index.js'; + +function Calendar() { + const [events, setEvents] = useState([ + { + id: '1', + title: '사과 파격 세일', + start: '2025-09-03', + allDay: true, + classNames: ['sports-event'], + }, + { + id: '2', + title: '딸기 반짝 세일', + start: '2025-09-05T10:00:00', + end: '2025-09-05T11:00:00', + classNames: ['science-event'], + }, + { + id: '3', + title: '바나나 반짝 세일', + start: '2025-09-05T11:00:00', + end: '2025-09-05T13:00:00', + color: '#ff7f50', // 배경 및 글자 기본 색상 + textColor: '#f00', // 글자 색상 + borderColor: '#cc3300', // 테두리 색상 + }, + ]); + // 일정 상세 보기 + const handleClick = (info: EventClickArg) => { + // id로 비교해서 삭제 + const arr = events.filter(item => item.id !== info.event.id); + setEvents(arr); + alert(`제목 : ${info.event.title} 이 삭제되었습니다.`); + }; + // 빈 날짜 선택 처리 + const handleSelect = (e: DateSelectArg) => { + console.log(e); + // 내용 입력 창을 만들어봄 + // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 + const title = prompt('일정의 제목을 입력하세요.') || ''; + const calendarData = e.view.calendar; + console.log(calendarData); + + if (!title?.trim()) { + alert('제목을 입력하세요.'); + return; + } + const newEvent = { + id: String(Date.now()), + title: title, + start: e.start, + allDay: e.allDay, + end: e.end, + }; + setEvents([...events, newEvent]); + }; + + // 헤더 도구 상자 + const headerToolbar = { + left: 'prev,next today', + center: 'title', + right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek', + }; + + return ( +
        +

        Full Calendar

        +
        + {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} + {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} + {/* timeGridPlugin : 시간순 출력 관련 플러그인 */} + {/* listPlugin : 목록 출력 관련 플러그인 */} + handleClick(e)} // 날짜 선택 내용 출력 + selectable={true} // 날짜를 선택할 수 있게 활성화 + selectMirror={true} + select={e => handleSelect(e)} + editable={true} // 드래그로 일정 추가, 수정 + height={'auto'} + eventColor="#90ee90" // 기본 이벤트 배경색상 + eventTextColor="#000" // 기본 글자색상 + eventBorderColor="#008000" // 기본 테두리색상 + // jsx 출력하기 + eventContent={e => { + return ( + <> +
        + {e.event.title} +
        + + ); + }} + /> +
        +
        ); } -export default App; +export default Calendar; ``` diff --git a/package-lock.json b/package-lock.json index fbc16fe..4e5bbdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,12 @@ "name": "til_vite_ts", "version": "0.0.0", "dependencies": { + "@fullcalendar/core": "^6.1.19", + "@fullcalendar/daygrid": "^6.1.19", + "@fullcalendar/interaction": "^6.1.19", + "@fullcalendar/list": "^6.1.19", + "@fullcalendar/react": "^6.1.19", + "@fullcalendar/timegrid": "^6.1.19", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.30.1" @@ -913,6 +919,65 @@ "url": "https://eslint.org/donate" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.19.tgz", + "integrity": "sha512-z0aVlO5e4Wah6p6mouM0UEqtRf1MZZPt4mwzEyU6kusaNL+dlWQgAasF2cK23hwT4cmxkEmr4inULXgpyeExdQ==", + "license": "MIT", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.19.tgz", + "integrity": "sha512-IAAfnMICnVWPjpT4zi87i3FEw0xxSza0avqY/HedKEz+l5MTBYvCDPOWDATpzXoLut3aACsjktIyw9thvIcRYQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.19" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.19.tgz", + "integrity": "sha512-GOciy79xe8JMVp+1evAU3ytdwN/7tv35t5i1vFkifiuWcQMLC/JnLg/RA2s4sYmQwoYhTw/p4GLcP0gO5B3X5w==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.19" + } + }, + "node_modules/@fullcalendar/list": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.19.tgz", + "integrity": "sha512-knZHpAVF0LbzZpSJSUmLUUzF0XlU/MRGK+Py2s0/mP93bCtno1k2L3XPs/kzh528hSjehwLm89RgKTSfW1P6cA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.19" + } + }, + "node_modules/@fullcalendar/react": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.19.tgz", + "integrity": "sha512-FP78vnyylaL/btZeHig8LQgfHgfwxLaIG6sKbNkzkPkKEACv11UyyBoTSkaavPsHtXvAkcTED1l7TOunAyPEnA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.19", + "react": "^16.7.0 || ^17 || ^18 || ^19", + "react-dom": "^16.7.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.19.tgz", + "integrity": "sha512-OuzpUueyO9wB5OZ8rs7TWIoqvu4v3yEqdDxZ2VcsMldCpYJRiOe7yHWKr4ap5Tb0fs7Rjbserc/b6Nt7ol6BRg==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.19" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.19" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -5279,6 +5344,16 @@ "dev": true, "license": "MIT" }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index c271126..97e73ab 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,12 @@ "preview": "vite preview" }, "dependencies": { + "@fullcalendar/core": "^6.1.19", + "@fullcalendar/daygrid": "^6.1.19", + "@fullcalendar/interaction": "^6.1.19", + "@fullcalendar/list": "^6.1.19", + "@fullcalendar/react": "^6.1.19", + "@fullcalendar/timegrid": "^6.1.19", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.30.1" diff --git a/src/App.tsx b/src/App.tsx index 0f1ef09..e0d2124 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import GoodsPage from './pages/GoodsPage'; import HomePage from './pages/HomePage'; import NotFound from './pages/NotFound'; import WalletPage from './pages/WalletPage'; +import Calendar from './pages/Calendar'; function App() { return ( @@ -65,7 +66,7 @@ function App() {

        유비두비's 쇼핑몰

        - + {/* 컨텐츠 */}
        diff --git a/src/index.css b/src/index.css index ff0452c..8452842 100644 --- a/src/index.css +++ b/src/index.css @@ -71,3 +71,13 @@ body { --primary: 62 100% 50%; /* 노랑 */ --primary-fg: 0 0% 0%; } + +/* CSS */ +.sports-event { + background-color: #f08080 !important; + color: #fff !important; +} +.science-event { + background-color: #4682b4 !important; + color: #fff !important; +} diff --git a/src/pages/Calendar.tsx b/src/pages/Calendar.tsx new file mode 100644 index 0000000..869fe16 --- /dev/null +++ b/src/pages/Calendar.tsx @@ -0,0 +1,120 @@ +import React, { useState } from 'react'; +// full screen 관련 +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; +import timeGridPlugin from '@fullcalendar/timegrid'; +import listPlugin from '@fullcalendar/list'; +import koLocale from '@fullcalendar/core/locales/ko'; +// full calendar 에 입력 시 들어오는 데이터 모양 +import type { EventInput } from '@fullcalendar/core/index.js'; + +function Calendar() { + const [events, setEvents] = useState([ + { + id: '1', + title: '사과 파격 세일', + start: '2025-09-03', + allDay: true, + classNames: ['sports-event'], + }, + { + id: '2', + title: '딸기 반짝 세일', + start: '2025-09-05T10:00:00', + end: '2025-09-05T11:00:00', + classNames: ['science-event'], + }, + { + id: '3', + title: '바나나 반짝 세일', + start: '2025-09-05T11:00:00', + end: '2025-09-05T13:00:00', + color: '#ff7f50', // 배경 및 글자 기본 색상 + textColor: '#f00', // 글자 색상 + borderColor: '#cc3300', // 테두리 색상 + }, + ]); + // 일정 상세 보기 + const handleClick = (info: EventClickArg) => { + // id로 비교해서 삭제 + const arr = events.filter(item => item.id !== info.event.id); + setEvents(arr); + alert(`제목 : ${info.event.title} 이 삭제되었습니다.`); + }; + // 빈 날짜 선택 처리 + const handleSelect = (e: DateSelectArg) => { + console.log(e); + // 내용 입력 창을 만들어봄 + // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 + const title = prompt('일정의 제목을 입력하세요.') || ''; + const calendarData = e.view.calendar; + console.log(calendarData); + + if (!title?.trim()) { + alert('제목을 입력하세요.'); + return; + } + const newEvent = { + id: String(Date.now()), + title: title, + start: e.start, + allDay: e.allDay, + end: e.end, + }; + setEvents([...events, newEvent]); + }; + + // 헤더 도구 상자 + const headerToolbar = { + left: 'prev,next today', + center: 'title', + right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek', + }; + + return ( +
        +

        Full Calendar

        +
        + {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} + {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} + {/* timeGridPlugin : 시간순 출력 관련 플러그인 */} + {/* listPlugin : 목록 출력 관련 플러그인 */} + handleClick(e)} // 날짜 선택 내용 출력 + selectable={true} // 날짜를 선택할 수 있게 활성화 + selectMirror={true} + select={e => handleSelect(e)} + editable={true} // 드래그로 일정 추가, 수정 + height={'auto'} + eventColor="#90ee90" // 기본 이벤트 배경색상 + eventTextColor="#000" // 기본 글자색상 + eventBorderColor="#008000" // 기본 테두리색상 + // jsx 출력하기 + eventContent={e => { + return ( + <> +
        + {e.event.title} +
        + + ); + }} + /> +
        +
        + ); +} + +export default Calendar; From 0cb336997798d8be2ad4a59da40680b2179c5454 Mon Sep 17 00:00:00 2001 From: suha720 Date: Mon, 1 Sep 2025 11:10:53 +0900 Subject: [PATCH 10/13] =?UTF-8?q?[docs]=20full-calendar=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=ED=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9e1554f..7bef592 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # full-calendar - https://fullcalendar.io/docs/getting-started +- 가능하면 6.0 버전 쓰기 ## 1. 설치 From 48cef32ecb45b93d17c5491563f9d672854541ff Mon Sep 17 00:00:00 2001 From: suha720 Date: Tue, 2 Sep 2025 09:07:56 +0900 Subject: [PATCH 11/13] [docs] supabase --- .gitignore | 2 + README.md | 907 ++++------------------------------- package-lock.json | 148 ++++++ package.json | 4 +- src/App.tsx | 90 +--- src/lib/supabase.ts | 10 + src/services/todoServices.ts | 72 +++ src/types/todoType.ts | 183 +++++++ types_db.ts | 181 +++++++ 9 files changed, 691 insertions(+), 906 deletions(-) create mode 100644 src/lib/supabase.ts create mode 100644 src/services/todoServices.ts create mode 100644 types_db.ts diff --git a/.gitignore b/.gitignore index a547bf3..3b0b403 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +.env \ No newline at end of file diff --git a/README.md b/README.md index 7bef592..a79ac76 100644 --- a/README.md +++ b/README.md @@ -1,862 +1,117 @@ -# full-calendar - -- https://fullcalendar.io/docs/getting-started -- 가능하면 6.0 버전 쓰기 - -## 1. 설치 - -```bash -npm i @fullcalendar/react @fullcalendar/core \ - @fullcalendar/daygrid @fullcalendar/timegrid \ - @fullcalendar/interaction @fullcalendar/list -``` - -## 2. 폴더 및 파일 구조 - -- /src/pages/Calendar.tsx 파일 생성 -- 최초 월 달력 출력하기 - -```tsx -import FullCalendar from '@fullcalendar/react'; -// full screen 관련 ( 직접 타이핑 ) -import DayGridPlugin from '@fullcalendar/daygrid'; -import React from 'react'; - -function Calendar() { - return ( -
        -

        Full Calendar

        -
        - {/* daygridPlugin : 월 달력 플러그인, initialView : `월`로 보기 */} - -
        -
        - ); -} - -export default Calendar; -``` - -- 일정 출력 및 날짜 선택 시 상세 내용 보기 - -```tsx -import React, { useState } from 'react'; -// full screen 관련 -import FullCalendar from '@fullcalendar/react'; -import dayGridPlugin from '@fullcalendar/daygrid'; -import type { EventClickArg } from '@fullcalendar/core/index.js'; - -function Calendar() { - const [events, setEvents] = useState([ - { id: '1', title: '우리반 운동회', start: '2025-09-03', allDay: true }, - { id: '2', title: '과학 실험', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, - ]); - // 일정 상세 보기 - const handleClick = (info: EventClickArg) => { - // console.log(info.event.title); - alert(`제목 : ${info.event.title} 입니다.`); - }; - return ( -
        -

        Full Calendar

        -
        - {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} - handleClick(e)} - height={'auto'} - /> -
        -
        - ); -} - -export default Calendar; +# Supabase 프로젝트 연동 + +- 테이블 생성은 생략함. + +```sql +CREATE TABLE todos ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + title VARCHAR NOT NULL, + completed BOOLEAN DEFAULT FALSE NOT NULL, + content TEXT, + updated_at TIMESTAMPTZ DEFAULT now(), + created_at TIMESTAMPTZ DEFAULT now() +); ``` -- 일정 추가하기 +## 1. `.env` 파일 생성 -```tsx -import React, { useState } from 'react'; -// full screen 관련 -import FullCalendar from '@fullcalendar/react'; -import dayGridPlugin from '@fullcalendar/daygrid'; -import interactionPlugin from '@fullcalendar/interaction'; -import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; -// full calendar 에 입력 시 들어오는 데이터 모양 -import type { EventInput } from '@fullcalendar/core/index.js'; +- 주의 사항 : .gitignore 꼭 확인 -> `.env` 없으면 꼭 추가해줘야함. -function Calendar() { - const [events, setEvents] = useState([ - { id: '1', title: '사과 반짝 세일', start: '2025-09-03', allDay: true }, - { id: '2', title: '딸기 파격 세일', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, - ]); - // 일정 상세 보기 - const handleClick = (info: EventClickArg) => { - // console.log(info.event.title); - alert(`제목 : ${info.event.title} 입니다.`); - }; - // 빈 날짜 선택 처리 - const handleSelect = (e: DateSelectArg) => { - // 내용 입력 창을 만들어봄 - // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 - const title = prompt('일정의 제목을 입력하세요.') || ''; - const calendarData = e.view.calendar; - - if (!title?.trim()) { - alert('제목을 입력하세요.'); - return; - } - const newEvent = { - id: String(Date.now()), - title: title, - start: e.start, - allDay: e.allDay, - end: e.end, - }; - setEvents([...events, { newEvent }]); - }; - - return ( -
        -

        Full Calendar

        -
        - {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} - {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} - handleClick(e)} // 날짜 선택 내용 출력 - selectable={true} // 날짜를 선택할 수 있게 활성화 - selectMirror={true} - select={e => handleSelect(e)} - height={'auto'} - /> -
        -
        - ); -} - -export default Calendar; ``` - -- 드래그 해서 일정 수정하기 : `editable = { true / false }` - - editable={true} // 드래그로 일정 추가, 수정 - -```tsx -import React, { useState } from 'react'; -// full screen 관련 -import FullCalendar from '@fullcalendar/react'; -import dayGridPlugin from '@fullcalendar/daygrid'; -import interactionPlugin from '@fullcalendar/interaction'; -import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; -// full calendar 에 입력 시 들어오는 데이터 모양 -import type { EventInput } from '@fullcalendar/core/index.js'; - -function Calendar() { - const [events, setEvents] = useState([ - { id: '1', title: '사과 반짝 세일', start: '2025-09-03', allDay: true }, - { id: '2', title: '딸기 파격 세일', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, - ]); - // 일정 상세 보기 - const handleClick = (info: EventClickArg) => { - // console.log(info.event.title); - alert(`제목 : ${info.event.title} 입니다.`); - }; - // 빈 날짜 선택 처리 - const handleSelect = (e: DateSelectArg) => { - // 내용 입력 창을 만들어봄 - // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 - const title = prompt('일정의 제목을 입력하세요.') || ''; - const calendarData = e.view.calendar; - - if (!title?.trim()) { - alert('제목을 입력하세요.'); - return; - } - const newEvent = { - id: String(Date.now()), - title: title, - start: e.start, - allDay: e.allDay, - end: e.end, - }; - setEvents([...events, { newEvent }]); - }; - - return ( -
        -

        Full Calendar

        -
        - {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} - {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} - handleClick(e)} // 날짜 선택 내용 출력 - selectable={true} // 날짜를 선택할 수 있게 활성화 - selectMirror={true} - select={e => handleSelect(e)} - editable={true} // 드래그로 일정 추가, 수정 - height={'auto'} - /> -
        -
        - ); -} - -export default Calendar; +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.env ``` -- 주 / 일 버튼 처리하기 ( 도구 모음 ) - -```tsx -import React, { useState } from 'react'; -// full screen 관련 -import FullCalendar from '@fullcalendar/react'; -import dayGridPlugin from '@fullcalendar/daygrid'; -import interactionPlugin from '@fullcalendar/interaction'; -import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; -import timeGridPlugin from '@fullcalendar/timegrid'; -import listPlugin from '@fullcalendar/list'; -// full calendar 에 입력 시 들어오는 데이터 모양 -import type { EventInput } from '@fullcalendar/core/index.js'; - -function Calendar() { - const [events, setEvents] = useState([ - { id: '1', title: '사과 반짝 세일', start: '2025-09-03', allDay: true }, - { id: '2', title: '딸기 파격 세일', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, - ]); - // 일정 상세 보기 - const handleClick = (info: EventClickArg) => { - // console.log(info.event.title); - alert(`제목 : ${info.event.title} 입니다.`); - }; - // 빈 날짜 선택 처리 - const handleSelect = (e: DateSelectArg) => { - // 내용 입력 창을 만들어봄 - // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 - const title = prompt('일정의 제목을 입력하세요.') || ''; - const calendarData = e.view.calendar; - - if (!title?.trim()) { - alert('제목을 입력하세요.'); - return; - } - const newEvent = { - id: String(Date.now()), - title: title, - start: e.start, - allDay: e.allDay, - end: e.end, - }; - setEvents([...events, { newEvent }]); - }; +- `VITE_` 를 접두어로 사용 - // 헤더 도구 상자 - const headerToolbar = { - left: 'prev,next today', - center: 'title', - right: 'dayGridMonth, timeGridWeek, timeGridDay, listWeek', - }; - - return ( -
        -

        Full Calendar

        -
        - {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} - {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} - {/* timeGridPlugin : 시간순 출력 관련 플러그인 */} - {/* listPlugin : 목록 출력 관련 플러그인 */} - handleClick(e)} // 날짜 선택 내용 출력 - selectable={true} // 날짜를 선택할 수 있게 활성화 - selectMirror={true} - select={e => handleSelect(e)} - editable={true} // 드래그로 일정 추가, 수정 - height={'auto'} - /> -
        -
        - ); -} - -export default Calendar; ``` - -- 한국어 / 한국시간 처리하기 - -```tsx -import React, { useState } from 'react'; -// full screen 관련 -import FullCalendar from '@fullcalendar/react'; -import dayGridPlugin from '@fullcalendar/daygrid'; -import interactionPlugin from '@fullcalendar/interaction'; -import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; -import timeGridPlugin from '@fullcalendar/timegrid'; -import listPlugin from '@fullcalendar/list'; -import koLocale from '@fullcalendar/core/locales/ko'; -// full calendar 에 입력 시 들어오는 데이터 모양 -import type { EventInput } from '@fullcalendar/core/index.js'; - -function Calendar() { - const [events, setEvents] = useState([ - { id: '1', title: '사과 반짝 세일', start: '2025-09-03', allDay: true }, - { id: '2', title: '딸기 파격 세일', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, - ]); - // 일정 상세 보기 - const handleClick = (info: EventClickArg) => { - // console.log(info.event.title); - alert(`제목 : ${info.event.title} 입니다.`); - }; - // 빈 날짜 선택 처리 - const handleSelect = (e: DateSelectArg) => { - console.log(e); - // 내용 입력 창을 만들어봄 - // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 - const title = prompt('일정의 제목을 입력하세요.') || ''; - const calendarData = e.view.calendar; - console.log(calendarData); - - if (!title?.trim()) { - alert('제목을 입력하세요.'); - return; - } - const newEvent = { - id: String(Date.now()), - title: title, - start: e.start, - allDay: e.allDay, - end: e.end, - }; - setEvents([...events, newEvent]); - }; - - // 헤더 도구 상자 - const headerToolbar = { - left: 'prev,next today', - center: 'title', - right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek', - }; - - return ( -
        -

        Full Calendar

        -
        - {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} - {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} - {/* timeGridPlugin : 시간순 출력 관련 플러그인 */} - {/* listPlugin : 목록 출력 관련 플러그인 */} - handleClick(e)} // 날짜 선택 내용 출력 - selectable={true} // 날짜를 선택할 수 있게 활성화 - selectMirror={true} - select={e => handleSelect(e)} - editable={true} // 드래그로 일정 추가, 수정 - height={'auto'} - /> -
        -
        - ); -} - -export default Calendar; +VITE_SUPABASE_DB_PW=초기 생성한 DB 비밀번호 +VITE_SUPABASE_URL=URL값 +VITE_SUPABASE_ANON_KEY=키값 ``` -- 하루에 최대 출력 가능 개수 : ( 더 많으면 more 출력 ) - - `dayMaxEvents={3} // 최대 미리보기 개수` 만 추가함 - -```tsx -import React, { useState } from 'react'; -// full screen 관련 -import FullCalendar from '@fullcalendar/react'; -import dayGridPlugin from '@fullcalendar/daygrid'; -import interactionPlugin from '@fullcalendar/interaction'; -import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; -import timeGridPlugin from '@fullcalendar/timegrid'; -import listPlugin from '@fullcalendar/list'; -import koLocale from '@fullcalendar/core/locales/ko'; -// full calendar 에 입력 시 들어오는 데이터 모양 -import type { EventInput } from '@fullcalendar/core/index.js'; - -function Calendar() { - const [events, setEvents] = useState([ - { id: '1', title: '사과 반짝 세일', start: '2025-09-03', allDay: true }, - { id: '2', title: '딸기 파격 세일', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, - ]); - // 일정 상세 보기 - const handleClick = (info: EventClickArg) => { - // console.log(info.event.title); - alert(`제목 : ${info.event.title} 입니다.`); - }; - // 빈 날짜 선택 처리 - const handleSelect = (e: DateSelectArg) => { - console.log(e); - // 내용 입력 창을 만들어봄 - // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 - const title = prompt('일정의 제목을 입력하세요.') || ''; - const calendarData = e.view.calendar; - console.log(calendarData); +## 2. Supabase 클라이언트 라이브러리 설치 - if (!title?.trim()) { - alert('제목을 입력하세요.'); - return; - } - const newEvent = { - id: String(Date.now()), - title: title, - start: e.start, - allDay: e.allDay, - end: e.end, - }; - setEvents([...events, newEvent]); - }; - - // 헤더 도구 상자 - const headerToolbar = { - left: 'prev,next today', - center: 'title', - right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek', - }; - - return ( -
        -

        Full Calendar

        -
        - {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} - {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} - {/* timeGridPlugin : 시간순 출력 관련 플러그인 */} - {/* listPlugin : 목록 출력 관련 플러그인 */} - handleClick(e)} // 날짜 선택 내용 출력 - selectable={true} // 날짜를 선택할 수 있게 활성화 - selectMirror={true} - select={e => handleSelect(e)} - editable={true} // 드래그로 일정 추가, 수정 - height={'auto'} - /> -
        -
        - ); -} - -export default Calendar; +```bash +npm install @supabase/supabase-js ``` -- 일정 삭제하기 +## 3. 폴더 및 파일 구조 -```tsx -import React, { useState } from 'react'; -// full screen 관련 -import FullCalendar from '@fullcalendar/react'; -import dayGridPlugin from '@fullcalendar/daygrid'; -import interactionPlugin from '@fullcalendar/interaction'; -import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; -import timeGridPlugin from '@fullcalendar/timegrid'; -import listPlugin from '@fullcalendar/list'; -import koLocale from '@fullcalendar/core/locales/ko'; -// full calendar 에 입력 시 들어오는 데이터 모양 -import type { EventInput } from '@fullcalendar/core/index.js'; +- /src/lib 폴더 생성 +- /src/lib/supabase.ts 파일 생성 +- supabase 는 거의 next.js 와 많이 씀. ( ts에서 쓰이는 경우는 거의 없음 ) -function Calendar() { - const [events, setEvents] = useState([ - { id: '1', title: '사과 반짝 세일', start: '2025-09-03', allDay: true }, - { id: '2', title: '딸기 파격 세일', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, - ]); - // 일정 상세 보기 - const handleClick = (info: EventClickArg) => { - // id로 비교해서 삭제 - const arr = events.filter(item => item.id !== info.event.id); - setEvents(arr); - alert(`제목 : ${info.event.title} 이 삭제되었습니다.`); - }; - // 빈 날짜 선택 처리 - const handleSelect = (e: DateSelectArg) => { - console.log(e); - // 내용 입력 창을 만들어봄 - // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 - const title = prompt('일정의 제목을 입력하세요.') || ''; - const calendarData = e.view.calendar; - console.log(calendarData); +```ts +import { createClient } from '@supabase/supabase-js'; - if (!title?.trim()) { - alert('제목을 입력하세요.'); - return; - } - const newEvent = { - id: String(Date.now()), - title: title, - start: e.start, - allDay: e.allDay, - end: e.end, - }; - setEvents([...events, newEvent]); - }; +// CRA 의 환경 변수 호출과는 형식이 다름. (meta) +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; - // 헤더 도구 상자 - const headerToolbar = { - left: 'prev,next today', - center: 'title', - right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek', - }; - - return ( -
        -

        Full Calendar

        -
        - {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} - {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} - {/* timeGridPlugin : 시간순 출력 관련 플러그인 */} - {/* listPlugin : 목록 출력 관련 플러그인 */} - handleClick(e)} // 날짜 선택 내용 출력 - selectable={true} // 날짜를 선택할 수 있게 활성화 - selectMirror={true} - select={e => handleSelect(e)} - editable={true} // 드래그로 일정 추가, 수정 - height={'auto'} - /> -
        -
        - ); +if (!supabaseUrl || !supabaseAnonKey) { + throw new Error('Missing Supabase environment variables'); } -export default Calendar; +export const supabase = createClient(supabaseUrl, supabaseAnonKey); ``` -- 일정별로 색상을 다르게 표현하기 - -```tsx -import React, { useState } from 'react'; -// full screen 관련 -import FullCalendar from '@fullcalendar/react'; -import dayGridPlugin from '@fullcalendar/daygrid'; -import interactionPlugin from '@fullcalendar/interaction'; -import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; -import timeGridPlugin from '@fullcalendar/timegrid'; -import listPlugin from '@fullcalendar/list'; -import koLocale from '@fullcalendar/core/locales/ko'; -// full calendar 에 입력 시 들어오는 데이터 모양 -import type { EventInput } from '@fullcalendar/core/index.js'; - -function Calendar() { - const [events, setEvents] = useState([ - { id: '1', title: '사과 파격 세일', start: '2025-09-03', allDay: true }, - { id: '2', title: '딸기 반짝 세일', start: '2025-09-05T10:00:00', end: '2025-09-05T11:00:00' }, - { - id: '3', - title: '바나나 반짝 세일', - start: '2025-09-05T11:00:00', - end: '2025-09-05T13:00:00', - color: '#ff7f50', // 배경 및 글자 기본 색상 - textColor: '#f00', // 글자 색상 - borderColor: '#cc3300', // 테두리 색상 - }, - ]); - // 일정 상세 보기 - const handleClick = (info: EventClickArg) => { - // id로 비교해서 삭제 - const arr = events.filter(item => item.id !== info.event.id); - setEvents(arr); - alert(`제목 : ${info.event.title} 이 삭제되었습니다.`); - }; - // 빈 날짜 선택 처리 - const handleSelect = (e: DateSelectArg) => { - console.log(e); - // 내용 입력 창을 만들어봄 - // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 - const title = prompt('일정의 제목을 입력하세요.') || ''; - const calendarData = e.view.calendar; - console.log(calendarData); - - if (!title?.trim()) { - alert('제목을 입력하세요.'); - return; - } - const newEvent = { - id: String(Date.now()), - title: title, - start: e.start, - allDay: e.allDay, - end: e.end, - }; - setEvents([...events, newEvent]); - }; +## 4. ※ 중요 ※ Supabase 의 테이블의 컬럼의 데이터 타입 정의 ( js는 필요없지만 ts에선 필요함 ) - // 헤더 도구 상자 - const headerToolbar = { - left: 'prev,next today', - center: 'title', - right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek', - }; +### 4.1 데이터 타입 쉽게 추출하기 - return ( -
        -

        Full Calendar

        -
        - {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} - {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} - {/* timeGridPlugin : 시간순 출력 관련 플러그인 */} - {/* listPlugin : 목록 출력 관련 플러그인 */} - handleClick(e)} // 날짜 선택 내용 출력 - selectable={true} // 날짜를 선택할 수 있게 활성화 - selectMirror={true} - select={e => handleSelect(e)} - editable={true} // 드래그로 일정 추가, 수정 - height={'auto'} - /> -
        -
        - ); -} - -export default Calendar; -``` - -- 일정 기본 색상을 지정하기 - -```tsx - handleClick(e)} // 날짜 선택 내용 출력 - selectable={true} // 날짜를 선택할 수 있게 활성화 - selectMirror={true} - select={e => handleSelect(e)} - editable={true} // 드래그로 일정 추가, 수정 - height={'auto'} - eventColor="#90ee90" // 기본 이벤트 배경색상 - eventTextColor="#000" // 기본 글자색상 - eventBorderColor="#008000" // 기본 테두리색상 -/> +```bash +npx supabase login ``` -- 클래스로 일정 색상 통일하기 : ( 카테고리별로 처리 ) +- 향후 지시대로 실행함 +- id 는 URL 의 앞쪽 단어가 ID가 됨 -- index.css +- 타입을 쉽게 만들어줌. package.json 해당 문구 추가 + - `"generate-types": "npx supabase gen types typescript --project-id erontyifxxztudowhees --schema public > types_db.ts"` -```css -/* CSS */ -.sports-event { - background-color: #f08080 !important; - color: #fff !important; -} -.science-event { - background-color: #4682b4 !important; - color: #fff !important; -} ``` - -- Calendar.tsx - -```tsx - { - id: '1', - title: '사과 파격 세일', - start: '2025-09-03', - allDay: true, - classNames: ['sports-event'], - }, - { - id: '2', - title: '딸기 반짝 세일', - start: '2025-09-05T10:00:00', - end: '2025-09-05T11:00:00', - classNames: ['science-event'], - }, +"scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "generate-types": "npx supabase gen types typescript --project-id erontyifxxztudowhees --schema public > types_db.ts" + }, ``` -- 아이콘 및 jsx 출력하기 - -```tsx -// jsx 출력하기 - eventContent={e => { - return ( - <> -
        - {e.event.title} -
        - -); +```bash +npm run generate-types ``` -- 전체 Calendar.tsx 코드 - -```tsx -import React, { useState } from 'react'; -// full screen 관련 -import FullCalendar from '@fullcalendar/react'; -import dayGridPlugin from '@fullcalendar/daygrid'; -import interactionPlugin from '@fullcalendar/interaction'; -import type { DateSelectArg, EventClickArg } from '@fullcalendar/core/index.js'; -import timeGridPlugin from '@fullcalendar/timegrid'; -import listPlugin from '@fullcalendar/list'; -import koLocale from '@fullcalendar/core/locales/ko'; -// full calendar 에 입력 시 들어오는 데이터 모양 -import type { EventInput } from '@fullcalendar/core/index.js'; +## 5. CRUD 실행해 보기 -function Calendar() { - const [events, setEvents] = useState([ - { - id: '1', - title: '사과 파격 세일', - start: '2025-09-03', - allDay: true, - classNames: ['sports-event'], - }, - { - id: '2', - title: '딸기 반짝 세일', - start: '2025-09-05T10:00:00', - end: '2025-09-05T11:00:00', - classNames: ['science-event'], - }, - { - id: '3', - title: '바나나 반짝 세일', - start: '2025-09-05T11:00:00', - end: '2025-09-05T13:00:00', - color: '#ff7f50', // 배경 및 글자 기본 색상 - textColor: '#f00', // 글자 색상 - borderColor: '#cc3300', // 테두리 색상 - }, - ]); - // 일정 상세 보기 - const handleClick = (info: EventClickArg) => { - // id로 비교해서 삭제 - const arr = events.filter(item => item.id !== info.event.id); - setEvents(arr); - alert(`제목 : ${info.event.title} 이 삭제되었습니다.`); - }; - // 빈 날짜 선택 처리 - const handleSelect = (e: DateSelectArg) => { - console.log(e); - // 내용 입력 창을 만들어봄 - // 기본 프롬프트창으로 (웹브라우저 프롬프트로) 일단 처리 - const title = prompt('일정의 제목을 입력하세요.') || ''; - const calendarData = e.view.calendar; - console.log(calendarData); +### 5.1 CRUD 를 위한 폴더 및 파일 구조 - if (!title?.trim()) { - alert('제목을 입력하세요.'); - return; - } - const newEvent = { - id: String(Date.now()), - title: title, - start: e.start, - allDay: e.allDay, - end: e.end, - }; - setEvents([...events, newEvent]); - }; +- `/src/apis 폴더` 생성 또는 `/src/services 폴더` 생성 ( 수업은 services 로 만듦 ) +- /src/services/todoServices.ts 파일 생성 - // 헤더 도구 상자 - const headerToolbar = { - left: 'prev,next today', - center: 'title', - right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek', - }; - - return ( -
        -

        Full Calendar

        -
        - {/* dayGridPlugin : 월 달력 플러그 인, initialView : `월`로 보기 */} - {/* interactionPlugin : 클릭 및 드래그 관련 플러그인 */} - {/* timeGridPlugin : 시간순 출력 관련 플러그인 */} - {/* listPlugin : 목록 출력 관련 플러그인 */} - handleClick(e)} // 날짜 선택 내용 출력 - selectable={true} // 날짜를 선택할 수 있게 활성화 - selectMirror={true} - select={e => handleSelect(e)} - editable={true} // 드래그로 일정 추가, 수정 - height={'auto'} - eventColor="#90ee90" // 기본 이벤트 배경색상 - eventTextColor="#000" // 기본 글자색상 - eventBorderColor="#008000" // 기본 테두리색상 - // jsx 출력하기 - eventContent={e => { - return ( - <> -
        - {e.event.title} -
        - - ); - }} - /> -
        -
        - ); -} - -export default Calendar; -``` diff --git a/package-lock.json b/package-lock.json index 4e5bbdc..4f23a3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@fullcalendar/list": "^6.1.19", "@fullcalendar/react": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19", + "@supabase/supabase-js": "^2.56.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.30.1" @@ -1478,6 +1479,80 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.71.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz", + "integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz", + "integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.21.3", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.3.tgz", + "integrity": "sha512-rg3DmmZQKEVCreXq6Am29hMVe1CzemXyIWVYyyua69y6XubfP+DzGfLxME/1uvdgwqdoaPbtjBDpEBhqxq1ZwA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.4.tgz", + "integrity": "sha512-e/FYIWjvQJHOCNACWehnKvg26zosju3694k0NMUNb+JGLdvHJzEa29ZVVLmawd2kvx4hdbv8mxSqfttRnH3+DA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.13", + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "ws": "^8.18.2" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.11.0.tgz", + "integrity": "sha512-Y+kx/wDgd4oasAgoAq0bsbQojwQ+ejIif8uczZ9qufRHWFLMU5cODT+ApHsSrDufqUcVKt+eyxtOXSkeh2v9ww==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.56.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.56.1.tgz", + "integrity": "sha512-cb/kS0d6G/qbcmUFItkqVrQbxQHWXzfRZuoiSDv/QiU6RbGNTn73XjjvmbBCZ4MMHs+5teihjhpEVluqbXISEg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.71.1", + "@supabase/functions-js": "2.4.5", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.21.3", + "@supabase/realtime-js": "2.15.4", + "@supabase/storage-js": "^2.10.4" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1537,6 +1612,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1565,6 +1655,15 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -6470,6 +6569,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -6653,6 +6758,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -6807,6 +6918,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7030,6 +7157,27 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 97e73ab..8a3264f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "generate-types": "npx supabase gen types typescript --project-id rqckhcqnpwvkjofyetzm --schema public > types_db.ts" }, "dependencies": { "@fullcalendar/core": "^6.1.19", @@ -16,6 +17,7 @@ "@fullcalendar/list": "^6.1.19", "@fullcalendar/react": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19", + "@supabase/supabase-js": "^2.56.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.30.1" diff --git a/src/App.tsx b/src/App.tsx index e0d2124..6e25d8e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,86 +1,18 @@ import React from 'react'; -import { NavLink, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; -import { ShopProvider } from './features'; -import CartPage from './pages/CartPage'; -import GoodsPage from './pages/GoodsPage'; -import HomePage from './pages/HomePage'; -import NotFound from './pages/NotFound'; -import WalletPage from './pages/WalletPage'; -import Calendar from './pages/Calendar'; +import { createTodo } from './services/todoServices'; function App() { + const addTodo = async (): Promise => { + const result = await createTodo({ title: '할 일 입니다.', content: '내용입니다.' }); + if (result) { + console.log(result); + } + }; + return ( - -
        - - {/* 상단 헤더 */} -
        -

        유비두비's 쇼핑몰

        -
        - - {/* 컨텐츠 */} - -
        - - } /> - } /> - } /> - } /> - } /> - -
        -
        -
        -
        +
        + +
        ); } diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 0000000..cb15a97 --- /dev/null +++ b/src/lib/supabase.ts @@ -0,0 +1,10 @@ +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; + +if (!supabaseUrl || !supabaseAnonKey) { + throw new Error('Missing Supabase environment variables'); +} + +export const supabase = createClient(supabaseUrl, supabaseAnonKey); diff --git a/src/services/todoServices.ts b/src/services/todoServices.ts new file mode 100644 index 0000000..581151c --- /dev/null +++ b/src/services/todoServices.ts @@ -0,0 +1,72 @@ +import { supabase } from '../lib/supabase'; +import type { Todo, TodoInsert, TodoUpdate } from '../types/todoType'; + +// 이것이 CRUD!! + +// Todo 목록 조회 +export const getTodos = async (): Promise => { + const { data, error } = await supabase + .from('todos') + .select('*') + .order('created_at', { ascending: false }); + // 실행은 되었지만, 결과가 오류이다. + if (error) { + throw new Error(`getTodos 오류 : ${error.message}`); + } + return data || []; +}; +// Todo 생성 +export const createTodo = async (newTodo: TodoInsert): Promise => { + try { + const { data, error } = await supabase + .from('todos') + .insert([{ ...newTodo, completed: false }]) + .select() + .single(); + if (error) { + throw new Error(`createTodo 오류 : ${error.message}`); + } + return data; + } catch (error) { + console.log(error); + return null; + } +}; +// Todo 수정 +export const updateTodo = async (id: number, editTitle: TodoUpdate): Promise => { + try { + // 업데이트 구문 : const { data, error } = await supabase ~ .select(); + const { data, error } = await supabase + .from('todos') + .update({ ...editTitle, updated_at: new Date().toISOString() }) + .eq('id', id) + .select() + .single(); + + if (error) { + throw new Error(`updateTodo 오류 : ${error.message}`); + } + + return data; + } catch (error) { + console.log(error); + return null; + } +}; +// Todo 삭제 +export const deleteTodo = async (id: number): Promise => { + try { + const { error } = await supabase.from('todos').delete().eq('id', id); + if (error) { + throw new Error(`deleteTodo 오류 : ${error.message}`); + } + } catch (error) { + console.log(error); + } +}; + +// Complited 토글 = 어차피 toggle도 업데이트기 때문에 굳이 만들지 않아도 되지만 수업상 만듦 + +export const toggleTodo = async (id: number, completed: boolean): Promise => { + return updateTodo(id, { completed }); +}; diff --git a/src/types/todoType.ts b/src/types/todoType.ts index 6ad7a4a..e955671 100644 --- a/src/types/todoType.ts +++ b/src/types/todoType.ts @@ -4,3 +4,186 @@ export type NewTodoType = { title: string; completed: boolean; }; + +// 해당 작업은 수작업 : 테이블명을 바꾸지 않는 이상 하단 타입은 변경되지 않음. (제너레이트란 명령을 주면 됨) +// 해당 작업 이후 todoService.ts 가서 Promise import해주기 +// // Todo 목록 조회 +// export const getTodos = async (): Promise => { +// try { +export type Todo = Database['public']['Tables']['todos']['Row']; +export type TodoInsert = Database['public']['Tables']['todos']['Insert']; +export type TodoUpdate = Database['public']['Tables']['todos']['Update']; + +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: '13.0.4'; + }; + public: { + Tables: { + todos: { + Row: { + completed: boolean; + content: string | null; + created_at: string | null; + id: number; + title: string; + updated_at: string | null; + }; + Insert: { + completed?: boolean; + content?: string | null; + created_at?: string | null; + id?: number; + title: string; + updated_at?: string | null; + }; + Update: { + completed?: boolean; + content?: string | null; + created_at?: string | null; + id?: number; + title?: string; + updated_at?: string | null; + }; + Relationships: []; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + [_ in never]: never; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +}; + +type DatabaseWithoutInternals = Omit; + +type DefaultSchema = DatabaseWithoutInternals[Extract]; + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views']) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends { + Row: infer R; + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + ? (DefaultSchema['Tables'] & DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R; + } + ? R + : never + : never; + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Insert: infer I; + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I; + } + ? I + : never + : never; + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Update: infer U; + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Update: infer U; + } + ? U + : never + : never; + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema['Enums'] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums'] + ? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions] + : never; + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema['CompositeTypes'] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes'] + ? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions] + : never; + +export const Constants = { + public: { + Enums: {}, + }, +} as const; diff --git a/types_db.ts b/types_db.ts new file mode 100644 index 0000000..107bff5 --- /dev/null +++ b/types_db.ts @@ -0,0 +1,181 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "13.0.4" + } + public: { + Tables: { + todos: { + Row: { + completed: boolean + content: string | null + created_at: string | null + id: number + title: string + updated_at: string | null + } + Insert: { + completed?: boolean + content?: string | null + created_at?: string | null + id?: number + title: string + updated_at?: string | null + } + Update: { + completed?: boolean + content?: string | null + created_at?: string | null + id?: number + title?: string + updated_at?: string | null + } + Relationships: [] + } + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + Enums: {}, + }, +} as const From 679839715112169c7e0c170760e3bb20558d7539 Mon Sep 17 00:00:00 2001 From: suha720 Date: Tue, 2 Sep 2025 12:15:41 +0900 Subject: [PATCH 12/13] =?UTF-8?q?[supabase]=20todos=20DB=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=ED=95=98=EA=B8=B0=20=EC=8B=A4=EC=8A=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 128 ++++++++++++++++++++++++++--- src/App.tsx | 17 ++-- src/components/todos/TodoItem.tsx | 64 +++++++++++++-- src/components/todos/TodoList.tsx | 3 +- src/components/todos/TodoWrite.tsx | 46 +++++++---- src/contexts/TodoContext.tsx | 89 ++++++++++++++------ src/lib/supabase.ts | 1 + tsconfig.app.json | 2 +- 8 files changed, 279 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index a79ac76..49fd1cd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ # Supabase 프로젝트 연동 -- 테이블 생성은 생략함. +- https://supabase.com/ +- `New organization` 으로 프로젝트 생성 +- `데이터베이스 비밀번호` 필수 보관 (.env) +- 테이블 생성 ( Table Editor 또는 SQL Editor ) +- 기본형은 Table Editor 로 생성하고 추가적 설정 SQL Editor 활용 +- SQL 문 익숙해질 시 SQL Editor 관리 권장 + +## 1. todos 테이블 생성 쿼리 ```sql CREATE TABLE todos ( @@ -13,7 +20,7 @@ CREATE TABLE todos ( ); ``` -## 1. `.env` 파일 생성 +## 2. `.env` 파일 생성 - 주의 사항 : .gitignore 꼭 확인 -> `.env` 없으면 꼭 추가해줘야함. @@ -54,13 +61,17 @@ VITE_SUPABASE_URL=URL값 VITE_SUPABASE_ANON_KEY=키값 ``` -## 2. Supabase 클라이언트 라이브러리 설치 +- Supabase URL 과 Anon Key 파악하기 + - Project 선택 후 `Project Overview` 에서 확인 + - `Project API` 항목에서 파악 가능 + +## 3. Supabase 클라이언트 라이브러리 설치 ```bash npm install @supabase/supabase-js ``` -## 3. 폴더 및 파일 구조 +## 4. 폴더 및 파일 구조 - /src/lib 폴더 생성 - /src/lib/supabase.ts 파일 생성 @@ -69,20 +80,21 @@ npm install @supabase/supabase-js ```ts import { createClient } from '@supabase/supabase-js'; -// CRA 의 환경 변수 호출과는 형식이 다름. (meta) +// CRA : process.env... 의 환경 변수 호출과는 형식이 다름. (meta) +// Vite : import.meta.env... 환경 변수 호출 const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; if (!supabaseUrl || !supabaseAnonKey) { throw new Error('Missing Supabase environment variables'); } - +// 웹브라우저 클라이언트 생성 export const supabase = createClient(supabaseUrl, supabaseAnonKey); ``` -## 4. ※ 중요 ※ Supabase 의 테이블의 컬럼의 데이터 타입 정의 ( js는 필요없지만 ts에선 필요함 ) +## 5. ※ 중요 ※ Supabase 의 테이블의 컬럼의 데이터 타입 정의 ( js는 필요없지만 ts에선 필요함 ) -### 4.1 데이터 타입 쉽게 추출하기 +### 5.1 데이터 타입 쉽게 추출하기 ```bash npx supabase login @@ -91,8 +103,11 @@ npx supabase login - 향후 지시대로 실행함 - id 는 URL 의 앞쪽 단어가 ID가 됨 + - id 는 supabase URL 의 앞 단어가 됨 ( rqckhcqnpwvkjofyetzm ) + - VITE_SUPABASE_URL=https://`rqckhcqnpwvkjofyetzm`.supabase.co + - 타입을 쉽게 만들어줌. package.json 해당 문구 추가 - - `"generate-types": "npx supabase gen types typescript --project-id erontyifxxztudowhees --schema public > types_db.ts"` + - `"generate-types": "npx supabase gen types typescript --project-id 프로젝트 ID --schema 경로 > 파일명.ts"` ``` "scripts": { @@ -100,7 +115,7 @@ npx supabase login "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "generate-types": "npx supabase gen types typescript --project-id erontyifxxztudowhees --schema public > types_db.ts" + "generate-types": "npx supabase gen types typescript --project-id rqckhcqnpwvkjofyetzm --schema public > types_db.ts" }, ``` @@ -108,10 +123,99 @@ npx supabase login npm run generate-types ``` -## 5. CRUD 실행해 보기 +- 생성된 ts 의 내용을 참조하여 우리가 원하는 곳에 복사 및 붙여넣기 권장 + - 권장사항 : /src/types/database.ts 생성 및 붙여넣기 + - 편하게 활용하기 위한 처리 + +```ts +// 해당 작업은 수작업 : 테이블명을 바꾸지 않는 이상 하단 타입은 변경되지 않음. (제너레이트란 명령을 주면 됨) +// 해당 작업 이후 todoService.ts 가서 Promise import해주기 +// // Todo 목록 조회 +// export const getTodos = async (): Promise => { +// try { +export type Todo = Database['public']['Tables']['todos']['Row']; +export type TodoInsert = Database['public']['Tables']['todos']['Insert']; +export type TodoUpdate = Database['public']['Tables']['todos']['Update']; +``` + +## 6. CRUD 실행해 보기 -### 5.1 CRUD 를 위한 폴더 및 파일 구조 +### 6.1 CRUD 를 위한 폴더 및 파일 구조 - `/src/apis 폴더` 생성 또는 `/src/services 폴더` 생성 ( 수업은 services 로 만듦 ) - /src/services/todoServices.ts 파일 생성 +```ts +import { supabase } from '../lib/supabase'; +import type { Todo, TodoInsert, TodoUpdate } from '../types/todoType'; + +// 이것이 CRUD!! + +// Todo 목록 조회 +export const getTodos = async (): Promise => { + const { data, error } = await supabase + .from('todos') + .select('*') + .order('created_at', { ascending: false }); + // 실행은 되었지만, 결과가 오류이다. + if (error) { + throw new Error(`getTodos 오류 : ${error.message}`); + } + return data || []; +}; +// Todo 생성 +export const createTodo = async (newTodo: TodoInsert): Promise => { + try { + const { data, error } = await supabase + .from('todos') + .insert([{ ...newTodo, completed: false }]) + .select() + .single(); + if (error) { + throw new Error(`createTodo 오류 : ${error.message}`); + } + return data; + } catch (error) { + console.log(error); + return null; + } +}; +// Todo 수정 +export const updateTodo = async (id: number, editTitle: TodoUpdate): Promise => { + try { + // 업데이트 구문 : const { data, error } = await supabase ~ .select(); + const { data, error } = await supabase + .from('todos') + .update({ ...editTitle, updated_at: new Date().toISOString() }) + .eq('id', id) + .select() + .single(); + + if (error) { + throw new Error(`updateTodo 오류 : ${error.message}`); + } + + return data; + } catch (error) { + console.log(error); + return null; + } +}; +// Todo 삭제 +export const deleteTodo = async (id: number): Promise => { + try { + const { error } = await supabase.from('todos').delete().eq('id', id); + if (error) { + throw new Error(`deleteTodo 오류 : ${error.message}`); + } + } catch (error) { + console.log(error); + } +}; + +// Complited 토글 = 어차피 toggle도 업데이트기 때문에 굳이 만들지 않아도 되지만 수업상 만듦 + +export const toggleTodo = async (id: number, completed: boolean): Promise => { + return updateTodo(id, { completed }); +}; +``` diff --git a/src/App.tsx b/src/App.tsx index 6e25d8e..e19fe13 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,17 +1,16 @@ import React from 'react'; -import { createTodo } from './services/todoServices'; +import TodoWrite from './components/todos/TodoWrite'; +import TodoList from './components/todos/TodoList'; +import { TodoProvider } from './contexts/TodoContext'; function App() { - const addTodo = async (): Promise => { - const result = await createTodo({ title: '할 일 입니다.', content: '내용입니다.' }); - if (result) { - console.log(result); - } - }; - return (
        - +

        Todo Service

        + + + +
        ); } diff --git a/src/components/todos/TodoItem.tsx b/src/components/todos/TodoItem.tsx index 5c2020b..d32ae75 100644 --- a/src/components/todos/TodoItem.tsx +++ b/src/components/todos/TodoItem.tsx @@ -1,16 +1,21 @@ import { useState } from 'react'; -import type { NewTodoType } from '../../types/todoType'; +import type { Todo } from '../../types/todoType'; import { useTodos } from '../../contexts/TodoContext'; +// 알리아스를 이용함 updateTodo as updateTodoService, toggleTodo as toggleTodoService, deleteTodo as deleteTodoService +import { + updateTodo as updateTodoService, + toggleTodo as toggleTodoService, + deleteTodo as deleteTodoService, +} from '../../services/todoServices'; type TodoItemProps = { - todo: NewTodoType; + todo: Todo; }; const TodoItem = ({ todo }: TodoItemProps) => { const { toggleTodo, editTodo, deleteTodo } = useTodos(); // 수정중인지 - const [isEdit, setIsEdit] = useState(false); const [editTitle, setEditTitle] = useState(todo.title); const handleChangeTitle = (e: React.ChangeEvent) => { @@ -21,16 +26,57 @@ const TodoItem = ({ todo }: TodoItemProps) => { handleEditSave(); } }; - const handleEditSave = () => { - if (editTitle.trim()) { - editTodo(todo.id, editTitle); - setIsEdit(false); + // 비동기로 DB에 update 한다 + const handleEditSave = async (): Promise => { + if (!editTitle.trim()) { + alert('제목을 입력하세요.'); + return; + } + + try { + // DB 의 내용 업데이트 + const result = await updateTodoService(todo.id, { title: editTitle }); + + if (result) { + // context 의 state.todos 의 항목 1개의 타이틀 수정 + editTodo(todo.id, editTitle); + setIsEdit(false); + } + } catch (error) { + console.log('데이터 업데이트에 실패하였습니다.'); } }; const handleEditCancel = () => { setEditTitle(todo.title); setIsEdit(false); }; + + // 비동기 통신으로 toggle 업데이트 + const handdleToggle = async (): Promise => { + try { + // DB 의 completed 가 업데이트가 되었다면, 성공 시 Todo 타입 리턴 + const result = await toggleTodoService(todo.id, !todo.completed); + if (result) { + // context 의 state.todos 의 1개 항목 completed 업데이트 + toggleTodo(todo.id); + } + } catch (error) { + console.log('데이터 토글에 실패하였습니다.', error); + } + }; + + // DB 의 데이터 delete + const handleDelete = async (): Promise => { + // DB 삭제 + try { + await deleteTodoService(todo.id); + // state 삭제기능 + deleteTodo(todo.id); + } catch (error) { + console.log('삭제에 실패하였습니다.', error); + } + }; + return (
      • {isEdit ? ( @@ -46,10 +92,10 @@ const TodoItem = ({ todo }: TodoItemProps) => { ) : ( <> - toggleTodo(todo.id)} /> + {todo.title} - + )}
      • diff --git a/src/components/todos/TodoList.tsx b/src/components/todos/TodoList.tsx index c3e7e72..5575c3a 100644 --- a/src/components/todos/TodoList.tsx +++ b/src/components/todos/TodoList.tsx @@ -1,4 +1,5 @@ import { useTodos } from '../../contexts/TodoContext'; +import type { Todo } from '../../types/todoType'; import TodoItem from './TodoItem'; export type TodoListProps = {}; @@ -10,7 +11,7 @@ const TodoList = ({}: TodoListProps) => {

        TodoList

          - {todos.map((item: any) => ( + {todos.map((item: Todo) => ( ))}
        diff --git a/src/components/todos/TodoWrite.tsx b/src/components/todos/TodoWrite.tsx index 7dba486..32de044 100644 --- a/src/components/todos/TodoWrite.tsx +++ b/src/components/todos/TodoWrite.tsx @@ -1,37 +1,55 @@ import { useState } from 'react'; import { useTodos } from '../../contexts/TodoContext'; +import type { TodoInsert } from '../../types/todoType'; +import { createTodo } from '../../services/todoServices'; type TodoWriteProps = { - // children 이 있을 경우는 적지만, 없을 경우 굳이 안적어도 됨. (수업이라 적음) children?: React.ReactNode; }; - -const TodoWrite = ({}: TodoWriteProps) => { - const [title, setTitle] = useState(''); - // context 사용 +const TodoWrite = ({}: TodoWriteProps): JSX.Element => { + // Context 를 사용함. const { addTodo } = useTodos(); - const handleChange = (e: React.ChangeEvent) => { + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + + const handleChange = (e: React.ChangeEvent): void => { setTitle(e.target.value); }; - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: React.KeyboardEvent): void => { if (e.key === 'Enter') { - // 저장 handleSave(); } }; - const handleSave = () => { - if (title.trim()) { - // 업데이트 - const newTodo = { id: Date.now().toString(), title: title, completed: false }; - addTodo(newTodo); + + // Supabase 에 데이터를 Insert 한다. : 비동기 + const handleSave = async (): Promise => { + if (!title.trim()) { + alert('제목을 입력하세요.'); + return; + } + + try { + const newTodo: TodoInsert = { title, content }; + // Supabase 에 데이터를 Insert 함 + const result = await createTodo(newTodo); + if (result) { + // Context 에 데이터를 추가해 줌. + addTodo(result); + } + + // 현재 Write 컴포넌트 state 초기화 setTitle(''); + setContent(''); + } catch (error) { + console.log(error); + alert('데이터 추가에 실패 하였습니다.'); } }; return (
        -

        할 일 작성

        +

        할일 작성

        (item.id === id ? { ...item, title } : item)); return { ...state, todos: arr }; } + // Supabase 에 목록 읽기 + case TodoActionType.SET_TODOS: { + const { todos } = action.payload; + return { ...state, todos }; + } default: return state; } } -// 3. context 생성 + +// Context 타입 : todos는 Todo[]로 고정, addTodo도 Todo를 받도록 함 // 만들어진 context 가 관리하는 value 의 모양 type TodoContextValue = { - todos: NewTodoType[]; - addTodo: (todo: NewTodoType) => void; - toggleTodo: (id: string) => void; - deleteTodo: (id: string) => void; - editTodo: (id: string, editTitle: string) => void; + todos: Todo[]; + addTodo: (todo: Todo) => void; + toggleTodo: (id: number) => void; + deleteTodo: (id: number) => void; + editTodo: (id: number, editTitle: string) => void; }; const TodoContext = createContext(null); -// 4. provider 생성 +// 5. Provider // type TodoProviderProps = { // children: React.ReactNode; // }; @@ -77,18 +96,38 @@ export const TodoProvider: React.FC = ({ children }): JSX.Ele const [state, dispatch] = useReducer(reducer, initialState); // dispatch 를 위한 함수 표현식 모음 - const addTodo = (newTodo: NewTodoType) => { + // (중요) addTodo는 id가 있는 Todo만 받음 + // 새 항목 추가는: 서버 insert -> 응답으로 받은 Todo(id 포함) -> addTodo 호출 + const addTodo = (newTodo: Todo) => { dispatch({ type: TodoActionType.ADD, payload: { todo: newTodo } }); }; - const toggleTodo = (id: string) => { + const toggleTodo = (id: number) => { dispatch({ type: TodoActionType.TOGGLE, payload: { id } }); }; - const deleteTodo = (id: string) => { + const deleteTodo = (id: number) => { dispatch({ type: TodoActionType.DELETE, payload: { id } }); }; - const editTodo = (id: string, editTitle: string) => { + const editTodo = (id: number, editTitle: string) => { dispatch({ type: TodoActionType.EDIT, payload: { id, title: editTitle } }); }; + // 실행시 state { todos }를 업데이트함 + // reducer 함수를 실행함 + const setTodos = (todos: Todo[]) => { + dispatch({ type: TodoActionType.SET_TODOS, payload: { todos } }); + }; + // Supabase 의 목록 읽기 함수 표현식 + // 비동기 데이터베이스 접근 + const loadTodos = async (): Promise => { + try { + const result = await getTodos(); + setTodos(result ?? []); + } catch (error) { + console.error('[loadTodos] 실패:', error); + } + }; + useEffect(() => { + void loadTodos(); + }, []); // value 전달할 값 const value: TodoContextValue = { @@ -101,8 +140,8 @@ export const TodoProvider: React.FC = ({ children }): JSX.Ele return {children}; }; -// 5. custom hook 생성 -export function useTodos() { +// 6. custom hook 생성 +export function useTodos(): TodoContextValue { const ctx = useContext(TodoContext); if (!ctx) { throw new Error('context를 찾을 수 없습니다.'); diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index cb15a97..c3cdd2c 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -1,5 +1,6 @@ import { createClient } from '@supabase/supabase-js'; +// CRA 의 환경 변수 호출과는 형식이 다름. (meta) const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; diff --git a/tsconfig.app.json b/tsconfig.app.json index 7008a4b..852a62d 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -14,7 +14,7 @@ "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, - "jsx": "react", + "jsx": "react-jsx", "allowJs": true, "checkJs": false, From 39756125ce16047cf31609d3e3495ba5bf56e457 Mon Sep 17 00:00:00 2001 From: suha720 Date: Wed, 3 Sep 2025 09:40:50 +0900 Subject: [PATCH 13/13] =?UTF-8?q?[docs]=20README.md=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 692 +++++++++++++++++++++++++++++ src/App.tsx | 3 +- src/components/todos/TodoWrite.tsx | 1 + 3 files changed, 694 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 49fd1cd..4c110ad 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,9 @@ export type TodoUpdate = Database['public']['Tables']['todos']['Update']; - `/src/apis 폴더` 생성 또는 `/src/services 폴더` 생성 ( 수업은 services 로 만듦 ) - /src/services/todoServices.ts 파일 생성 +- 반드시 async ...await 활용 ( 비동기 ) +- 반드시 함수 리턴타입 : Promise < 리턴 데이터타입 > + - axios, fetch 등등 ```ts import { supabase } from '../lib/supabase'; @@ -219,3 +222,692 @@ export const toggleTodo = async (id: number, completed: boolean): Promise import해주기 +// // Todo 목록 조회 +// export const getTodos = async (): Promise => { +// try { +export type Todo = Database['public']['Tables']['todos']['Row']; +export type TodoInsert = Database['public']['Tables']['todos']['Insert']; +export type TodoUpdate = Database['public']['Tables']['todos']['Update']; +``` + +- TodoType.ts 전체 코드 + +```ts +// newTodoType = todos +export type NewTodoType = { + id: string; + title: string; + completed: boolean; +}; + +// 해당 작업은 수작업 : 테이블명을 바꾸지 않는 이상 하단 타입은 변경되지 않음. (제너레이트란 명령을 주면 됨) +// 해당 작업 이후 todoService.ts 가서 Promise import해주기 +// // Todo 목록 조회 +// export const getTodos = async (): Promise => { +// try { +export type Todo = Database['public']['Tables']['todos']['Row']; +export type TodoInsert = Database['public']['Tables']['todos']['Insert']; +export type TodoUpdate = Database['public']['Tables']['todos']['Update']; + +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: '13.0.4'; + }; + public: { + Tables: { + todos: { + Row: { + completed: boolean; + content: string | null; + created_at: string | null; + id: number; + title: string; + updated_at: string | null; + }; + Insert: { + completed?: boolean; + content?: string | null; + created_at?: string | null; + id?: number; + title: string; + updated_at?: string | null; + }; + Update: { + completed?: boolean; + content?: string | null; + created_at?: string | null; + id?: number; + title?: string; + updated_at?: string | null; + }; + Relationships: []; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + [_ in never]: never; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +}; + +type DatabaseWithoutInternals = Omit; + +type DefaultSchema = DatabaseWithoutInternals[Extract]; + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views']) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends { + Row: infer R; + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + ? (DefaultSchema['Tables'] & DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R; + } + ? R + : never + : never; + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Insert: infer I; + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I; + } + ? I + : never + : never; + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Update: infer U; + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Update: infer U; + } + ? U + : never + : never; + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema['Enums'] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums'] + ? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions] + : never; + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema['CompositeTypes'] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes'] + ? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions] + : never; + +export const Constants = { + public: { + Enums: {}, + }, +} as const; +``` + +- /src/services/todoServices.ts + +```ts +import { supabase } from '../lib/supabase'; +import type { Todo, TodoInsert, TodoUpdate } from '../types/todoType'; + +// 이것이 CRUD!! + +// Todo 목록 조회 +export const getTodos = async (): Promise => { + const { data, error } = await supabase + .from('todos') + .select('*') + .order('created_at', { ascending: false }); + // 실행은 되었지만, 결과가 오류이다. + if (error) { + throw new Error(`getTodos 오류 : ${error.message}`); + } + return data || []; +}; +// Todo 생성 +export const createTodo = async (newTodo: TodoInsert): Promise => { + try { + const { data, error } = await supabase + .from('todos') + .insert([{ ...newTodo, completed: false }]) + .select() + .single(); + if (error) { + throw new Error(`createTodo 오류 : ${error.message}`); + } + return data; + } catch (error) { + console.log(error); + return null; + } +}; +// Todo 수정 +export const updateTodo = async (id: number, editTitle: TodoUpdate): Promise => { + try { + // 업데이트 구문 : const { data, error } = await supabase ~ .select(); + const { data, error } = await supabase + .from('todos') + .update({ ...editTitle, updated_at: new Date().toISOString() }) + .eq('id', id) + .select() + .single(); + + if (error) { + throw new Error(`updateTodo 오류 : ${error.message}`); + } + + return data; + } catch (error) { + console.log(error); + return null; + } +}; +// Todo 삭제 +export const deleteTodo = async (id: number): Promise => { + try { + const { error } = await supabase.from('todos').delete().eq('id', id); + if (error) { + throw new Error(`deleteTodo 오류 : ${error.message}`); + } + } catch (error) { + console.log(error); + } +}; + +// Complited 토글 = 어차피 toggle도 업데이트기 때문에 굳이 만들지 않아도 되지만 수업상 만듦 + +export const toggleTodo = async (id: number, completed: boolean): Promise => { + return updateTodo(id, { completed }); +}; +``` + +- /src/contexts/TodoContext.tsx + +```tsx +import React, { + createContext, + useContext, + useEffect, + useReducer, + type PropsWithChildren, +} from 'react'; +import type { Todo } from '../types/todoType'; +// 전체 DB 가져오기 +import { getTodos } from '../services/todoServices'; + +/** 1) 상태 타입과 초기값: 항상 Todo[]만 유지 */ +type TodosState = { + todos: Todo[]; +}; +const initialState: TodosState = { + todos: [], +}; + +/** 2) 액션 타입 */ +enum TodoActionType { + ADD = 'ADD', + TOGGLE = 'TOGGLE', + DELETE = 'DELETE', + EDIT = 'EDIT', + // Supabase todos 의 목록을 읽어오는 Action Type + SET_TODOS = 'SET_TODOS', +} + +// action type 정의 +/** 액션들: 모두 id가 존재하는 Todo 기준 */ +type AddAction = { type: TodoActionType.ADD; payload: { todo: Todo } }; +type ToggleAction = { type: TodoActionType.TOGGLE; payload: { id: number } }; +type DeleteAction = { type: TodoActionType.DELETE; payload: { id: number } }; +type EditAction = { type: TodoActionType.EDIT; payload: { id: number; title: string } }; +// Supabase 목록으로 state.todos 배열을 채워라 +type SetTodosAction = { type: TodoActionType.SET_TODOS; payload: { todos: Todo[] } }; +type TodoAction = AddAction | ToggleAction | DeleteAction | EditAction | SetTodosAction; + +// 3. Reducer : 반환 타입을 명시해 주면 더 명확해짐 +// action 은 {type:"문자열", payload: 재료 } 형태 +function reducer(state: TodosState, action: TodoAction): TodosState { + switch (action.type) { + case TodoActionType.ADD: { + const { todo } = action.payload; + return { ...state, todos: [todo, ...state.todos] }; + } + case TodoActionType.TOGGLE: { + const { id } = action.payload; + const arr = state.todos.map(item => + item.id === id ? { ...item, completed: !item.completed } : item, + ); + return { ...state, todos: arr }; + } + case TodoActionType.DELETE: { + const { id } = action.payload; + const arr = state.todos.filter(item => item.id !== id); + return { ...state, todos: arr }; + } + case TodoActionType.EDIT: { + const { id, title } = action.payload; + const arr = state.todos.map(item => (item.id === id ? { ...item, title } : item)); + return { ...state, todos: arr }; + } + // Supabase 에 목록 읽기 + case TodoActionType.SET_TODOS: { + const { todos } = action.payload; + return { ...state, todos }; + } + default: + return state; + } +} + +// Context 타입 : todos는 Todo[]로 고정, addTodo도 Todo를 받도록 함 +// 만들어진 context 가 관리하는 value 의 모양 +type TodoContextValue = { + todos: Todo[]; + addTodo: (todo: Todo) => void; + toggleTodo: (id: number) => void; + deleteTodo: (id: number) => void; + editTodo: (id: number, editTitle: string) => void; +}; + +const TodoContext = createContext(null); + +// 5. Provider +// type TodoProviderProps = { +// children: React.ReactNode; +// }; +// export const TodoProvider = ({ children }: TodoProviderProps) => { + +// export const TodoProvider = ({ children }: React.PropsWithChildren) => { + +export const TodoProvider: React.FC = ({ children }): JSX.Element => { + const [state, dispatch] = useReducer(reducer, initialState); + + // dispatch 를 위한 함수 표현식 모음 + // (중요) addTodo는 id가 있는 Todo만 받음 + // 새 항목 추가는: 서버 insert -> 응답으로 받은 Todo(id 포함) -> addTodo 호출 + const addTodo = (newTodo: Todo) => { + dispatch({ type: TodoActionType.ADD, payload: { todo: newTodo } }); + }; + const toggleTodo = (id: number) => { + dispatch({ type: TodoActionType.TOGGLE, payload: { id } }); + }; + const deleteTodo = (id: number) => { + dispatch({ type: TodoActionType.DELETE, payload: { id } }); + }; + const editTodo = (id: number, editTitle: string) => { + dispatch({ type: TodoActionType.EDIT, payload: { id, title: editTitle } }); + }; + // 실행시 state { todos }를 업데이트함 + // reducer 함수를 실행함 + const setTodos = (todos: Todo[]) => { + dispatch({ type: TodoActionType.SET_TODOS, payload: { todos } }); + }; + // Supabase 의 목록 읽기 함수 표현식 + // 비동기 데이터베이스 접근 + const loadTodos = async (): Promise => { + try { + const result = await getTodos(); + setTodos(result ?? []); + } catch (error) { + console.error('[loadTodos] 실패:', error); + } + }; + useEffect(() => { + void loadTodos(); + }, []); + + // value 전달할 값 + const value: TodoContextValue = { + todos: state.todos, + addTodo, + toggleTodo, + deleteTodo, + editTodo, + }; + return {children}; +}; + +// 6. custom hook 생성 +export function useTodos(): TodoContextValue { + const ctx = useContext(TodoContext); + if (!ctx) { + throw new Error('context를 찾을 수 없습니다.'); + } + return ctx; // value 를 리턴함 +} +``` + +- /src/lib/supabase.ts + +```ts +import { createClient } from '@supabase/supabase-js'; + +// CRA 의 환경 변수 호출과는 형식이 다름. (meta) +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; + +if (!supabaseUrl || !supabaseAnonKey) { + throw new Error('Missing Supabase environment variables'); +} + +export const supabase = createClient(supabaseUrl, supabaseAnonKey); +``` + +- /src/components/TodoList.tsx + +```tsx +import { useTodos } from '../../contexts/TodoContext'; +import type { Todo } from '../../types/todoType'; +import TodoItem from './TodoItem'; + +export type TodoListProps = {}; + +const TodoList = ({}: TodoListProps) => { + const { todos } = useTodos(); + + return ( +
        +

        TodoList

        +
          + {todos.map((item: Todo) => ( + + ))} +
        +
        + ); +}; + +export default TodoList; +``` + +- /src/components/TodoItem.tsx + +```tsx +import { useState } from 'react'; +import type { Todo } from '../../types/todoType'; +import { useTodos } from '../../contexts/TodoContext'; +// 알리아스를 이용함 updateTodo as updateTodoService, toggleTodo as toggleTodoService, deleteTodo as deleteTodoService +import { + updateTodo as updateTodoService, + toggleTodo as toggleTodoService, + deleteTodo as deleteTodoService, +} from '../../services/todoServices'; + +type TodoItemProps = { + todo: Todo; +}; + +const TodoItem = ({ todo }: TodoItemProps) => { + const { toggleTodo, editTodo, deleteTodo } = useTodos(); + + // 수정중인지 + const [isEdit, setIsEdit] = useState(false); + const [editTitle, setEditTitle] = useState(todo.title); + const handleChangeTitle = (e: React.ChangeEvent) => { + setEditTitle(e.target.value); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleEditSave(); + } + }; + // 비동기로 DB에 update 한다 + const handleEditSave = async (): Promise => { + if (!editTitle.trim()) { + alert('제목을 입력하세요.'); + return; + } + + try { + // DB 의 내용 업데이트 + const result = await updateTodoService(todo.id, { title: editTitle }); + + if (result) { + // context 의 state.todos 의 항목 1개의 타이틀 수정 + editTodo(todo.id, editTitle); + setIsEdit(false); + } + } catch (error) { + console.log('데이터 업데이트에 실패하였습니다.'); + } + }; + const handleEditCancel = () => { + setEditTitle(todo.title); + setIsEdit(false); + }; + + // 비동기 통신으로 toggle 업데이트 + const handdleToggle = async (): Promise => { + try { + // DB 의 completed 가 업데이트가 되었다면, 성공 시 Todo 타입 리턴 + const result = await toggleTodoService(todo.id, !todo.completed); + if (result) { + // context 의 state.todos 의 1개 항목 completed 업데이트 + toggleTodo(todo.id); + } + } catch (error) { + console.log('데이터 토글에 실패하였습니다.', error); + } + }; + + // DB 의 데이터 delete + const handleDelete = async (): Promise => { + // DB 삭제 + try { + await deleteTodoService(todo.id); + // state 삭제기능 + deleteTodo(todo.id); + } catch (error) { + console.log('삭제에 실패하였습니다.', error); + } + }; + + return ( +
      • + {isEdit ? ( + <> + handleChangeTitle(e)} + onKeyDown={e => handleKeyDown(e)} + /> + + + + ) : ( + <> + + {todo.title} + + + + )} +
      • + ); +}; + +export default TodoItem; +``` + +- /src/components/TodoWrite.tsx + +```tsx +import { useState } from 'react'; +import { useTodos } from '../../contexts/TodoContext'; +import type { TodoInsert } from '../../types/todoType'; +import { createTodo } from '../../services/todoServices'; + +type TodoWriteProps = { + children?: React.ReactNode; +}; +const TodoWrite = ({}: TodoWriteProps): JSX.Element => { + // Context 를 사용함. + const { addTodo } = useTodos(); + + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + + const handleChange = (e: React.ChangeEvent): void => { + setTitle(e.target.value); + }; + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter') { + handleSave(); + } + }; + + // Supabase 에 데이터를 Insert 한다. : 비동기 + const handleSave = async (): Promise => { + if (!title.trim()) { + alert('제목을 입력하세요.'); + return; + } + + try { + const newTodo: TodoInsert = { title, content }; + // Supabase 에 데이터를 Insert 함 + // Insert 결과 + const result = await createTodo(newTodo); + if (result) { + // Context 에 데이터를 추가해 줌. + addTodo(result); + } + + // 현재 Write 컴포넌트 state 초기화 + setTitle(''); + setContent(''); + } catch (error) { + console.log(error); + alert('데이터 추가에 실패 하였습니다.'); + } + }; + + return ( +
        +

        할일 작성

        +
        + handleChange(e)} + onKeyDown={e => handleKeyDown(e)} + /> + +
        +
        + ); +}; + +export default TodoWrite; +``` + +- 전체 App.tsx + +```tsx +import TodoWrite from './components/todos/TodoWrite'; +import TodoList from './components/todos/TodoList'; +import { TodoProvider } from './contexts/TodoContext'; + +function App() { + return ( +
        +

        Todo Service

        + + + + +
        + ); +} + +export default App; +``` diff --git a/src/App.tsx b/src/App.tsx index e19fe13..ccd13a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ -import React from 'react'; -import TodoWrite from './components/todos/TodoWrite'; import TodoList from './components/todos/TodoList'; +import TodoWrite from './components/todos/TodoWrite'; import { TodoProvider } from './contexts/TodoContext'; function App() { diff --git a/src/components/todos/TodoWrite.tsx b/src/components/todos/TodoWrite.tsx index 32de044..34f837c 100644 --- a/src/components/todos/TodoWrite.tsx +++ b/src/components/todos/TodoWrite.tsx @@ -32,6 +32,7 @@ const TodoWrite = ({}: TodoWriteProps): JSX.Element => { try { const newTodo: TodoInsert = { title, content }; // Supabase 에 데이터를 Insert 함 + // Insert 결과 const result = await createTodo(newTodo); if (result) { // Context 에 데이터를 추가해 줌.