From 98411d919d13737e3e41330dcfeecb03667b4a59 Mon Sep 17 00:00:00 2001 From: suha720 Date: Mon, 25 Aug 2025 11:11:31 +0900 Subject: [PATCH 01/51] =?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/51] =?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/51] =?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/51] =?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/51] =?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/51] =?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/51] =?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/51] =?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/51] =?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/51] =?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/51] [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/51] =?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/51] =?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 에 데이터를 추가해 줌. From 0b5ba1b99cc8acbc74b092ca5f69e4a3f153ae2e Mon Sep 17 00:00:00 2001 From: suha720 Date: Wed, 3 Sep 2025 14:20:56 +0900 Subject: [PATCH 14/51] =?UTF-8?q?[docs]=20supabase=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1336 ++++++++++++----------------- src/App.tsx | 73 +- src/components/todos/TodoItem.tsx | 6 +- src/contexts/AuthContext.tsx | 121 +++ src/contexts/Protected.tsx | 42 + src/lib/supabase.ts | 12 +- src/pages/AuthCallbackPage.tsx | 30 + src/pages/SignInPage.tsx | 35 + src/pages/SignUpPage.tsx | 39 + src/pages/TodosPage.tsx | 21 + 10 files changed, 905 insertions(+), 810 deletions(-) create mode 100644 src/contexts/AuthContext.tsx create mode 100644 src/contexts/Protected.tsx create mode 100644 src/pages/AuthCallbackPage.tsx create mode 100644 src/pages/SignInPage.tsx create mode 100644 src/pages/SignUpPage.tsx create mode 100644 src/pages/TodosPage.tsx diff --git a/README.md b/README.md index 4c110ad..2a4781f 100644 --- a/README.md +++ b/README.md @@ -1,913 +1,657 @@ -# Supabase 프로젝트 연동 - -- https://supabase.com/ -- `New organization` 으로 프로젝트 생성 -- `데이터베이스 비밀번호` 필수 보관 (.env) -- 테이블 생성 ( Table Editor 또는 SQL Editor ) -- 기본형은 Table Editor 로 생성하고 추가적 설정 SQL Editor 활용 -- SQL 문 익숙해질 시 SQL Editor 관리 권장 - -## 1. todos 테이블 생성 쿼리 - -```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() -); -``` +# Supabase Auth -## 2. `.env` 파일 생성 +- Auth : 인증 +- https://supabase.com/dashboard/project/rqckhcqnpwvkjofyetzm/editor/17327?schema=public -- 주의 사항 : .gitignore 꼭 확인 -> `.env` 없으면 꼭 추가해줘야함. +## 1. Auth 메뉴 확인 -``` -# 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 -``` +- 왼쪽 아이콘 중 `Authentication` 선택 -- `VITE_` 를 접두어로 사용 +### 1.1 User Table 확인 -``` -VITE_SUPABASE_DB_PW=초기 생성한 DB 비밀번호 -VITE_SUPABASE_URL=URL값 -VITE_SUPABASE_ANON_KEY=키값 -``` +- 회원에 대한 테이블명은 미리 생성이 되어있음 +- `Users` 라는 테이블이 이미 존재함 +- 회원가입을 하게 되면 `Users 테이블에 자동으로 추가`가 됨 -- Supabase URL 과 Anon Key 파악하기 - - Project 선택 후 `Project Overview` 에서 확인 - - `Project API` 항목에서 파악 가능 +### 1.2 Sign In / Providers 메뉴 -## 3. Supabase 클라이언트 라이브러리 설치 +- Auth Providers : 회원가입할 수 있는 여러가지 항목을 미리 제공함 +- `Email 항목` 이 활성화 되어 있는지 확인 -```bash -npm install @supabase/supabase-js -``` +### 1.3 Email 메뉴 확인 + +- SMTP ( Simple Mail Transfer Protocol ) : 인터넷에서 이메일을 보내고 받는 데 사용되는 통신 프로토콜 + - 단순 메일 전송 프로토콜 + - 예 ) http : HyperText Transfer Protocol + - 예) ftp : file Transfer Protocol +- Supabase 에는 이메일 인증을 테스트만 제공함 ( 1시간에 3번만 사용 가능, 그래서 SMTP 서버 구축 필요 ) +- 추후 SMTP 서버 구축 또는 Google Service, `resend.com` 로 무료로 활용 가능 + - resend.com 을 가장 많이 활용함, 혹은 구글/카카오 로그인 제일 많이 활용 +- Confirm signip 탭 : 회원가입 시 전달되는 인증메일 제목, 내용을 작성함 + +### 1.4 URL Configuration ※ 중요 ※ + +- Site URL : http://localhost:3000 번에서 `http://localhost:5173` 로 변경함 ( 추후 Vercel 주소로 변경 예정 ) +- Redirect URLs : `http://localhost:5173`, `http://localhost:3000` 등으로 입력. ( 이것도 추후 Vercel 주소도 입력 ) -## 4. 폴더 및 파일 구조 +- 여기까지 Auth 환경설정 끝 -- /src/lib 폴더 생성 -- /src/lib/supabase.ts 파일 생성 -- supabase 는 거의 next.js 와 많이 씀. ( ts에서 쓰이는 경우는 거의 없음 ) +--- + +## 2. Auth 적용하기 + +- /src/lib/supabase.ts ```ts import { createClient } from '@supabase/supabase-js'; -// CRA : process.env... 의 환경 변수 호출과는 형식이 다름. (meta) -// Vite : import.meta.env... 환경 변수 호출 +// 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); -``` - -## 5. ※ 중요 ※ Supabase 의 테이블의 컬럼의 데이터 타입 정의 ( js는 필요없지만 ts에선 필요함 ) - -### 5.1 데이터 타입 쉽게 추출하기 - -```bash -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 프로젝트 ID --schema 경로 > 파일명.ts"` - -``` -"scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview", - "generate-types": "npx supabase gen types typescript --project-id rqckhcqnpwvkjofyetzm --schema public > types_db.ts" +// 회원 인증 Auth 기능 추가하기 +export const supabase = createClient(supabaseUrl, supabaseAnonKey, { + auth: { + // 웹브라우저에 탭이 열려 있는 동안 로그인 인증 토큰(글자) 자동 갱신 + autoRefreshToken: true, // false 일 경우 자동으로 로그아웃이 됨 + // 사용자 세션 정보를 localStorage 에 저장해서 웹브라우저 새로고침 시에도 로그인 유지 + persistSession: true, + // URL 인증 세션을 파악해서 Auth 로그인 등의 콜백을 처리한다 + detectSessionInUrl: true, }, +}); ``` -```bash -npm run generate-types -``` +## 3. Auth 인증 정보 관리 ( 전역에서 Session 관리 ) -- 생성된 ts 의 내용을 참조하여 우리가 원하는 곳에 복사 및 붙여넣기 권장 - - 권장사항 : /src/types/database.ts 생성 및 붙여넣기 - - 편하게 활용하기 위한 처리 +- /src/Contexts/AuthContext.tsx -```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']; -``` +```tsx +/** + * 주요 기능 + * - 사용자 세션관리 + * - 로그인, 회원가입, 로그아웃 + * - 사용자 인증 정보 상태 변경 감시 + * - 전역 인증 상태를 컴포넌트에 반영 + */ + +import type { Session, User } from '@supabase/supabase-js'; +import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react'; +import { supabase } from '../lib/supabase'; -## 6. CRUD 실행해 보기 +// 1. 인증 Context Type +type AuthContextType = { + // 현재 사용자의 세션 정보 ( 로그인 상태, 토큰 ) + session: Session | null; + // 현재 로그인 된 사용자 정보 + user: User | null; + // 회원 가입 함수 - 개발자가 직접 수기 작성 ( 사용자의 이메일, 비밀번호를 받음 ) : 비동기라서 Promise 로 들어옴 + signUp: (email: string, password: string) => Promise<{ error?: string }>; + // 회원 로그인 함수 - 개발자가 직접 수기 작성 ( 사용자의 이메일, 비밀번호를 받음 ) : 비동기라서 Promise 로 들어옴 + signIn: (email: string, password: string) => Promise<{ error?: string }>; + // 회원 로그아웃 + signOut: () => Promise; +}; -### 6.1 CRUD 를 위한 폴더 및 파일 구조 +// 2. 인증 Context 생성 ( 인증 기능을 Children들 Component 에서 활용하게 해줌 ) +const AuthContext = createContext(null); -- `/src/apis 폴더` 생성 또는 `/src/services 폴더` 생성 ( 수업은 services 로 만듦 ) -- /src/services/todoServices.ts 파일 생성 -- 반드시 async ...await 활용 ( 비동기 ) -- 반드시 함수 리턴타입 : Promise < 리턴 데이터타입 > - - axios, fetch 등등 +// 3. 인증 Context Provider +export const AuthProvider: React.FC = ({ children }) => { + // 현재 사용자 세션 + const [session, setSession] = useState(null); + // 현재 로그인한 사용자 정보 + const [user, setUser] = useState(null); -```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(); + // 실행이 되자마자 ( 초기 세션 ) 로드 및 인증 상태 변경 감시 ( 새로고침을 하던 뭘 하던 바로 작동되게끔 ) + useEffect(() => { + // 기존 세션이 있는지 확인 + supabase.auth.getSession().then(({ data }) => { + setSession(data.session ? data.session : null); + setUser(data.session?.user ?? null); + }); + // 인증상태 변경 이벤트를 체크함 ( 로그인, 로그아웃 , 토큰 갱신 등의 이벤트 실시간 감시 ) + const { data } = supabase.auth.onAuthStateChange((_event, newSession) => { + setSession(newSession); + setUser(newSession?.user ?? null); + }); + // Component 가 제거 되면, 이벤트 체크 해제함 : cleanUp ( return () => {} << 이렇게 생김 ) + return () => { + // 이벤트 감시 해제 + data.subscription.unsubscribe(); + }; + }, []); + // 회원 가입 (이메일, 비밀번호) + const signUp: AuthContextType['signUp'] = async (email, password) => { + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + // 회원 가입 후 이메일로 인증 확인 시 리다이렉트 될 URL + emailRedirectTo: `${window.location.origin}/auth/callback`, + }, + }); if (error) { - throw new Error(`updateTodo 오류 : ${error.message}`); + return { error: error.message }; } + // 우리는 이메일 확인을 활성화 시켰음 + // 이메일 확인 후 인증 전까지는 아무것도 넘어오지 않음 + return {}; + }; - 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); + // 회원 로그인 (이메일, 비밀번호) + const signIn: AuthContextType['signIn'] = async (email, password) => { + const { error } = await supabase.auth.signInWithPassword({ email, password, options: {} }); if (error) { - throw new Error(`deleteTodo 오류 : ${error.message}`); + return { error: error.message }; } - } catch (error) { - console.log(error); - } -}; + return {}; + }; + // 회원 로그아웃 + const signOut: AuthContextType['signOut'] = async () => { + await supabase.auth.signOut(); + }; -// Complited 토글 = 어차피 toggle도 업데이트기 때문에 굳이 만들지 않아도 되지만 수업상 만듦 + return ( + + {children} + + ); +}; -export const toggleTodo = async (id: number, completed: boolean): Promise => { - return updateTodo(id, { completed }); +// const {signUp, signIn, signOut, user, session} = useAuth() +export const useAuth = () => { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error('AuthContext 가 없습니다.'); + } + return ctx; }; ``` -## 7. todos 예제 +- App.tsx -- /src/types/TodoType.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']; -``` - -- TodoType.ts 전체 코드 +```tsx +import TodoList from './components/todos/TodoList'; +import TodoWrite from './components/todos/TodoWrite'; +import { AuthProvider } from './contexts/AuthContext'; +import { TodoProvider } from './contexts/TodoContext'; -```ts -// newTodoType = todos -export type NewTodoType = { - id: string; - title: string; - completed: boolean; -}; +function App() { + return ( + +
        +

        Todo Service

        + + + + +
        +
        + ); +} -// 해당 작업은 수작업 : 테이블명을 바꾸지 않는 이상 하단 타입은 변경되지 않음. (제너레이트란 명령을 주면 됨) -// 해당 작업 이후 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; - }; - }; -}; +export default App; +``` -type DatabaseWithoutInternals = Omit; +## 4. 회원 가입 폼 만들기 -type DefaultSchema = DatabaseWithoutInternals[Extract]; +- /src/pages/SignUpPage.tsx 파일 생성 -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; -``` +```tsx +import { useState } from 'react'; +import { useAuth } from '../contexts/AuthContext'; -- /src/services/todoServices.ts +function SignUpPage() { + const { signUp } = useAuth(); -```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(); + const [email, setEmail] = useState(''); + const [pw, setPw] = useState(''); + const [msg, setMsg] = useState(''); - if (error) { - throw new Error(`updateTodo 오류 : ${error.message}`); - } + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); // 해당 코드 필수 : 웹브라우저 갱신 막아주기 - 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); + // 회원 가입 하기 + const { error } = await signUp(email, pw); if (error) { - throw new Error(`deleteTodo 오류 : ${error.message}`); + setMsg(`회원가입 오류 : ${error}`); + } else { + setMsg(`이메일이 발송 되었습니다. 이메일을 확인 해주세요.`); } - } 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', + }; + return ( +
        +

        Todo Service 회원 가입

        +
        +
        + setEmail(e.target.value)} /> + {/* */} + setPw(e.target.value)} /> + {/* form 안에선 button type 지정해주기 */} + +
        +

        {msg}

        +
        +
        + ); } -// 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; - } -} +export default SignUpPage; +``` -// 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; -}; +## 5. 로그인 폼 만들기 -const TodoContext = createContext(null); +- /src/pages/SignInPage.tsx -// 5. Provider -// type TodoProviderProps = { -// children: React.ReactNode; -// }; -// export const TodoProvider = ({ children }: TodoProviderProps) => { +```tsx +import { useState } from 'react'; +import { useAuth } from '../contexts/AuthContext'; -// export const TodoProvider = ({ children }: React.PropsWithChildren) => { +function SignInPage() { + const { signIn } = useAuth(); + const [email, setEmail] = useState(''); + const [pw, setPw] = useState(''); + const [msg, setMsg] = useState(''); -export const TodoProvider: React.FC = ({ children }): JSX.Element => { - const [state, dispatch] = useReducer(reducer, initialState); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); // 해당 코드 필수 : 웹브라우저 갱신 막아주기 - // 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); + const { error } = await signIn(email, pw); + if (error) { + setMsg(`로그인 오류 : ${error}`); + } else { + setMsg(`로그인이 성공하였습니다.`); } }; - 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 를 리턴함 + return ( +
        +

        로그인

        +
        +
        + setEmail(e.target.value)} /> + setPw(e.target.value)} /> + +
        +

        {msg}

        +
        +
        + ); } + +export default SignInPage; ``` -- /src/lib/supabase.ts +## 6. TodoPage 생성 -```ts -import { createClient } from '@supabase/supabase-js'; +- 목적 : 인증이 안된 사용자는 할 일 작성 못하게끔 만드려고함. +- /src/pages/TodosPage.tsx 파일 생성 -// CRA 의 환경 변수 호출과는 형식이 다름. (meta) -const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; -const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; +```tsx +import TodoList from '../components/todos/TodoList'; +import TodoWrite from '../components/todos/TodoWrite'; +import { TodoProvider } from '../contexts/TodoContext'; -if (!supabaseUrl || !supabaseAnonKey) { - throw new Error('Missing Supabase environment variables'); +function TodosPage() { + return ( +
        +

        할 일

        + +
        + +
        +
        + +
        +
        +
        + ); } -export const supabase = createClient(supabaseUrl, supabaseAnonKey); +export default TodosPage; ``` -- /src/components/TodoList.tsx +## 7. 인증 페이지 + +- /src/pages/AuthCallback.tsx ```tsx -import { useTodos } from '../../contexts/TodoContext'; -import type { Todo } from '../../types/todoType'; -import TodoItem from './TodoItem'; +import React, { useEffect, useState } from 'react'; -export type TodoListProps = {}; +/** + * - 인증 콜백 URL 처리 + * - 사용자에게 인증 진행 상태 안내 + * - 자동 인증 처리 완료 안내 + */ -const TodoList = ({}: TodoListProps) => { - const { todos } = useTodos(); +function AuthCallback() { + const [msg, setMsg] = useState('인증 처리 중 ...'); + useEffect(() => { + const timer = setTimeout(() => { + setMsg('이메일 인증 완료. 홈으로 이동해주세요'); + }, 1500); + + // 클린업 함수 + return () => { + clearTimeout(timer); + }; + }, []); return (
        -

        TodoList

        -
          - {todos.map((item: Todo) => ( - - ))} -
        +

        인증 페이지

        +
        {msg}
        ); -}; +} -export default TodoList; +export default AuthCallback; ``` -- /src/components/TodoItem.tsx +## 8. Router 구성하기 ( 메뉴 구성하기 ) + +- App.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; +import { Link, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; +import AuthCallbackPage from './pages/AuthCallbackPage'; +import HomePage from './pages/HomePage'; +import SignInPage from './pages/SignInPage'; +import SignUpPage from './pages/SignUpPage'; +import TodosPage from './pages/TodosPage'; + +const TopBar = () => { + const { signOut, user } = useAuth(); + return ( + + ); }; -const TodoItem = ({ todo }: TodoItemProps) => { - const { toggleTodo, editTodo, deleteTodo } = useTodos(); +function App() { + return ( + +
        +

        Todo Service

        + + + + } /> + } /> + } /> + } /> + } /> + + +
        +
        + ); +} - // 수정중인지 - 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; - } +export default App; +``` - try { - // DB 의 내용 업데이트 - const result = await updateTodoService(todo.id, { title: editTitle }); +## 9. 인증 ( Auth ) 에 따라서 라우터 처리하기 - if (result) { - // context 의 state.todos 의 항목 1개의 타이틀 수정 - editTodo(todo.id, editTitle); - setIsEdit(false); - } - } catch (error) { - console.log('데이터 업데이트에 실패하였습니다.'); - } - }; - const handleEditCancel = () => { - setEditTitle(todo.title); - setIsEdit(false); - }; +- 인증된 사용자 ( 로그인 사용자 허가 ) 페이지 처리하기 +- /src/components/Protected.tsx 파일 생성 - // 비동기 통신으로 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); - } - }; +```tsx +/** + * 로그인 한 사용자가 접근 할 수 있는 페이지 : + * - 사용자 프로필 페이지 + * - 관리자 대시보드 페이지 + * - 개인 설정 페이지 + * - 구매 내역 페이지 등등 + */ + +import type { PropsWithChildren } from 'react'; +import { useAuth } from './AuthContext'; +import { Navigate } from 'react-router-dom'; + +const Protected: React.FC = ({ children }) => { + const { user } = useAuth(); + // 로그인하지 않은 사용자는 로그인 페이지로 강제 이동, 로그인 한 사용자는 return
        {children}
        ; + if (!user) { + return ; + } + return
        {children}
        ; +}; - // DB 의 데이터 delete - const handleDelete = async (): Promise => { - // DB 삭제 - try { - await deleteTodoService(todo.id); - // state 삭제기능 - deleteTodo(todo.id); - } catch (error) { - console.log('삭제에 실패하였습니다.', error); - } - }; +export default Protected; +``` + +## 10. App.tsx 에 Protected 사용하기 + +- App.tsx +```tsx +import { Link, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; +import AuthCallbackPage from './pages/AuthCallbackPage'; +import HomePage from './pages/HomePage'; +import SignInPage from './pages/SignInPage'; +import SignUpPage from './pages/SignUpPage'; +import TodosPage from './pages/TodosPage'; +import Protected from './contexts/Protected'; + +const TopBar = () => { + const { signOut, user } = useAuth(); return ( -
      • - {isEdit ? ( - <> - handleChangeTitle(e)} - onKeyDown={e => handleKeyDown(e)} - /> - - - - ) : ( - <> - - {todo.title} - - - - )} -
      • + ); }; -export default TodoItem; +function App() { + return ( + +
        +

        Todo Service

        + + + + } /> + } /> + } /> + } /> + {/* Protected 로 감싸주기 */} + + + + } + /> + + +
        +
        + ); +} + +export default App; ``` -- /src/components/TodoWrite.tsx +## 11. 새로 고침을 하거나, 직접 주소를 입력 할 경우에도 사용자 정보 유지하기 + +- 유지는 되고 있으나, React 에서 처리 순서가 늦음 +- AuthContext 에 loadding 이라는 처리를 진행해 주고, 활용함 +- AuthContext.tsx ```tsx -import { useState } from 'react'; -import { useTodos } from '../../contexts/TodoContext'; -import type { TodoInsert } from '../../types/todoType'; -import { createTodo } from '../../services/todoServices'; +/** + * 주요 기능 + * - 사용자 세션관리 + * - 로그인, 회원가입, 로그아웃 + * - 사용자 인증 정보 상태 변경 감시 + * - 전역 인증 상태를 컴포넌트에 반영 + */ + +import type { Session, User } from '@supabase/supabase-js'; +import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react'; +import { supabase } from '../lib/supabase'; -type TodoWriteProps = { - children?: React.ReactNode; +// 1. 인증 Context Type +type AuthContextType = { + // 현재 사용자의 세션 정보 ( 로그인 상태, 토큰 ) + session: Session | null; + // 현재 로그인 된 사용자 정보 + user: User | null; + // 회원 가입 함수 - 개발자가 직접 수기 작성 ( 사용자의 이메일, 비밀번호를 받음 ) : 비동기라서 Promise 로 들어옴 + signUp: (email: string, password: string) => Promise<{ error?: string }>; + // 회원 로그인 함수 - 개발자가 직접 수기 작성 ( 사용자의 이메일, 비밀번호를 받음 ) : 비동기라서 Promise 로 들어옴 + signIn: (email: string, password: string) => Promise<{ error?: string }>; + // 회원 로그아웃 + signOut: () => Promise; + // 회원 정보 로딩 상태 + loading: boolean; }; -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(); - } - }; +// 2. 인증 Context 생성 ( 인증 기능을 Children들 Component 에서 활용하게 해줌 ) +const AuthContext = createContext(null); - // Supabase 에 데이터를 Insert 한다. : 비동기 - const handleSave = async (): Promise => { - if (!title.trim()) { - alert('제목을 입력하세요.'); - return; - } +// 3. 인증 Context Provider +export const AuthProvider: React.FC = ({ children }) => { + // 현재 사용자 세션 + const [session, setSession] = useState(null); + // 현재 로그인한 사용자 정보 + const [user, setUser] = useState(null); + // 로딩 상태 추가 : 초기 실행시 loading 시킴, true + const [loading, setLoading] = useState(true); - try { - const newTodo: TodoInsert = { title, content }; - // Supabase 에 데이터를 Insert 함 - // Insert 결과 - const result = await createTodo(newTodo); - if (result) { - // Context 에 데이터를 추가해 줌. - addTodo(result); + // 실행이 되자마자 ( 초기 세션 ) 로드 및 인증 상태 변경 감시 ( 새로고침을 하던 뭘 하던 바로 작동되게끔 ) + useEffect(() => { + // 세션을 초기에 로딩을 한 후 처리함 + const loadSession = async () => { + try { + setLoading(true); // 로딩중. 해당 코드는 굳이 안적어도 됨 + + const { data } = await supabase.auth.getSession(); + setSession(data.session ? data.session : null); + setUser(data.session?.user ?? null); + } catch (error) { + console.log(error); + } finally { + // finally : 성공해도 실행, 실패해도 실행 ( 과정이 끝나면 무조건 로딩완료함 ) + setLoading(false); } + }; + loadSession(); + + // // 기존 세션이 있는지 확인 + // supabase.auth.getSession().then(({ data }) => { + // setSession(data.session ? data.session : null); + // setUser(data.session?.user ?? null); + // }); + + // 인증상태 변경 이벤트를 체크함 ( 로그인, 로그아웃 , 토큰 갱신 등의 이벤트 실시간 감시 ) + const { data } = supabase.auth.onAuthStateChange((_event, newSession) => { + setSession(newSession); + setUser(newSession?.user ?? null); + }); + // Component 가 제거 되면, 이벤트 체크 해제함 : cleanUp ( return () => {} << 이렇게 생김 ) + return () => { + // 이벤트 감시 해제 + data.subscription.unsubscribe(); + }; + }, []); - // 현재 Write 컴포넌트 state 초기화 - setTitle(''); - setContent(''); - } catch (error) { - console.log(error); - alert('데이터 추가에 실패 하였습니다.'); + // 회원 가입 (이메일, 비밀번호) + const signUp: AuthContextType['signUp'] = async (email, password) => { + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + // 회원 가입 후 이메일로 인증 확인 시 리다이렉트 될 URL + emailRedirectTo: `${window.location.origin}/auth/callback`, + }, + }); + if (error) { + return { error: error.message }; } + // 우리는 이메일 확인을 활성화 시켰음 + // 이메일 확인 후 인증 전까지는 아무것도 넘어오지 않음 + return {}; }; - return ( -
        -

        할일 작성

        -
        - handleChange(e)} - onKeyDown={e => handleKeyDown(e)} - /> - -
        -
        - ); -}; - -export default TodoWrite; -``` + // 회원 로그인 (이메일, 비밀번호) + const signIn: AuthContextType['signIn'] = async (email, password) => { + const { error } = await supabase.auth.signInWithPassword({ email, password, options: {} }); + if (error) { + return { error: error.message }; + } + return {}; + }; + // 회원 로그아웃 + const signOut: AuthContextType['signOut'] = async () => { + await supabase.auth.signOut(); + }; -- 전체 App.tsx + const value: AuthContextType = { signUp, signOut, signIn, user, session, loading }; -```tsx -import TodoWrite from './components/todos/TodoWrite'; -import TodoList from './components/todos/TodoList'; -import { TodoProvider } from './contexts/TodoContext'; - -function App() { - return ( -
        -

        Todo Service

        - - - - -
        - ); -} + return {children}; +}; -export default App; +// const {signUp, signIn, signOut, user, session} = useAuth() +export const useAuth = () => { + const ctx = useContext(AuthContext); +}; ``` + +## 12. Protected 에 loading 값 활용하기 diff --git a/src/App.tsx b/src/App.tsx index ccd13a7..ea20112 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,69 @@ -import TodoList from './components/todos/TodoList'; -import TodoWrite from './components/todos/TodoWrite'; -import { TodoProvider } from './contexts/TodoContext'; +import { Link, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; +import AuthCallbackPage from './pages/AuthCallbackPage'; +import HomePage from './pages/HomePage'; +import SignInPage from './pages/SignInPage'; +import SignUpPage from './pages/SignUpPage'; +import TodosPage from './pages/TodosPage'; +import Protected from './contexts/Protected'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; + +const TopBar = () => { + const { signOut, user } = useAuth(); + return ( + + ); +}; function App() { return ( -
        -

        Todo Service

        - - - - -
        + +
        +

        Todo Service

        + + + + } /> + } /> + } /> + } /> + {/* Protected 로 감싸주기 */} + + + + } + /> + + +
        +
        ); } diff --git a/src/components/todos/TodoItem.tsx b/src/components/todos/TodoItem.tsx index d32ae75..1d4172b 100644 --- a/src/components/todos/TodoItem.tsx +++ b/src/components/todos/TodoItem.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import type { Todo } from '../../types/todoType'; import { useTodos } from '../../contexts/TodoContext'; -// 알리아스를 이용함 updateTodo as updateTodoService, toggleTodo as toggleTodoService, deleteTodo as deleteTodoService +// 알리아스를 이용함 updateTodo as updateTodoService, toggleTodo as toggleTodoService, deleteTodo as deleteTodoService import { updateTodo as updateTodoService, toggleTodo as toggleTodoService, @@ -52,7 +52,7 @@ const TodoItem = ({ todo }: TodoItemProps) => { }; // 비동기 통신으로 toggle 업데이트 - const handdleToggle = async (): Promise => { + const handleToggle = async (): Promise => { try { // DB 의 completed 가 업데이트가 되었다면, 성공 시 Todo 타입 리턴 const result = await toggleTodoService(todo.id, !todo.completed); @@ -92,7 +92,7 @@ const TodoItem = ({ todo }: TodoItemProps) => { ) : ( <> - + {todo.title} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..756f444 --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,121 @@ +/** + * 주요 기능 + * - 사용자 세션관리 + * - 로그인, 회원가입, 로그아웃 + * - 사용자 인증 정보 상태 변경 감시 + * - 전역 인증 상태를 컴포넌트에 반영 + */ + +import type { Session, User } from '@supabase/supabase-js'; +import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react'; +import { supabase } from '../lib/supabase'; + +// 1. 인증 Context Type +type AuthContextType = { + // 현재 사용자의 세션 정보 ( 로그인 상태, 토큰 ) + session: Session | null; + // 현재 로그인 된 사용자 정보 + user: User | null; + // 회원 가입 함수 - 개발자가 직접 수기 작성 ( 사용자의 이메일, 비밀번호를 받음 ) : 비동기라서 Promise 로 들어옴 + signUp: (email: string, password: string) => Promise<{ error?: string }>; + // 회원 로그인 함수 - 개발자가 직접 수기 작성 ( 사용자의 이메일, 비밀번호를 받음 ) : 비동기라서 Promise 로 들어옴 + signIn: (email: string, password: string) => Promise<{ error?: string }>; + // 회원 로그아웃 + signOut: () => Promise; + // 회원 정보 로딩 상태 + loading: boolean; +}; + +// 2. 인증 Context 생성 ( 인증 기능을 Children들 Component 에서 활용하게 해줌 ) +const AuthContext = createContext(null); + +// 3. 인증 Context Provider +export const AuthProvider: React.FC = ({ children }) => { + // 현재 사용자 세션 + const [session, setSession] = useState(null); + // 현재 로그인한 사용자 정보 + const [user, setUser] = useState(null); + // 로딩 상태 추가 : 초기 실행시 loading 시킴, true + const [loading, setLoading] = useState(true); + + // 실행이 되자마자 ( 초기 세션 ) 로드 및 인증 상태 변경 감시 ( 새로고침을 하던 뭘 하던 바로 작동되게끔 ) + useEffect(() => { + // 세션을 초기에 로딩을 한 후 처리함 + const loadSession = async () => { + try { + setLoading(true); // 로딩중. 해당 코드는 굳이 안적어도 됨 + + const { data } = await supabase.auth.getSession(); + setSession(data.session ? data.session : null); + setUser(data.session?.user ?? null); + } catch (error) { + console.log(error); + } finally { + // finally : 성공해도 실행, 실패해도 실행 ( 과정이 끝나면 무조건 로딩완료함 ) + setLoading(false); + } + }; + loadSession(); + + // // 기존 세션이 있는지 확인 + // supabase.auth.getSession().then(({ data }) => { + // setSession(data.session ? data.session : null); + // setUser(data.session?.user ?? null); + // }); + + // 인증상태 변경 이벤트를 체크함 ( 로그인, 로그아웃 , 토큰 갱신 등의 이벤트 실시간 감시 ) + const { data } = supabase.auth.onAuthStateChange((_event, newSession) => { + setSession(newSession); + setUser(newSession?.user ?? null); + }); + // Component 가 제거 되면, 이벤트 체크 해제함 : cleanUp ( return () => {} << 이렇게 생김 ) + return () => { + // 이벤트 감시 해제 + data.subscription.unsubscribe(); + }; + }, []); + + // 회원 가입 (이메일, 비밀번호) + const signUp: AuthContextType['signUp'] = async (email, password) => { + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + // 회원 가입 후 이메일로 인증 확인 시 리다이렉트 될 URL + emailRedirectTo: `${window.location.origin}/auth/callback`, + }, + }); + if (error) { + return { error: error.message }; + } + // 우리는 이메일 확인을 활성화 시켰음 + // 이메일 확인 후 인증 전까지는 아무것도 넘어오지 않음 + return {}; + }; + + // 회원 로그인 (이메일, 비밀번호) + const signIn: AuthContextType['signIn'] = async (email, password) => { + const { error } = await supabase.auth.signInWithPassword({ email, password, options: {} }); + if (error) { + return { error: error.message }; + } + return {}; + }; + // 회원 로그아웃 + const signOut: AuthContextType['signOut'] = async () => { + await supabase.auth.signOut(); + }; + + const value: AuthContextType = { signUp, signOut, signIn, user, session, loading }; + + return {children}; +}; + +// const {signUp, signIn, signOut, user, session} = useAuth() +export const useAuth = () => { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error('AuthContext 가 없습니다.'); + } + return ctx; +}; diff --git a/src/contexts/Protected.tsx b/src/contexts/Protected.tsx new file mode 100644 index 0000000..de9d4e5 --- /dev/null +++ b/src/contexts/Protected.tsx @@ -0,0 +1,42 @@ +/** + * 로그인 한 사용자가 접근 할 수 있는 페이지 : + * - 사용자 프로필 페이지 + * - 관리자 대시보드 페이지 + * - 개인 설정 페이지 + * - 구매 내역 페이지 등등 + */ + +import type { PropsWithChildren } from 'react'; +import { useAuth } from './AuthContext'; +import { Navigate } from 'react-router-dom'; + +const Protected: React.FC = ({ children }) => { + const { user, loading } = useAuth(); + + if (loading) { + // 사용자 정보가 로딩중이라면 ? + return ( +
        + ); + } + + // 로그인하지 않은 사용자는 로그인 페이지로 강제 이동, 로그인 한 사용자는 return
        {children}
        ; + if (!user) { + return ; + } + return
        {children}
        ; +}; + +export default Protected; diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index c3cdd2c..89c715e 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -8,4 +8,14 @@ if (!supabaseUrl || !supabaseAnonKey) { throw new Error('Missing Supabase environment variables'); } -export const supabase = createClient(supabaseUrl, supabaseAnonKey); +// 회원 인증 Auth 기능 추가하기 +export const supabase = createClient(supabaseUrl, supabaseAnonKey, { + auth: { + // 웹브라우저에 탭이 열려 있는 동안 로그인 인증 토큰(글자) 자동 갱신 + autoRefreshToken: true, // false 일 경우 자동으로 로그아웃이 됨 + // 사용자 세션 정보를 localStorage 에 저장해서 웹브라우저 새로고침 시에도 로그인 유지 + persistSession: true, + // URL 인증 세션을 파악해서 Auth 로그인 등의 콜백을 처리한다 + detectSessionInUrl: true, + }, +}); diff --git a/src/pages/AuthCallbackPage.tsx b/src/pages/AuthCallbackPage.tsx new file mode 100644 index 0000000..44d1840 --- /dev/null +++ b/src/pages/AuthCallbackPage.tsx @@ -0,0 +1,30 @@ +import React, { useEffect, useState } from 'react'; + +/** + * - 인증 콜백 URL 처리 + * - 사용자에게 인증 진행 상태 안내 + * - 자동 인증 처리 완료 안내 + */ + +function AuthCallback() { + const [msg, setMsg] = useState('인증 처리 중 ...'); + useEffect(() => { + const timer = setTimeout(() => { + setMsg('이메일 인증 완료. 홈으로 이동해주세요'); + }, 1500); + + // 클린업 함수 + return () => { + clearTimeout(timer); + }; + }, []); + + return ( +
        +

        인증 페이지

        +
        {msg}
        +
        + ); +} + +export default AuthCallback; diff --git a/src/pages/SignInPage.tsx b/src/pages/SignInPage.tsx new file mode 100644 index 0000000..487f91f --- /dev/null +++ b/src/pages/SignInPage.tsx @@ -0,0 +1,35 @@ +import { useState } from 'react'; +import { useAuth } from '../contexts/AuthContext'; + +function SignInPage() { + const { signIn } = useAuth(); + const [email, setEmail] = useState(''); + const [pw, setPw] = useState(''); + const [msg, setMsg] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); // 해당 코드 필수 : 웹브라우저 갱신 막아주기 + + const { error } = await signIn(email, pw); + if (error) { + setMsg(`로그인 오류 : ${error}`); + } else { + setMsg(`로그인이 성공하였습니다.`); + } + }; + return ( +
        +

        로그인

        +
        +
        + setEmail(e.target.value)} /> + setPw(e.target.value)} /> + +
        +

        {msg}

        +
        +
        + ); +} + +export default SignInPage; diff --git a/src/pages/SignUpPage.tsx b/src/pages/SignUpPage.tsx new file mode 100644 index 0000000..7ef87cc --- /dev/null +++ b/src/pages/SignUpPage.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; +import { useAuth } from '../contexts/AuthContext'; + +function SignUpPage() { + const { signUp } = useAuth(); + + const [email, setEmail] = useState(''); + const [pw, setPw] = useState(''); + const [msg, setMsg] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); // 해당 코드 필수 : 웹브라우저 갱신 막아주기 + + // 회원 가입 하기 + const { error } = await signUp(email, pw); + if (error) { + setMsg(`회원가입 오류 : ${error}`); + } else { + setMsg(`이메일이 발송 되었습니다. 이메일을 확인 해주세요.`); + } + }; + return ( +
        +

        Todo Service 회원 가입

        +
        +
        + setEmail(e.target.value)} /> + {/* */} + setPw(e.target.value)} /> + {/* form 안에선 button type 지정해주기 */} + +
        +

        {msg}

        +
        +
        + ); +} + +export default SignUpPage; diff --git a/src/pages/TodosPage.tsx b/src/pages/TodosPage.tsx new file mode 100644 index 0000000..336970c --- /dev/null +++ b/src/pages/TodosPage.tsx @@ -0,0 +1,21 @@ +import TodoList from '../components/todos/TodoList'; +import TodoWrite from '../components/todos/TodoWrite'; +import { TodoProvider } from '../contexts/TodoContext'; + +function TodosPage() { + return ( +
        +

        할 일

        + +
        + +
        +
        + +
        +
        +
        + ); +} + +export default TodosPage; From 120389ac9114d304442b00de457cf39e18060302 Mon Sep 17 00:00:00 2001 From: suha720 Date: Thu, 4 Sep 2025 09:36:10 +0900 Subject: [PATCH 15/51] =?UTF-8?q?[docs]=20Auth=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/README.md b/README.md index 2a4781f..e1527c6 100644 --- a/README.md +++ b/README.md @@ -655,3 +655,51 @@ export const useAuth = () => { ``` ## 12. Protected 에 loading 값 활용하기 + +- Auth 인증 후 `새로고침` 또는 `주소 직접 입력` 시 `인증 상태를 읽기 위한 시간 확보` +- AuthContext.tsx 에서 읽어 들일 때 까지 Loading 을 활성화 함 + +```tsx +/** + * 로그인 한 사용자가 접근 할 수 있는 페이지 : + * - 사용자 프로필 페이지 + * - 관리자 대시보드 페이지 + * - 개인 설정 페이지 + * - 구매 내역 페이지 등등 + */ + +import type { PropsWithChildren } from 'react'; +import { useAuth } from './AuthContext'; +import { Navigate } from 'react-router-dom'; + +const Protected: React.FC = ({ children }) => { + const { user, loading } = useAuth(); + + if (loading) { + // 사용자 정보가 로딩중이라면 ? + return ( +
        + ); + } + + // 로그인하지 않은 사용자는 로그인 페이지로 강제 이동, 로그인 한 사용자는 return
        {children}
        ; + if (!user) { + return ; + } + return
        {children}
        ; +}; + +export default Protected; +``` From befca8d73bfd7516cb0a146cb3fa25c36bb924a3 Mon Sep 17 00:00:00 2001 From: suha720 Date: Thu, 4 Sep 2025 13:04:13 +0900 Subject: [PATCH 16/51] =?UTF-8?q?[docs]=20auth=20profile=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1160 ++++++++++++++++++++----------------- src/App.tsx | 20 +- src/lib/profile.ts | 70 +++ src/pages/ProfilePage.tsx | 191 ++++++ src/pages/SignUpPage.tsx | 78 ++- src/types/todoType.ts | 44 ++ types_db.ts | 39 ++ 7 files changed, 1055 insertions(+), 547 deletions(-) create mode 100644 src/lib/profile.ts create mode 100644 src/pages/ProfilePage.tsx diff --git a/README.md b/README.md index e1527c6..a57cf5a 100644 --- a/README.md +++ b/README.md @@ -1,238 +1,433 @@ -# Supabase Auth - -- Auth : 인증 -- https://supabase.com/dashboard/project/rqckhcqnpwvkjofyetzm/editor/17327?schema=public - -## 1. Auth 메뉴 확인 - -- 왼쪽 아이콘 중 `Authentication` 선택 - -### 1.1 User Table 확인 - -- 회원에 대한 테이블명은 미리 생성이 되어있음 -- `Users` 라는 테이블이 이미 존재함 -- 회원가입을 하게 되면 `Users 테이블에 자동으로 추가`가 됨 - -### 1.2 Sign In / Providers 메뉴 - -- Auth Providers : 회원가입할 수 있는 여러가지 항목을 미리 제공함 -- `Email 항목` 이 활성화 되어 있는지 확인 - -### 1.3 Email 메뉴 확인 - -- SMTP ( Simple Mail Transfer Protocol ) : 인터넷에서 이메일을 보내고 받는 데 사용되는 통신 프로토콜 - - 단순 메일 전송 프로토콜 - - 예 ) http : HyperText Transfer Protocol - - 예) ftp : file Transfer Protocol -- Supabase 에는 이메일 인증을 테스트만 제공함 ( 1시간에 3번만 사용 가능, 그래서 SMTP 서버 구축 필요 ) -- 추후 SMTP 서버 구축 또는 Google Service, `resend.com` 로 무료로 활용 가능 - - resend.com 을 가장 많이 활용함, 혹은 구글/카카오 로그인 제일 많이 활용 -- Confirm signip 탭 : 회원가입 시 전달되는 인증메일 제목, 내용을 작성함 +# Supabase 인증 후 회원 추가 정보 받기 + +- 회원가입 후에 `profiles 테이블` 에 추가 내용 받기 + +## 1. `profiles 테이블` 생성 + +- SQL Editor 를 이용해서 진행함 + +```sql +-- 사용자 프로필 정보를 저장하는 테이블 +-- auth.users 테이블에 데이터가 추가되면 이와 연동하여 별도로 자동 추가 +create table profiles ( + + -- id 컬럼은 pk + -- uuid 는 데이터 타입으로 중복 제거 + -- references auth.users : 참조 테이블로 auth.users 를 참조함 + -- on delete cascade : 사용자 계정을 삭제할 시 자동으로 profiles 도 같이 삭제 됨 + id uuid references auth.users on delete cascade primary key, + + -- 추가 컬럼들 ( 닉네임, 아바타URL 등 ) + nickname text, + -- avatar_url 은 사용자 이미지 + -- supabase 의 storage 에 이미지 업로드 시 해당 이미지 URL : null 값임. ( 있으면 올리고 없으면 안올리고 ) + avatar_url text, + -- created_at : 생성 날짜 + -- timestamp with time zone : 시간대 정보를 포함한 시간 + -- default now() : 기본 값으로 현재 시간을 저장하겠다 + created_at timestamp with time zone default now() +); +``` -### 1.4 URL Configuration ※ 중요 ※ +## 2. 만약, 테이블이 추가, 컬럼 추가, 변경 등이 되었다면 ? -- Site URL : http://localhost:3000 번에서 `http://localhost:5173` 로 변경함 ( 추후 Vercel 주소로 변경 예정 ) -- Redirect URLs : `http://localhost:5173`, `http://localhost:3000` 등으로 입력. ( 이것도 추후 Vercel 주소도 입력 ) +- npm run generate-types 실행 해주기 -- 여기까지 Auth 환경설정 끝 +```bash +npm run generate-types +``` ---- +- 실행 후 생성된 `/types_db.ts` 내용을 우리 type 파일에 추가함 -## 2. Auth 적용하기 +```ts +// newTodoType = todos +export type NewTodoType = { + id: string; + title: string; + completed: boolean; +}; -- /src/lib/supabase.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']; + +// 사용자 정보 +export type Profile = Database['public']['Tables']['profiles']['Row']; +export type ProfileInsert = Database['public']['Tables']['profiles']['Insert']; +export type ProfileUpdate = Database['public']['Tables']['profiles']['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: { + memos: { + Row: { + created_at: string; + id: number; + memo: string | null; + }; + Insert: { + created_at?: string; + id?: number; + memo?: string | null; + }; + Update: { + created_at?: string; + id?: number; + memo?: string | null; + }; + Relationships: []; + }; + profiles: { + Row: { + avatar_url: string | null; + created_at: string | null; + id: string; + nickname: string | null; + }; + Insert: { + avatar_url?: string | null; + created_at?: string | null; + id: string; + nickname?: string | null; + }; + Update: { + avatar_url?: string | null; + created_at?: string | null; + id?: string; + nickname?: string | null; + }; + Relationships: []; + }; + 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; + }; + }; +}; -```ts -import { createClient } from '@supabase/supabase-js'; +type DatabaseWithoutInternals = Omit; -// CRA 의 환경 변수 호출과는 형식이 다름. (meta) -const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; -const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; +type DefaultSchema = DatabaseWithoutInternals[Extract]; -if (!supabaseUrl || !supabaseAnonKey) { - throw new Error('Missing Supabase environment variables'); +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; } - -// 회원 인증 Auth 기능 추가하기 -export const supabase = createClient(supabaseUrl, supabaseAnonKey, { - auth: { - // 웹브라우저에 탭이 열려 있는 동안 로그인 인증 토큰(글자) 자동 갱신 - autoRefreshToken: true, // false 일 경우 자동으로 로그아웃이 됨 - // 사용자 세션 정보를 localStorage 에 저장해서 웹브라우저 새로고침 시에도 로그인 유지 - persistSession: true, - // URL 인증 세션을 파악해서 Auth 로그인 등의 콜백을 처리한다 - detectSessionInUrl: true, + ? (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; ``` -## 3. Auth 인증 정보 관리 ( 전역에서 Session 관리 ) +## 3. 프로필 CRUD 를 위한 파일 구성 -- /src/Contexts/AuthContext.tsx +- `src/lib/profile.ts` 파일 생성 -```tsx +```ts /** - * 주요 기능 - * - 사용자 세션관리 - * - 로그인, 회원가입, 로그아웃 - * - 사용자 인증 정보 상태 변경 감시 - * - 전역 인증 상태를 컴포넌트에 반영 + * 사용자 프로필 관리 ( profiles.ts 에서 관리 ) + * - 프로필 생성 + * - 프로필 정보 조회 + * - 프로필 정보 수정 + * - 프로필 정보 삭제 + * + * 주의 사항 + * - 반드시 사용자 인증 후에만 프로필 생성 */ -import type { Session, User } from '@supabase/supabase-js'; -import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react'; -import { supabase } from '../lib/supabase'; - -// 1. 인증 Context Type -type AuthContextType = { - // 현재 사용자의 세션 정보 ( 로그인 상태, 토큰 ) - session: Session | null; - // 현재 로그인 된 사용자 정보 - user: User | null; - // 회원 가입 함수 - 개발자가 직접 수기 작성 ( 사용자의 이메일, 비밀번호를 받음 ) : 비동기라서 Promise 로 들어옴 - signUp: (email: string, password: string) => Promise<{ error?: string }>; - // 회원 로그인 함수 - 개발자가 직접 수기 작성 ( 사용자의 이메일, 비밀번호를 받음 ) : 비동기라서 Promise 로 들어옴 - signIn: (email: string, password: string) => Promise<{ error?: string }>; - // 회원 로그아웃 - signOut: () => Promise; -}; - -// 2. 인증 Context 생성 ( 인증 기능을 Children들 Component 에서 활용하게 해줌 ) -const AuthContext = createContext(null); - -// 3. 인증 Context Provider -export const AuthProvider: React.FC = ({ children }) => { - // 현재 사용자 세션 - const [session, setSession] = useState(null); - // 현재 로그인한 사용자 정보 - const [user, setUser] = useState(null); - - // 실행이 되자마자 ( 초기 세션 ) 로드 및 인증 상태 변경 감시 ( 새로고침을 하던 뭘 하던 바로 작동되게끔 ) - useEffect(() => { - // 기존 세션이 있는지 확인 - supabase.auth.getSession().then(({ data }) => { - setSession(data.session ? data.session : null); - setUser(data.session?.user ?? null); - }); - // 인증상태 변경 이벤트를 체크함 ( 로그인, 로그아웃 , 토큰 갱신 등의 이벤트 실시간 감시 ) - const { data } = supabase.auth.onAuthStateChange((_event, newSession) => { - setSession(newSession); - setUser(newSession?.user ?? null); - }); - // Component 가 제거 되면, 이벤트 체크 해제함 : cleanUp ( return () => {} << 이렇게 생김 ) - return () => { - // 이벤트 감시 해제 - data.subscription.unsubscribe(); - }; - }, []); - - // 회원 가입 (이메일, 비밀번호) - const signUp: AuthContextType['signUp'] = async (email, password) => { - const { error } = await supabase.auth.signUp({ - email, - password, - options: { - // 회원 가입 후 이메일로 인증 확인 시 리다이렉트 될 URL - emailRedirectTo: `${window.location.origin}/auth/callback`, - }, - }); - if (error) { - return { error: error.message }; - } - // 우리는 이메일 확인을 활성화 시켰음 - // 이메일 확인 후 인증 전까지는 아무것도 넘어오지 않음 - return {}; - }; +import type { ProfileInsert } from '../types/todoType'; +import { supabase } from './supabase'; - // 회원 로그인 (이메일, 비밀번호) - const signIn: AuthContextType['signIn'] = async (email, password) => { - const { error } = await supabase.auth.signInWithPassword({ email, password, options: {} }); +// 사용자 프로필 생성 +const createProfile = async (newUserProfile: ProfileInsert): Promise => { + try { + const { error } = await supabase.from('profiles').insert([{ ...newUserProfile }]); if (error) { - return { error: error.message }; + console.log(`프로필 추가에 실패하였습니다 : ${error.message}`); + return false; } - return {}; - }; - // 회원 로그아웃 - const signOut: AuthContextType['signOut'] = async () => { - await supabase.auth.signOut(); - }; - - return ( - - {children} - - ); -}; - -// const {signUp, signIn, signOut, user, session} = useAuth() -export const useAuth = () => { - const ctx = useContext(AuthContext); - if (!ctx) { - throw new Error('AuthContext 가 없습니다.'); + return true; + } catch (error) { + console.log(`프로필 생성 오류 : ${error}`); + return false; } - return ctx; }; -``` -- App.tsx +// 사용자 프로필 조회 +const getProfile = () => {}; -```tsx -import TodoList from './components/todos/TodoList'; -import TodoWrite from './components/todos/TodoWrite'; -import { AuthProvider } from './contexts/AuthContext'; -import { TodoProvider } from './contexts/TodoContext'; +// 사용자 프로필 수정 +const updateProfile = () => {}; -function App() { - return ( - -
        -

        Todo Service

        - - - - -
        -
        - ); -} +// 사용자 프로필 삭제 +const deleteProfile = () => {}; -export default App; +// 사용자 프로필 이미지 업로드 +const uploadAvatar = () => {}; + +// 내보내기 ( 하나하나 export 넣기 귀찮을 시 ) +export { createProfile, getProfile, updateProfile, deleteProfile, uploadAvatar }; ``` -## 4. 회원 가입 폼 만들기 +## 4. 회원 가입 시 추가 정보 내용 구성 -- /src/pages/SignUpPage.tsx 파일 생성 +- id(uuid), nickname (null도 가능하긴 함), avata_url(null), create_at (자동으로 들어감) +- /src/pages/SignUpPage.tsx 추가 수정 ```tsx import { useState } from 'react'; import { useAuth } from '../contexts/AuthContext'; +import { supabase } from '../lib/supabase'; +import { createProfile } from '../lib/profile'; +import type { ProfileInsert } from '../types/todoType'; function SignUpPage() { const { signUp } = useAuth(); const [email, setEmail] = useState(''); const [pw, setPw] = useState(''); + + // 추가 정보 ( 닉네임 ) + const [nickName, setNickName] = useState(''); const [msg, setMsg] = useState(''); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // 해당 코드 필수 : 웹브라우저 갱신 막아주기 + // 유효성 검사 + if (!email.trim()) { + alert('이메일을 입력하세요.'); + return; + } + + if (!pw.trim()) { + alert('비밀번호를 입력하세요.'); + return; + } + if (pw.length < 6) { + alert('비밀번호는 최소 6자 이상입니다.'); + return; + } + + if (!nickName.trim()) { + alert('닉네임을 입력하세요.'); + return; + } + + // 회원 가입 및 추가 정보 입력하기 + const { error, data } = await supabase.auth.signUp({ + email, + password: pw, + options: { + // 회원 가입 후 이메일로 인증 확인시 리다이렉트 될 URL + emailRedirectTo: `${window.location.origin}/auth/callback`, + }, + }); - // 회원 가입 하기 - const { error } = await signUp(email, pw); if (error) { setMsg(`회원가입 오류 : ${error}`); } else { - setMsg(`이메일이 발송 되었습니다. 이메일을 확인 해주세요.`); + // 회원가입이 성공했으므로 profiles 도 채워줌 + if (data?.user?.id) { + // 프로필을 추가함 + const newUser: ProfileInsert = { id: data.user.id, nickname: nickName }; + const result = await createProfile(newUser); + if (result) { + // 프로필 추가가 성공한 경우 + setMsg(`회원 가입 및 프로필 생성 성공. 이메일을 확인 해주세요.`); + } else { + // 프로필 추가를 실패한 경우 + setMsg(`회원가입은 성공하였으나, 프로필 생성에 실패하였습니다.`); + } + } else { + setMsg(`이메일이 발송 되었습니다. 이메일을 확인 해주세요.`); + } } }; + return (

        Todo Service 회원 가입

        - setEmail(e.target.value)} /> +
        + setEmail(e.target.value)} + placeholder="이메일" + /> +
        +
        {/* */} - setPw(e.target.value)} /> + setPw(e.target.value)} + placeholder="비밀번호" + /> + setNickName(e.target.value)} + placeholder="닉네임" + /> +
        {/* form 안에선 button type 지정해주기 */}
        @@ -245,225 +440,295 @@ function SignUpPage() { export default SignUpPage; ``` -## 5. 로그인 폼 만들기 +## 5. 사용자 프로필 CRUD 기능 추가 -- /src/pages/SignInPage.tsx +- /src/lib/profile.ts 내용 추가 ```tsx -import { useState } from 'react'; -import { useAuth } from '../contexts/AuthContext'; - -function SignInPage() { - const { signIn } = useAuth(); - const [email, setEmail] = useState(''); - const [pw, setPw] = useState(''); - const [msg, setMsg] = useState(''); +/** + * 사용자 프로필 관리 ( profiles.ts 에서 관리 ) + * - 프로필 생성 + * - 프로필 정보 조회 + * - 프로필 정보 수정 + * - 프로필 정보 삭제 + * + * 주의 사항 + * - 반드시 사용자 인증 후에만 프로필 생성 + */ - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); // 해당 코드 필수 : 웹브라우저 갱신 막아주기 +import type { Profile, ProfileInsert, ProfileUpdate } from '../types/todoType'; +import { supabase } from './supabase'; - const { error } = await signIn(email, pw); +// 사용자 프로필 생성 +const createProfile = async (newUserProfile: ProfileInsert): Promise => { + try { + const { error } = await supabase.from('profiles').insert([{ ...newUserProfile }]); if (error) { - setMsg(`로그인 오류 : ${error}`); - } else { - setMsg(`로그인이 성공하였습니다.`); + console.log(`프로필 추가에 실패하였습니다 : ${error.message}`); + return false; } - }; - return ( -
        -

        로그인

        -
        -
        - setEmail(e.target.value)} /> - setPw(e.target.value)} /> - -
        -

        {msg}

        -
        -
        - ); -} - -export default SignInPage; -``` + return true; + } catch (error) { + console.log(`프로필 생성 오류 : ${error}`); + return false; + } +}; -## 6. TodoPage 생성 +// 사용자 프로필 조회 +const getProfile = async (userId: string): Promise => { + try { + const { error, data } = await supabase.from('profiles').select('*').eq('id', userId).single(); + if (error) { + console.log(error.message); + return null; + } + return data; + } catch (error) { + console.log(error); + return null; + } +}; -- 목적 : 인증이 안된 사용자는 할 일 작성 못하게끔 만드려고함. -- /src/pages/TodosPage.tsx 파일 생성 +// 사용자 프로필 수정 +const updateProfile = async (editUserProfile: ProfileUpdate, userId: string): Promise => { + try { + const { error } = await supabase + .from('profiles') + .update({ ...editUserProfile }) + .eq('id', userId); + if (error) { + console.log(error.message); + return false; + } + return true; + } catch (error) { + console.log(error); + return false; + } +}; -```tsx -import TodoList from '../components/todos/TodoList'; -import TodoWrite from '../components/todos/TodoWrite'; -import { TodoProvider } from '../contexts/TodoContext'; +// 사용자 프로필 삭제 +const deleteProfile = async (): Promise => {}; -function TodosPage() { - return ( -
        -

        할 일

        - -
        - -
        -
        - -
        -
        -
        - ); -} +// 사용자 프로필 이미지 업로드 +const uploadAvatar = async (): Promise => {}; -export default TodosPage; +// 내보내기 ( 하나하나 export 넣기 귀찮을 시 ) +export { createProfile, getProfile, updateProfile, deleteProfile, uploadAvatar }; ``` -## 7. 인증 페이지 +## 6. 사용자 프로필 출력 페이지 -- /src/pages/AuthCallback.tsx +- /src/pages/ProfilePage.tsx 파일 생성 ```tsx -import React, { useEffect, useState } from 'react'; - /** - * - 인증 콜백 URL 처리 - * - 사용자에게 인증 진행 상태 안내 - * - 자동 인증 처리 완료 안내 + * 사용자 프로필 페이지 + * - 기본 정보 표시 + * - 정보 수정 + * - 회원 탈퇴 기능 : 반드시 확인을 거치고 진행해야함 */ -function AuthCallback() { - const [msg, setMsg] = useState('인증 처리 중 ...'); - useEffect(() => { - const timer = setTimeout(() => { - setMsg('이메일 인증 완료. 홈으로 이동해주세요'); - }, 1500); - - // 클린업 함수 - return () => { - clearTimeout(timer); - }; - }, []); +import { useEffect, useState } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import { getProfile, updateProfile } from '../lib/profile'; +import type { Profile, ProfileUpdate } from '../types/todoType'; - return ( -
        -

        인증 페이지

        -
        {msg}
        -
        - ); -} +function ProfilePage() { + // 회원 기본 정보 + const { user } = useAuth(); + // 데이터 가져오는 동안의 로딩 + const [loading, setLoading] = useState(true); + // 사용자 프로필 + const [profileData, setProfileData] = useState(null); + // Error 메세지 + const [error, setError] = useState(''); + // 회원 정보 수정 + const [userEdit, setUserEdit] = useState(false); + // 회원 닉네임 보관 + const [nickName, setNickName] = useState(''); + + // 사용자 프로필 정보 가져오기 + const loadProfile = async () => { + if (!user?.id) { + // 사용자의 id 가 없으면 중지 + setError('사용자의 정보를 찾을 수 없습니다.'); + setLoading(false); + return; + } + try { + // 사용자 정보를 가져오기 ( null 일 수도 있음 ) + const tempData = await getProfile(user?.id); + if (!tempData) { + // null 일 경우 + setError('사용자의 프로필 정보를 찾을 수 없습니다.'); + return; + } -export default AuthCallback; -``` + // 사용자 정보가 있을 경우 + setNickName(tempData.nickname || ''); + setProfileData(tempData); + } catch (error) { + console.log(error); + setError('사용자의 프로필 정보 호출 오류'); + } finally { + setLoading(false); + } + }; -## 8. Router 구성하기 ( 메뉴 구성하기 ) + // 프로필 데이터 업데이트 + const saveProfile = async () => { + if (!user) { + return; + } + if (!profileData) { + return; + } -- App.tsx + try { + const tempUpdateData: ProfileUpdate = { nickname: nickName }; + const success = await updateProfile(tempUpdateData, user.id); + if (!success) { + console.log('프로필 업데이트에 실패하였습니다.'); + return; + } -```tsx -import { Link, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; -import { AuthProvider, useAuth } from './contexts/AuthContext'; -import AuthCallbackPage from './pages/AuthCallbackPage'; -import HomePage from './pages/HomePage'; -import SignInPage from './pages/SignInPage'; -import SignUpPage from './pages/SignUpPage'; -import TodosPage from './pages/TodosPage'; + loadProfile(); + } catch (err) { + console.log('프로필 업데이트 오류', err); + } finally { + setUserEdit(false); + } + }; -const TopBar = () => { - const { signOut, user } = useAuth(); - return ( - - ); -}; + ); + } + // error 메세지 출력하기 + if (error) { + return ( +
        +

        프로필

        +
        {error}
        + +
        + ); + } -function App() { return ( - +
        +

        회원 정보

        + {/* 사용자 기본 정보 */} +
        +

        기본 정보

        +
        이메일 : {user?.email}
        +
        + 가입일: {user?.created_at && new Date(user.created_at).toLocaleString()} +
        +
        + {/* 사용자 추가 정보 */} +
        +

        사용자 추가 정보

        +
        아이디 : {profileData?.id}
        + {userEdit ? ( + <> +
        + 닉네임 : + setNickName(e.target.value)} /> +
        +
        + 아바타 : + {profileData?.avatar_url ? ( + + ) : ( + + )} +
        + + ) : ( + <> +
        닉네임 : {profileData?.nickname}
        +
        + 아바타 : + {profileData?.avatar_url ? ( + + ) : ( + + )} +
        + + )} +
        + 아바타 : + {profileData?.avatar_url ? ( + + ) : ( + + )} +
        +
        + 가입일 :{profileData?.created_at && new Date(profileData.created_at).toLocaleString()} +
        +
        + {error && ( +
        + {error} +
        + )}
        -

        Todo Service

        - - - - } /> - } /> - } /> - } /> - } /> - - + {userEdit ? ( + <> + + + + ) : ( + <> + + + + )}
        - +
        ); } -export default App; -``` - -## 9. 인증 ( Auth ) 에 따라서 라우터 처리하기 - -- 인증된 사용자 ( 로그인 사용자 허가 ) 페이지 처리하기 -- /src/components/Protected.tsx 파일 생성 - -```tsx -/** - * 로그인 한 사용자가 접근 할 수 있는 페이지 : - * - 사용자 프로필 페이지 - * - 관리자 대시보드 페이지 - * - 개인 설정 페이지 - * - 구매 내역 페이지 등등 - */ - -import type { PropsWithChildren } from 'react'; -import { useAuth } from './AuthContext'; -import { Navigate } from 'react-router-dom'; - -const Protected: React.FC = ({ children }) => { - const { user } = useAuth(); - // 로그인하지 않은 사용자는 로그인 페이지로 강제 이동, 로그인 한 사용자는 return
        {children}
        ; - if (!user) { - return ; - } - return
        {children}
        ; -}; - -export default Protected; +export default ProfilePage; ``` -## 10. App.tsx 에 Protected 사용하기 +## 7. Router 세팅 - App.tsx ```tsx import { Link, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; -import { AuthProvider, useAuth } from './contexts/AuthContext'; import AuthCallbackPage from './pages/AuthCallbackPage'; import HomePage from './pages/HomePage'; import SignInPage from './pages/SignInPage'; import SignUpPage from './pages/SignUpPage'; import TodosPage from './pages/TodosPage'; import Protected from './contexts/Protected'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; +import ProfilePage from './pages/ProfilePage'; const TopBar = () => { const { signOut, user } = useAuth(); @@ -481,14 +746,9 @@ const TopBar = () => { 할 일 )} - {!user && ( - - 회원가입 - - )} - {!user && ( - - 로그인 + {user && ( + + 내 프로필 )} {user && } @@ -518,6 +778,14 @@ function App() { } /> + + + + } + />
        @@ -527,179 +795,3 @@ function App() { export default App; ``` - -## 11. 새로 고침을 하거나, 직접 주소를 입력 할 경우에도 사용자 정보 유지하기 - -- 유지는 되고 있으나, React 에서 처리 순서가 늦음 -- AuthContext 에 loadding 이라는 처리를 진행해 주고, 활용함 -- AuthContext.tsx - -```tsx -/** - * 주요 기능 - * - 사용자 세션관리 - * - 로그인, 회원가입, 로그아웃 - * - 사용자 인증 정보 상태 변경 감시 - * - 전역 인증 상태를 컴포넌트에 반영 - */ - -import type { Session, User } from '@supabase/supabase-js'; -import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react'; -import { supabase } from '../lib/supabase'; - -// 1. 인증 Context Type -type AuthContextType = { - // 현재 사용자의 세션 정보 ( 로그인 상태, 토큰 ) - session: Session | null; - // 현재 로그인 된 사용자 정보 - user: User | null; - // 회원 가입 함수 - 개발자가 직접 수기 작성 ( 사용자의 이메일, 비밀번호를 받음 ) : 비동기라서 Promise 로 들어옴 - signUp: (email: string, password: string) => Promise<{ error?: string }>; - // 회원 로그인 함수 - 개발자가 직접 수기 작성 ( 사용자의 이메일, 비밀번호를 받음 ) : 비동기라서 Promise 로 들어옴 - signIn: (email: string, password: string) => Promise<{ error?: string }>; - // 회원 로그아웃 - signOut: () => Promise; - // 회원 정보 로딩 상태 - loading: boolean; -}; - -// 2. 인증 Context 생성 ( 인증 기능을 Children들 Component 에서 활용하게 해줌 ) -const AuthContext = createContext(null); - -// 3. 인증 Context Provider -export const AuthProvider: React.FC = ({ children }) => { - // 현재 사용자 세션 - const [session, setSession] = useState(null); - // 현재 로그인한 사용자 정보 - const [user, setUser] = useState(null); - // 로딩 상태 추가 : 초기 실행시 loading 시킴, true - const [loading, setLoading] = useState(true); - - // 실행이 되자마자 ( 초기 세션 ) 로드 및 인증 상태 변경 감시 ( 새로고침을 하던 뭘 하던 바로 작동되게끔 ) - useEffect(() => { - // 세션을 초기에 로딩을 한 후 처리함 - const loadSession = async () => { - try { - setLoading(true); // 로딩중. 해당 코드는 굳이 안적어도 됨 - - const { data } = await supabase.auth.getSession(); - setSession(data.session ? data.session : null); - setUser(data.session?.user ?? null); - } catch (error) { - console.log(error); - } finally { - // finally : 성공해도 실행, 실패해도 실행 ( 과정이 끝나면 무조건 로딩완료함 ) - setLoading(false); - } - }; - loadSession(); - - // // 기존 세션이 있는지 확인 - // supabase.auth.getSession().then(({ data }) => { - // setSession(data.session ? data.session : null); - // setUser(data.session?.user ?? null); - // }); - - // 인증상태 변경 이벤트를 체크함 ( 로그인, 로그아웃 , 토큰 갱신 등의 이벤트 실시간 감시 ) - const { data } = supabase.auth.onAuthStateChange((_event, newSession) => { - setSession(newSession); - setUser(newSession?.user ?? null); - }); - // Component 가 제거 되면, 이벤트 체크 해제함 : cleanUp ( return () => {} << 이렇게 생김 ) - return () => { - // 이벤트 감시 해제 - data.subscription.unsubscribe(); - }; - }, []); - - // 회원 가입 (이메일, 비밀번호) - const signUp: AuthContextType['signUp'] = async (email, password) => { - const { error } = await supabase.auth.signUp({ - email, - password, - options: { - // 회원 가입 후 이메일로 인증 확인 시 리다이렉트 될 URL - emailRedirectTo: `${window.location.origin}/auth/callback`, - }, - }); - if (error) { - return { error: error.message }; - } - // 우리는 이메일 확인을 활성화 시켰음 - // 이메일 확인 후 인증 전까지는 아무것도 넘어오지 않음 - return {}; - }; - - // 회원 로그인 (이메일, 비밀번호) - const signIn: AuthContextType['signIn'] = async (email, password) => { - const { error } = await supabase.auth.signInWithPassword({ email, password, options: {} }); - if (error) { - return { error: error.message }; - } - return {}; - }; - // 회원 로그아웃 - const signOut: AuthContextType['signOut'] = async () => { - await supabase.auth.signOut(); - }; - - const value: AuthContextType = { signUp, signOut, signIn, user, session, loading }; - - return {children}; -}; - -// const {signUp, signIn, signOut, user, session} = useAuth() -export const useAuth = () => { - const ctx = useContext(AuthContext); -}; -``` - -## 12. Protected 에 loading 값 활용하기 - -- Auth 인증 후 `새로고침` 또는 `주소 직접 입력` 시 `인증 상태를 읽기 위한 시간 확보` -- AuthContext.tsx 에서 읽어 들일 때 까지 Loading 을 활성화 함 - -```tsx -/** - * 로그인 한 사용자가 접근 할 수 있는 페이지 : - * - 사용자 프로필 페이지 - * - 관리자 대시보드 페이지 - * - 개인 설정 페이지 - * - 구매 내역 페이지 등등 - */ - -import type { PropsWithChildren } from 'react'; -import { useAuth } from './AuthContext'; -import { Navigate } from 'react-router-dom'; - -const Protected: React.FC = ({ children }) => { - const { user, loading } = useAuth(); - - if (loading) { - // 사용자 정보가 로딩중이라면 ? - return ( -
        - ); - } - - // 로그인하지 않은 사용자는 로그인 페이지로 강제 이동, 로그인 한 사용자는 return
        {children}
        ; - if (!user) { - return ; - } - return
        {children}
        ; -}; - -export default Protected; -``` diff --git a/src/App.tsx b/src/App.tsx index ea20112..430e6ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import SignUpPage from './pages/SignUpPage'; import TodosPage from './pages/TodosPage'; import Protected from './contexts/Protected'; import { AuthProvider, useAuth } from './contexts/AuthContext'; +import ProfilePage from './pages/ProfilePage'; const TopBar = () => { const { signOut, user } = useAuth(); @@ -23,14 +24,9 @@ const TopBar = () => { 할 일 )} - {!user && ( - - 회원가입 - - )} - {!user && ( - - 로그인 + {user && ( + + 내 프로필 )} {user && } @@ -60,6 +56,14 @@ function App() { } /> + + + + } + />
        diff --git a/src/lib/profile.ts b/src/lib/profile.ts new file mode 100644 index 0000000..c49d73f --- /dev/null +++ b/src/lib/profile.ts @@ -0,0 +1,70 @@ +/** + * 사용자 프로필 관리 ( profiles.ts 에서 관리 ) + * - 프로필 생성 + * - 프로필 정보 조회 + * - 프로필 정보 수정 + * - 프로필 정보 삭제 + * + * 주의 사항 + * - 반드시 사용자 인증 후에만 프로필 생성 + */ + +import type { Profile, ProfileInsert, ProfileUpdate } from '../types/todoType'; +import { supabase } from './supabase'; + +// 사용자 프로필 생성 +const createProfile = async (newUserProfile: ProfileInsert): Promise => { + try { + const { error } = await supabase.from('profiles').insert([{ ...newUserProfile }]); + if (error) { + console.log(`프로필 추가에 실패하였습니다 : ${error.message}`); + return false; + } + return true; + } catch (error) { + console.log(`프로필 생성 오류 : ${error}`); + return false; + } +}; + +// 사용자 프로필 조회 +const getProfile = async (userId: string): Promise => { + try { + const { error, data } = await supabase.from('profiles').select('*').eq('id', userId).single(); + if (error) { + console.log(error.message); + return null; + } + return data; + } catch (error) { + console.log(error); + return null; + } +}; + +// 사용자 프로필 수정 +const updateProfile = async (editUserProfile: ProfileUpdate, userId: string): Promise => { + try { + const { error } = await supabase + .from('profiles') + .update({ ...editUserProfile }) + .eq('id', userId); + if (error) { + console.log(error.message); + return false; + } + return true; + } catch (error) { + console.log(error); + return false; + } +}; + +// 사용자 프로필 삭제 +const deleteProfile = async (): Promise => {}; + +// 사용자 프로필 이미지 업로드 +const uploadAvatar = async (): Promise => {}; + +// 내보내기 ( 하나하나 export 넣기 귀찮을 시 ) +export { createProfile, getProfile, updateProfile, deleteProfile, uploadAvatar }; diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..4d29edb --- /dev/null +++ b/src/pages/ProfilePage.tsx @@ -0,0 +1,191 @@ +/** + * 사용자 프로필 페이지 + * - 기본 정보 표시 + * - 정보 수정 + * - 회원 탈퇴 기능 : 반드시 확인을 거치고 진행해야함 + */ + +import { useEffect, useState } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import { getProfile, updateProfile } from '../lib/profile'; +import type { Profile, ProfileUpdate } from '../types/todoType'; + +function ProfilePage() { + // 회원 기본 정보 + const { user } = useAuth(); + // 데이터 가져오는 동안의 로딩 + const [loading, setLoading] = useState(true); + // 사용자 프로필 + const [profileData, setProfileData] = useState(null); + // Error 메세지 + const [error, setError] = useState(''); + // 회원 정보 수정 + const [userEdit, setUserEdit] = useState(false); + // 회원 닉네임 보관 + const [nickName, setNickName] = useState(''); + + // 사용자 프로필 정보 가져오기 + const loadProfile = async () => { + if (!user?.id) { + // 사용자의 id 가 없으면 중지 + setError('사용자의 정보를 찾을 수 없습니다.'); + setLoading(false); + return; + } + try { + // 사용자 정보를 가져오기 ( null 일 수도 있음 ) + const tempData = await getProfile(user?.id); + if (!tempData) { + // null 일 경우 + setError('사용자의 프로필 정보를 찾을 수 없습니다.'); + return; + } + + // 사용자 정보가 있을 경우 + setNickName(tempData.nickname || ''); + setProfileData(tempData); + } catch (error) { + console.log(error); + setError('사용자의 프로필 정보 호출 오류'); + } finally { + setLoading(false); + } + }; + + // 프로필 데이터 업데이트 + const saveProfile = async () => { + if (!user) { + return; + } + if (!profileData) { + return; + } + + try { + const tempUpdateData: ProfileUpdate = { nickname: nickName }; + const success = await updateProfile(tempUpdateData, user.id); + if (!success) { + console.log('프로필 업데이트에 실패하였습니다.'); + return; + } + + loadProfile(); + } catch (err) { + console.log('프로필 업데이트 오류', err); + } finally { + setUserEdit(false); + } + }; + + useEffect(() => { + loadProfile(); + }, []); + + if (loading) { + return ( +
        +

        프로필 로딩중 ...

        +
        + ); + } + // error 메세지 출력하기 + if (error) { + return ( +
        +

        프로필

        +
        {error}
        + +
        + ); + } + + return ( +
        +

        회원 정보

        + {/* 사용자 기본 정보 */} +
        +

        기본 정보

        +
        이메일 : {user?.email}
        +
        + 가입일: {user?.created_at && new Date(user.created_at).toLocaleString()} +
        +
        + {/* 사용자 추가 정보 */} +
        +

        사용자 추가 정보

        +
        아이디 : {profileData?.id}
        + {userEdit ? ( + <> +
        + 닉네임 : + setNickName(e.target.value)} /> +
        +
        + 아바타 : + {profileData?.avatar_url ? ( + + ) : ( + + )} +
        + + ) : ( + <> +
        닉네임 : {profileData?.nickname}
        +
        + 아바타 : + {profileData?.avatar_url ? ( + + ) : ( + + )} +
        + + )} +
        + 아바타 : + {profileData?.avatar_url ? ( + + ) : ( + + )} +
        +
        + 가입일 :{profileData?.created_at && new Date(profileData.created_at).toLocaleString()} +
        +
        + {error && ( +
        + {error} +
        + )} +
        + {userEdit ? ( + <> + + + + ) : ( + <> + + + + )} +
        +
        + ); +} + +export default ProfilePage; diff --git a/src/pages/SignUpPage.tsx b/src/pages/SignUpPage.tsx index 7ef87cc..e0ee06d 100644 --- a/src/pages/SignUpPage.tsx +++ b/src/pages/SignUpPage.tsx @@ -1,32 +1,100 @@ import { useState } from 'react'; import { useAuth } from '../contexts/AuthContext'; +import { supabase } from '../lib/supabase'; +import { createProfile } from '../lib/profile'; +import type { ProfileInsert } from '../types/todoType'; function SignUpPage() { const { signUp } = useAuth(); const [email, setEmail] = useState(''); const [pw, setPw] = useState(''); + + // 추가 정보 ( 닉네임 ) + const [nickName, setNickName] = useState(''); const [msg, setMsg] = useState(''); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // 해당 코드 필수 : 웹브라우저 갱신 막아주기 + // 유효성 검사 + if (!email.trim()) { + alert('이메일을 입력하세요.'); + return; + } + + if (!pw.trim()) { + alert('비밀번호를 입력하세요.'); + return; + } + if (pw.length < 6) { + alert('비밀번호는 최소 6자 이상입니다.'); + return; + } + + if (!nickName.trim()) { + alert('닉네임을 입력하세요.'); + return; + } + + // 회원 가입 및 추가 정보 입력하기 + const { error, data } = await supabase.auth.signUp({ + email, + password: pw, + options: { + // 회원 가입 후 이메일로 인증 확인시 리다이렉트 될 URL + emailRedirectTo: `${window.location.origin}/auth/callback`, + }, + }); - // 회원 가입 하기 - const { error } = await signUp(email, pw); if (error) { setMsg(`회원가입 오류 : ${error}`); } else { - setMsg(`이메일이 발송 되었습니다. 이메일을 확인 해주세요.`); + // 회원가입이 성공했으므로 profiles 도 채워줌 + if (data?.user?.id) { + // 프로필을 추가함 + const newUser: ProfileInsert = { id: data.user.id, nickname: nickName }; + const result = await createProfile(newUser); + if (result) { + // 프로필 추가가 성공한 경우 + setMsg(`회원 가입 및 프로필 생성 성공. 이메일을 확인 해주세요.`); + } else { + // 프로필 추가를 실패한 경우 + setMsg(`회원가입은 성공하였으나, 프로필 생성에 실패하였습니다.`); + } + } else { + setMsg(`이메일이 발송 되었습니다. 이메일을 확인 해주세요.`); + } } }; + return (

        Todo Service 회원 가입

        - setEmail(e.target.value)} /> +
        + setEmail(e.target.value)} + placeholder="이메일" + /> +
        +
        {/* */} - setPw(e.target.value)} /> + setPw(e.target.value)} + placeholder="비밀번호" + /> + setNickName(e.target.value)} + placeholder="닉네임" + /> +
        {/* form 안에선 button type 지정해주기 */}
        diff --git a/src/types/todoType.ts b/src/types/todoType.ts index e955671..9640dff 100644 --- a/src/types/todoType.ts +++ b/src/types/todoType.ts @@ -14,6 +14,11 @@ 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 Profile = Database['public']['Tables']['profiles']['Row']; +export type ProfileInsert = Database['public']['Tables']['profiles']['Insert']; +export type ProfileUpdate = Database['public']['Tables']['profiles']['Update']; + export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; export type Database = { @@ -24,6 +29,45 @@ export type Database = { }; public: { Tables: { + memos: { + Row: { + created_at: string; + id: number; + memo: string | null; + }; + Insert: { + created_at?: string; + id?: number; + memo?: string | null; + }; + Update: { + created_at?: string; + id?: number; + memo?: string | null; + }; + Relationships: []; + }; + profiles: { + Row: { + avatar_url: string | null; + created_at: string | null; + id: string; + nickname: string | null; + }; + Insert: { + avatar_url?: string | null; + created_at?: string | null; + id: string; + nickname?: string | null; + }; + Update: { + avatar_url?: string | null; + created_at?: string | null; + id?: string; + nickname?: string | null; + }; + Relationships: []; + }; todos: { Row: { completed: boolean; diff --git a/types_db.ts b/types_db.ts index 107bff5..1e8a92d 100644 --- a/types_db.ts +++ b/types_db.ts @@ -14,6 +14,45 @@ export type Database = { } public: { Tables: { + memos: { + Row: { + created_at: string + id: number + memo: string | null + } + Insert: { + created_at?: string + id?: number + memo?: string | null + } + Update: { + created_at?: string + id?: number + memo?: string | null + } + Relationships: [] + } + profiles: { + Row: { + avatar_url: string | null + created_at: string | null + id: string + nickname: string | null + } + Insert: { + avatar_url?: string | null + created_at?: string | null + id: string + nickname?: string | null + } + Update: { + avatar_url?: string | null + created_at?: string | null + id?: string + nickname?: string | null + } + Relationships: [] + } todos: { Row: { completed: boolean From 90d2c254d40f18e4f84ae5e58aca63de5b92cdf3 Mon Sep 17 00:00:00 2001 From: suha720 Date: Fri, 5 Sep 2025 12:51:37 +0900 Subject: [PATCH 17/51] =?UTF-8?q?[docs]=20auth=20delete=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1150 ++++++++++++++-------------- src/App.tsx | 43 +- src/components/todos/TodoItem.tsx | 54 +- src/components/todos/TodoList.tsx | 18 +- src/components/todos/TodoWrite.tsx | 15 +- src/contexts/AuthContext.tsx | 52 +- src/pages/AdminPage.tsx | 157 ++++ src/pages/ProfilePage.tsx | 103 ++- src/pages/SignInPage.tsx | 44 +- src/pages/SignUpPage.tsx | 76 +- src/pages/TodosPage.tsx | 1 - src/types/todoType.ts | 43 ++ types_db.ts | 36 + 13 files changed, 1110 insertions(+), 682 deletions(-) create mode 100644 src/pages/AdminPage.tsx diff --git a/README.md b/README.md index a57cf5a..b7a2295 100644 --- a/README.md +++ b/README.md @@ -1,525 +1,591 @@ -# Supabase 인증 후 회원 추가 정보 받기 +# Supabase 회원 탈퇴 -- 회원가입 후에 `profiles 테이블` 에 추가 내용 받기 +- 기본 제공되는 탈퇴 기능 + - `supabase.auth.admin.deleteUser()` + - 관리자 전용 ( 서버에서만 실행됨 ) + - react 는 클라이언트 즉, 웬브라우저 전용이라서 실행 불가 + - 보안상 위험 : 실수로 지울 가능성 + - 복구 불가 +- 탈퇴 기능 + - 사용자 비활성 + - 30일 후 삭제가 일반적으로 진행됨 -## 1. `profiles 테이블` 생성 +## 1. React 에서는 관리자가 수작업으로 삭제 -- SQL Editor 를 이용해서 진행함 +- profiles 및 사용자가 등록한 테이블에서 제거 진행 +- 사용자 삭제 수작업 실행 +- `탈퇴 신청한 사용자 목록을 관리할 테이블`이 필요함 + +## 2. DB 테이블 생성 및 업데이트 진행 + +- 탈퇴 신청 사용자 테이블 ( SQL Editor ) ```sql --- 사용자 프로필 정보를 저장하는 테이블 --- auth.users 테이블에 데이터가 추가되면 이와 연동하여 별도로 자동 추가 -create table profiles ( - - -- id 컬럼은 pk - -- uuid 는 데이터 타입으로 중복 제거 - -- references auth.users : 참조 테이블로 auth.users 를 참조함 - -- on delete cascade : 사용자 계정을 삭제할 시 자동으로 profiles 도 같이 삭제 됨 - id uuid references auth.users on delete cascade primary key, - - -- 추가 컬럼들 ( 닉네임, 아바타URL 등 ) - nickname text, - -- avatar_url 은 사용자 이미지 - -- supabase 의 storage 에 이미지 업로드 시 해당 이미지 URL : null 값임. ( 있으면 올리고 없으면 안올리고 ) - avatar_url text, - -- created_at : 생성 날짜 - -- timestamp with time zone : 시간대 정보를 포함한 시간 - -- default now() : 기본 값으로 현재 시간을 저장하겠다 - created_at timestamp with time zone default now() +-- 탈퇴 신청한 사용자 목록 테이블 +CREATE TABLE account_deletion_requests ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, -- PK, 중복이 되지 않는 ID 생성 ( DEFAULT gen_random_uuid() ) + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, -- auth.users(id) 가 삭제 되면, 같이 삭제해줌 ( 관리자가 수작업으로 삭제하면 같이 삭제됨 ) + user_email TEXT NOT NULL, -- 탈퇴 신청자의 email 을 담아둠 + requested_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), -- 신청한 날짜 + reason TEXT, -- 사유 + -- status : 현재 탈퇴 신청 진행 상태 + -- 기본은 Pending (default값으로) : 처리중 + -- 탈퇴 승인 approved : 승인 + -- 탈퇴 거부 rejected : 거절 + status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), + admin_notes TEXT, -- 관리자가 메세지를 남겨서 승인 / 거절 사유 등을 기록함 + processed_at TIMESTAMP WITH TIME ZONE, -- 요청을 처리한 시간 + processed_by UUID REFERENCES auth.users(id) -- 요청을 처리한 관리자 ID ); ``` -## 2. 만약, 테이블이 추가, 컬럼 추가, 변경 등이 되었다면 ? - -- npm run generate-types 실행 해주기 - -```bash -npm run generate-types +```sql +-- Supabase Dashboard에서 실행 +CREATE TABLE account_deletion_requests ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + user_email TEXT NOT NULL, + requested_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + reason TEXT, + status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), + admin_notes TEXT, + processed_at TIMESTAMP WITH TIME ZONE, + processed_by UUID REFERENCES auth.users(id) +); ``` -- 실행 후 생성된 `/types_db.ts` 내용을 우리 type 파일에 추가함 +## 3. dummy 회원 가입 시키기 -```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 Profile = Database['public']['Tables']['profiles']['Row']; -export type ProfileInsert = Database['public']['Tables']['profiles']['Insert']; -export type ProfileUpdate = Database['public']['Tables']['profiles']['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: { - memos: { - Row: { - created_at: string; - id: number; - memo: string | null; - }; - Insert: { - created_at?: string; - id?: number; - memo?: string | null; - }; - Update: { - created_at?: string; - id?: number; - memo?: string | null; - }; - Relationships: []; - }; - profiles: { - Row: { - avatar_url: string | null; - created_at: string | null; - id: string; - nickname: string | null; - }; - Insert: { - avatar_url?: string | null; - created_at?: string | null; - id: string; - nickname?: string | null; - }; - Update: { - avatar_url?: string | null; - created_at?: string | null; - id?: string; - nickname?: string | null; - }; - Relationships: []; - }; - 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; - }; - }; -}; +- https://tmailor.com/ko/ +- 5명 정도 가입 시켜봄 -type DatabaseWithoutInternals = Omit; +## 4. 관리자와 일반 회원을 구분함 -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; +```tsx +const isAdmin = user?.email === 'lynn9702@naver.com'; // 관리자 이메일 입력 ``` -## 3. 프로필 CRUD 를 위한 파일 구성 - -- `src/lib/profile.ts` 파일 생성 +- App.tsx -```ts -/** - * 사용자 프로필 관리 ( profiles.ts 에서 관리 ) - * - 프로필 생성 - * - 프로필 정보 조회 - * - 프로필 정보 수정 - * - 프로필 정보 삭제 - * - * 주의 사항 - * - 반드시 사용자 인증 후에만 프로필 생성 - */ +```tsx +import { Link, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; +import AuthCallbackPage from './pages/AuthCallbackPage'; +import HomePage from './pages/HomePage'; +import SignInPage from './pages/SignInPage'; +import SignUpPage from './pages/SignUpPage'; +import TodosPage from './pages/TodosPage'; +import Protected from './contexts/Protected'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; +import ProfilePage from './pages/ProfilePage'; +import AdminPage from './pages/AdminPage'; -import type { ProfileInsert } from '../types/todoType'; -import { supabase } from './supabase'; +const TopBar = () => { + const { signOut, user } = useAuth(); + // 관리자인 경우 메뉴 추가로 출력하기 + // isAdmin 에는 boolean 임. ( true / false ) + const isAdmin = user?.email === 'lynn9702@naver.com'; // 관리자 이메일 입력 + return ( + + ); }; -// 사용자 프로필 조회 -const getProfile = () => {}; - -// 사용자 프로필 수정 -const updateProfile = () => {}; - -// 사용자 프로필 삭제 -const deleteProfile = () => {}; - -// 사용자 프로필 이미지 업로드 -const uploadAvatar = () => {}; +function App() { + return ( + +
        +

        Todo Service

        + + + + } /> + } /> + } /> + } /> + {/* Protected 로 감싸주기 */} + + + + } + /> + + + + } + /> + } /> + + +
        +
        + ); +} -// 내보내기 ( 하나하나 export 넣기 귀찮을 시 ) -export { createProfile, getProfile, updateProfile, deleteProfile, uploadAvatar }; +export default App; ``` -## 4. 회원 가입 시 추가 정보 내용 구성 +## 5. 관리자 페이지 생성 및 라우터 세팅 -- id(uuid), nickname (null도 가능하긴 함), avata_url(null), create_at (자동으로 들어감) -- /src/pages/SignUpPage.tsx 추가 수정 +- /src/pages/AdminPage.tsx ```tsx -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useAuth } from '../contexts/AuthContext'; import { supabase } from '../lib/supabase'; -import { createProfile } from '../lib/profile'; -import type { ProfileInsert } from '../types/todoType'; +import type { DeleteRequest, DeleteRequestUpdate } from '../types/TodoType'; -function SignUpPage() { - const { signUp } = useAuth(); - - const [email, setEmail] = useState(''); - const [pw, setPw] = useState(''); +function AdminPage() { + // ts 자리 + const { user } = useAuth(); + // 삭제 요청 DB 목록 관리 + const [deleteRequests, setDeleteRequests] = useState([]); + // 로딩창 + const [loading, setLoading] = useState(true); - // 추가 정보 ( 닉네임 ) - const [nickName, setNickName] = useState(''); - const [msg, setMsg] = useState(''); + // 관리자 확인 + const isAdmin = user?.email === 'tarolong@naver.com'; + useEffect(() => { + console.log(user?.email); + console.log(user?.id); + console.log(user); + }, [user]); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); // 해당 코드 필수 : 웹브라우저 갱신 막아주기 - // 유효성 검사 - if (!email.trim()) { - alert('이메일을 입력하세요.'); - return; + // 컴포넌트가 완료가 되었을 때, isAdmin 을 체크 후 실행 + useEffect(() => { + if (isAdmin) { + // 회원 탈퇴 신청자 목록을 파악 + loadDeleteMember(); } + }, [isAdmin]); - if (!pw.trim()) { - alert('비밀번호를 입력하세요.'); - return; - } - if (pw.length < 6) { - alert('비밀번호는 최소 6자 이상입니다.'); - return; + // 탈퇴 신청자 목록 파악 테이터 요청 + const loadDeleteMember = async (): Promise => { + try { + const { data, error } = await supabase + .from('account_deletion_requests') + .select('*') + .eq('status', 'pending') + .order('requested_at', { ascending: false }); + + if (error) { + console.log(`삭제 목록 요청 에러 : ${error.message}`); + return; + } + + // 삭제 요청 목록 보관 + setDeleteRequests(data || []); + } catch (err) { + console.log('삭제 요청 목록 오류', err); + } finally { + setLoading(false); } + }; + // 탈퇴 승인 + const approveDelete = async (id: string, updateUser: DeleteRequestUpdate): Promise => { + try { + const { error } = await supabase + .from('account_deletion_requests') + .update({ ...updateUser, status: 'approved' }) + .eq('id', id); + if (error) { + console.log(`탈퇴 업데이트 오류 : ${error.message}`); + return; + } - if (!nickName.trim()) { - alert('닉네임을 입력하세요.'); - return; + alert(`사용자 ${id}의 계정이 삭제가 승인되었습니다. \n\n 관리자님 수동으로 삭제하세요.`); + + // 목록 다시 읽기 + loadDeleteMember(); + } catch (err) { + console.log('탈퇴승인 오류 : ', err); } + }; - // 회원 가입 및 추가 정보 입력하기 - const { error, data } = await supabase.auth.signUp({ - email, - password: pw, - options: { - // 회원 가입 후 이메일로 인증 확인시 리다이렉트 될 URL - emailRedirectTo: `${window.location.origin}/auth/callback`, - }, - }); + // 탈퇴 거절 + const rejectDelete = async (id: string, updateUser: DeleteRequestUpdate): Promise => { + try { + const { error } = await supabase + .from('account_deletion_requests') + .update({ ...updateUser, status: 'rejected' }) + .eq('id', id); - if (error) { - setMsg(`회원가입 오류 : ${error}`); - } else { - // 회원가입이 성공했으므로 profiles 도 채워줌 - if (data?.user?.id) { - // 프로필을 추가함 - const newUser: ProfileInsert = { id: data.user.id, nickname: nickName }; - const result = await createProfile(newUser); - if (result) { - // 프로필 추가가 성공한 경우 - setMsg(`회원 가입 및 프로필 생성 성공. 이메일을 확인 해주세요.`); - } else { - // 프로필 추가를 실패한 경우 - setMsg(`회원가입은 성공하였으나, 프로필 생성에 실패하였습니다.`); - } - } else { - setMsg(`이메일이 발송 되었습니다. 이메일을 확인 해주세요.`); + if (error) { + console.log(`탈퇴 업데이트 오류 : ${error.message}`); + return; } + + alert(`사용자 ${id}의 계정이 삭제가 거부되었습니다.`); + + // 목록 다시 읽기 + loadDeleteMember(); + } catch (err) { + console.log('탈퇴거절 오류 : ', err); } }; + // 1. 관리자 아이디가 불일치라면 + if (!isAdmin) { + return ( +
        +

        접근 권한이 없습니다.

        +

        관리자 페이지에 접근할 수 없습니다.

        +
        + ); + } + // 2. 로딩중 이라면 + if (loading) { + return
        로딩중...
        ; + } + + // tsx 자리 return (
        -

        Todo Service 회원 가입

        -
        -
        -
        - setEmail(e.target.value)} - placeholder="이메일" - /> -
        -
        - {/* */} - setPw(e.target.value)} - placeholder="비밀번호" - /> - setNickName(e.target.value)} - placeholder="닉네임" - /> -
        - {/* form 안에선 button type 지정해주기 */} - -
        -

        {msg}

        +

        관리자 페이지

        +
        + {deleteRequests.length === 0 ? ( +

        대기 중인 삭제 요청이 없습니다.

        + ) : ( +
        + {deleteRequests.map(item => ( +
        +
        +

        사용자: {item.user_email}

        + 대기 중 +
        +
        +

        사용자 ID : {item.user_id}

        +

        요청시간 : {item.requested_at}

        +

        사유 : {item.reason}

        +
        +
        + + +
        +
        + ))} +
        + )}
        ); } -export default SignUpPage; +export default AdminPage; +``` + +### 5.2 라우터 적용 + +- App.tsx + +```tsx +import { Link, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; +import AuthCallbackPage from './pages/AuthCallbackPage'; +import HomePage from './pages/HomePage'; +import SignInPage from './pages/SignInPage'; +import SignUpPage from './pages/SignUpPage'; +import TodosPage from './pages/TodosPage'; +import Protected from './contexts/Protected'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; +import ProfilePage from './pages/ProfilePage'; +import AdminPage from './pages/AdminPage'; + +const TopBar = () => { + const { signOut, user } = useAuth(); + // 관리자인 경우 메뉴 추가로 출력하기 + // isAdmin 에는 boolean 임. ( true / false ) + const isAdmin = user?.email === 'lynn9702@naver.com'; // 관리자 이메일 입력 + return ( + + ); +}; + +function App() { + return ( + +
        +

        Todo Service

        + + + + } /> + } /> + } /> + } /> + {/* Protected 로 감싸주기 */} + + + + } + /> + + + + } + /> + + + + } + /> + + +
        +
        + ); +} + +export default App; ``` -## 5. 사용자 프로필 CRUD 기능 추가 +## 6. 회원 탈퇴 기능 -- /src/lib/profile.ts 내용 추가 +### 6.1 AuthContext 기능 업데이트 ```tsx /** - * 사용자 프로필 관리 ( profiles.ts 에서 관리 ) - * - 프로필 생성 - * - 프로필 정보 조회 - * - 프로필 정보 수정 - * - 프로필 정보 삭제 - * - * 주의 사항 - * - 반드시 사용자 인증 후에만 프로필 생성 + * 주요 기능 + * - 사용자 세션관리 + * - 로그인, 회원가입, 로그아웃 + * - 사용자 인증 정보 상태 변경 감시 + * - 전역 인증 상태를 컴포넌트에 반영 */ -import type { Profile, ProfileInsert, ProfileUpdate } from '../types/todoType'; -import { supabase } from './supabase'; +import type { Session, User } from '@supabase/supabase-js'; +import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react'; +import { supabase } from '../lib/supabase'; +import type { DeleteRequestInsert } from '../types/todoType'; + +// 1. 인증 Context Type +type AuthContextType = { + // 현재 사용자의 세션 정보 ( 로그인 상태, 토큰 ) + session: Session | null; + // 현재 로그인 된 사용자 정보 + user: User | null; + // 회원 가입 함수 - 개발자가 직접 수기 작성 ( 사용자의 이메일, 비밀번호를 받음 ) : 비동기라서 Promise 로 들어옴 + signUp: (email: string, password: string) => Promise<{ error?: string }>; + // 회원 로그인 함수 - 개발자가 직접 수기 작성 ( 사용자의 이메일, 비밀번호를 받음 ) : 비동기라서 Promise 로 들어옴 + signIn: (email: string, password: string) => Promise<{ error?: string }>; + // 회원 로그아웃 + signOut: () => Promise; + // 회원 정보 로딩 상태 + loading: boolean; + // 회원 탈퇴 기능 + deleteAccount: () => Promise<{ error?: string; success?: boolean; message?: string }>; +}; + +// 2. 인증 Context 생성 ( 인증 기능을 Children들 Component 에서 활용하게 해줌 ) +const AuthContext = createContext(null); + +// 3. 인증 Context Provider +export const AuthProvider: React.FC = ({ children }) => { + // 현재 사용자 세션 + const [session, setSession] = useState(null); + // 현재 로그인한 사용자 정보 + const [user, setUser] = useState(null); + // 로딩 상태 추가 : 초기 실행시 loading 시킴, true + const [loading, setLoading] = useState(true); + + // 실행이 되자마자 ( 초기 세션 ) 로드 및 인증 상태 변경 감시 ( 새로고침을 하던 뭘 하던 바로 작동되게끔 ) + useEffect(() => { + // 세션을 초기에 로딩을 한 후 처리함 + const loadSession = async () => { + try { + setLoading(true); // 로딩중. 해당 코드는 굳이 안적어도 됨 + + const { data } = await supabase.auth.getSession(); + setSession(data.session ? data.session : null); + setUser(data.session?.user ?? null); + } catch (error) { + console.log(error); + } finally { + // finally : 성공해도 실행, 실패해도 실행 ( 과정이 끝나면 무조건 로딩완료함 ) + setLoading(false); + } + }; + loadSession(); + + // 인증상태 변경 이벤트를 체크함 ( 로그인, 로그아웃 , 토큰 갱신 등의 이벤트 실시간 감시 ) + const { data } = supabase.auth.onAuthStateChange((_event, newSession) => { + setSession(newSession); + setUser(newSession?.user ?? null); + }); + // Component 가 제거 되면, 이벤트 체크 해제함 : cleanUp ( return () => {} << 이렇게 생김 ) + return () => { + // 이벤트 감시 해제 + data.subscription.unsubscribe(); + }; + }, []); -// 사용자 프로필 생성 -const createProfile = async (newUserProfile: ProfileInsert): Promise => { - try { - const { error } = await supabase.from('profiles').insert([{ ...newUserProfile }]); + // 회원 가입 (이메일, 비밀번호) + const signUp: AuthContextType['signUp'] = async (email, password) => { + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + // 회원 가입 후 이메일로 인증 확인 시 리다이렉트 될 URL + emailRedirectTo: `${window.location.origin}/auth/callback`, + }, + }); if (error) { - console.log(`프로필 추가에 실패하였습니다 : ${error.message}`); - return false; + return { error: error.message }; } - return true; - } catch (error) { - console.log(`프로필 생성 오류 : ${error}`); - return false; - } -}; + // 우리는 이메일 확인을 활성화 시켰음 + // 이메일 확인 후 인증 전까지는 아무것도 넘어오지 않음 + return {}; + }; -// 사용자 프로필 조회 -const getProfile = async (userId: string): Promise => { - try { - const { error, data } = await supabase.from('profiles').select('*').eq('id', userId).single(); + // 회원 로그인 (이메일, 비밀번호) + const signIn: AuthContextType['signIn'] = async (email, password) => { + const { error } = await supabase.auth.signInWithPassword({ email, password, options: {} }); if (error) { - console.log(error.message); - return null; + return { error: error.message }; } - return data; - } catch (error) { - console.log(error); - return null; - } -}; + return {}; + }; -// 사용자 프로필 수정 -const updateProfile = async (editUserProfile: ProfileUpdate, userId: string): Promise => { - try { - const { error } = await supabase - .from('profiles') - .update({ ...editUserProfile }) - .eq('id', userId); - if (error) { - console.log(error.message); - return false; + // 회원 로그아웃 + const signOut: AuthContextType['signOut'] = async () => { + await supabase.auth.signOut(); + }; + + // 회원 탈퇴 기능 + const deleteAccount: AuthContextType['deleteAccount'] = async () => { + try { + // 기존에 사용한 데이터들을 먼저 정리한다 + const { error: profileError } = await supabase.from('profiles').delete().eq('id', user?.id); + if (profileError) { + console.log('프로필 삭제 실패', profileError.message); + return { error: '프로필 삭제에 실패했습니다.' }; + } + + // 탈퇴 신청 데이터 추가 + // account_deletion_requests 에 Pending 으로 Insert 함 + const deleteInfo: DeleteRequestInsert = { + user_email: user?.email as string, + user_id: user?.id, + reason: '사용자 요청', + status: 'pending', + }; + const { error: deleteRequestsError } = await supabase + .from('account_deletion_requests') + .insert([{ ...deleteInfo }]); + + if (deleteRequestsError) { + console.log('탈퇴 목록 추가에 실패', deleteRequestsError.message); + return { error: '탈퇴 목록 추가에 실패했습니다.' }; + } + + // 강제 로그아웃 시켜줌 + await signOut(); + + return { + success: true, + message: '계정 삭제가 요청되었습니다. 관리자 승인 후 완전히 삭제됩니다.', + }; + } catch (err) { + console.log('탈퇴 요청 기능 오류 : ', err); + return { error: '계정 탈퇴 처리 중 오류가 발생하였습니다.' }; } - return true; - } catch (error) { - console.log(error); - return false; - } -}; + }; -// 사용자 프로필 삭제 -const deleteProfile = async (): Promise => {}; + const value: AuthContextType = { signUp, signOut, signIn, user, session, loading, deleteAccount }; -// 사용자 프로필 이미지 업로드 -const uploadAvatar = async (): Promise => {}; + return {children}; +}; -// 내보내기 ( 하나하나 export 넣기 귀찮을 시 ) -export { createProfile, getProfile, updateProfile, deleteProfile, uploadAvatar }; +// const {signUp, signIn, signOut, user, session} = useAuth() +export const useAuth = () => { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error('AuthContext 가 없습니다.'); + } + return ctx; +}; ``` -## 6. 사용자 프로필 출력 페이지 +### 6.2 ProfilePage 업데이트 -- /src/pages/ProfilePage.tsx 파일 생성 +- ProfilePage.tsx ```tsx /** @@ -536,7 +602,7 @@ import type { Profile, ProfileUpdate } from '../types/todoType'; function ProfilePage() { // 회원 기본 정보 - const { user } = useAuth(); + const { user, deleteAccount } = useAuth(); // 데이터 가져오는 동안의 로딩 const [loading, setLoading] = useState(true); // 사용자 프로필 @@ -601,13 +667,24 @@ function ProfilePage() { } }; + // 회원탈퇴 + const handleDeleteUser = () => { + const message: string = '계정을 완전히 삭제하시겠습니까? 복구가 불가능 합니다.'; + let isConfirm = false; + isConfirm = confirm(message); + + if (isConfirm) { + deleteAccount(); + } + }; + useEffect(() => { loadProfile(); }, []); if (loading) { return ( -
        +

        프로필 로딩중 ...

        ); @@ -615,54 +692,66 @@ function ProfilePage() { // error 메세지 출력하기 if (error) { return ( -
        -

        프로필

        -
        {error}
        - +
        +

        프로필

        +
        {error}
        +
        ); } return (
        -

        회원 정보

        +

        회원 정보

        {/* 사용자 기본 정보 */} -
        -

        기본 정보

        -
        이메일 : {user?.email}
        +
        +

        기본 정보

        +
        이메일 : {user?.email}
        가입일: {user?.created_at && new Date(user.created_at).toLocaleString()}
        {/* 사용자 추가 정보 */} -
        -

        사용자 추가 정보

        -
        아이디 : {profileData?.id}
        +
        +

        사용자 추가 정보

        +
        아이디 : {profileData?.id}
        {userEdit ? ( <> -
        +
        닉네임 : - setNickName(e.target.value)} /> + setNickName(e.target.value)} + className="ml-2 px-2 py-1 border rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-400" + />
        -
        +
        아바타 : {profileData?.avatar_url ? ( - + ) : ( - + )}
        ) : ( <> -
        닉네임 : {profileData?.nickname}
        -
        +
        닉네임 : {profileData?.nickname}
        +
        아바타 : {profileData?.avatar_url ? ( - + ) : ( )} -
        - 아바타 : - {profileData?.avatar_url ? ( - - ) : ( - - )} -
        -
        - 가입일 :{profileData?.created_at && new Date(profileData.created_at).toLocaleString()} +
        + 가입일 : {profileData?.created_at && new Date(profileData.created_at).toLocaleString()}
        {error && ( @@ -688,23 +769,39 @@ function ProfilePage() { {error}
        )} -
        +
        {userEdit ? ( <> - + ) : ( <> - - + + )}
        @@ -714,84 +811,3 @@ function ProfilePage() { export default ProfilePage; ``` - -## 7. Router 세팅 - -- App.tsx - -```tsx -import { Link, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; -import AuthCallbackPage from './pages/AuthCallbackPage'; -import HomePage from './pages/HomePage'; -import SignInPage from './pages/SignInPage'; -import SignUpPage from './pages/SignUpPage'; -import TodosPage from './pages/TodosPage'; -import Protected from './contexts/Protected'; -import { AuthProvider, useAuth } from './contexts/AuthContext'; -import ProfilePage from './pages/ProfilePage'; - -const TopBar = () => { - const { signOut, user } = useAuth(); - return ( - - ); -}; - -function App() { - return ( - -
        -

        Todo Service

        - - - - } /> - } /> - } /> - } /> - {/* Protected 로 감싸주기 */} - - - - } - /> - - - - } - /> - - -
        -
        - ); -} - -export default App; -``` diff --git a/src/App.tsx b/src/App.tsx index 430e6ad..1de4d55 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,9 +7,13 @@ import TodosPage from './pages/TodosPage'; import Protected from './contexts/Protected'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import ProfilePage from './pages/ProfilePage'; +import AdminPage from './pages/AdminPage'; const TopBar = () => { const { signOut, user } = useAuth(); + // 관리자인 경우 메뉴 추가로 출력하기 + // isAdmin 에는 boolean 임. ( true / false ) + const isAdmin = user?.email === 'lynn9702@naver.com'; // 관리자 이메일 입력 return ( ); @@ -39,7 +55,6 @@ function App() { return (
        -

        Todo Service

        @@ -64,6 +79,14 @@ function App() { } /> + + + + } + />
        diff --git a/src/components/todos/TodoItem.tsx b/src/components/todos/TodoItem.tsx index 1d4172b..fd0fcd3 100644 --- a/src/components/todos/TodoItem.tsx +++ b/src/components/todos/TodoItem.tsx @@ -78,25 +78,57 @@ const TodoItem = ({ todo }: TodoItemProps) => { }; return ( -
      • +
      • {isEdit ? ( - <> +
        handleChangeTitle(e)} onKeyDown={e => handleKeyDown(e)} + className="flex-grow border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-400" /> - - - + + +
        ) : ( - <> - - {todo.title} - - - +
        +
        + + + {todo.title} + +
        +
        + + +
        +
        )}
      • ); diff --git a/src/components/todos/TodoList.tsx b/src/components/todos/TodoList.tsx index 5575c3a..c885db1 100644 --- a/src/components/todos/TodoList.tsx +++ b/src/components/todos/TodoList.tsx @@ -8,12 +8,18 @@ const TodoList = ({}: TodoListProps) => { const { todos } = useTodos(); return ( -
        -

        TodoList

        -
          - {todos.map((item: Todo) => ( - - ))} +
          +

          Todo List

          +
            + {todos.length === 0 ? ( +
          • 할 일이 없습니다.
          • + ) : ( + todos.map((item: Todo) => ( +
          • + +
          • + )) + )}
          ); diff --git a/src/components/todos/TodoWrite.tsx b/src/components/todos/TodoWrite.tsx index 34f837c..12fce4c 100644 --- a/src/components/todos/TodoWrite.tsx +++ b/src/components/todos/TodoWrite.tsx @@ -49,16 +49,23 @@ const TodoWrite = ({}: TodoWriteProps): JSX.Element => { }; return ( -
          -

          할일 작성

          -
          +
          +

          할 일 작성

          +
          handleChange(e)} onKeyDown={e => handleKeyDown(e)} + placeholder="할 일을 입력하세요..." + className="flex-grow border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400" /> - +
          ); diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 756f444..af09321 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -9,6 +9,7 @@ import type { Session, User } from '@supabase/supabase-js'; import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react'; import { supabase } from '../lib/supabase'; +import type { DeleteRequestInsert } from '../types/todoType'; // 1. 인증 Context Type type AuthContextType = { @@ -24,6 +25,8 @@ type AuthContextType = { signOut: () => Promise; // 회원 정보 로딩 상태 loading: boolean; + // 회원 탈퇴 기능 + deleteAccount: () => Promise<{ error?: string; success?: boolean; message?: string }>; }; // 2. 인증 Context 생성 ( 인증 기능을 Children들 Component 에서 활용하게 해줌 ) @@ -57,12 +60,6 @@ export const AuthProvider: React.FC = ({ children }) => { }; loadSession(); - // // 기존 세션이 있는지 확인 - // supabase.auth.getSession().then(({ data }) => { - // setSession(data.session ? data.session : null); - // setUser(data.session?.user ?? null); - // }); - // 인증상태 변경 이벤트를 체크함 ( 로그인, 로그아웃 , 토큰 갱신 등의 이벤트 실시간 감시 ) const { data } = supabase.auth.onAuthStateChange((_event, newSession) => { setSession(newSession); @@ -101,12 +98,53 @@ export const AuthProvider: React.FC = ({ children }) => { } return {}; }; + // 회원 로그아웃 const signOut: AuthContextType['signOut'] = async () => { await supabase.auth.signOut(); }; - const value: AuthContextType = { signUp, signOut, signIn, user, session, loading }; + // 회원 탈퇴 기능 + const deleteAccount: AuthContextType['deleteAccount'] = async () => { + try { + // 기존에 사용한 데이터들을 먼저 정리한다 + const { error: profileError } = await supabase.from('profiles').delete().eq('id', user?.id); + if (profileError) { + console.log('프로필 삭제 실패', profileError.message); + return { error: '프로필 삭제에 실패했습니다.' }; + } + + // 탈퇴 신청 데이터 추가 + // account_deletion_requests 에 Pending 으로 Insert 함 + const deleteInfo: DeleteRequestInsert = { + user_email: user?.email as string, + user_id: user?.id, + reason: '사용자 요청', + status: 'pending', + }; + const { error: deleteRequestsError } = await supabase + .from('account_deletion_requests') + .insert([{ ...deleteInfo }]); + + if (deleteRequestsError) { + console.log('탈퇴 목록 추가에 실패', deleteRequestsError.message); + return { error: '탈퇴 목록 추가에 실패했습니다.' }; + } + + // 강제 로그아웃 시켜줌 + await signOut(); + + return { + success: true, + message: '계정 삭제가 요청되었습니다. 관리자 승인 후 완전히 삭제됩니다.', + }; + } catch (err) { + console.log('탈퇴 요청 기능 오류 : ', err); + return { error: '계정 탈퇴 처리 중 오류가 발생하였습니다.' }; + } + }; + + const value: AuthContextType = { signUp, signOut, signIn, user, session, loading, deleteAccount }; return {children}; }; diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx new file mode 100644 index 0000000..103cc27 --- /dev/null +++ b/src/pages/AdminPage.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from 'react'; +import { supabase } from '../lib/supabase'; +import type { DeleteRequest, DeleteRequestUpdate } from '../types/todoType'; +import { useAuth } from '../contexts/AuthContext'; + +function AdminPage() { + // ts 자리 + const { user } = useAuth(); + // 삭제 요청 DB 목록 관리 + const [deleteRequests, setDeleteRequests] = useState([]); + // 로딩창 + const [loading, setLoading] = useState(true); + + // 관리자 확인 + const isAdmin = user?.email === 'lynn9702@naver.com'; + useEffect(() => { + console.log(user?.email); + console.log(user?.id); + console.log(user); + }, [user]); + + // 컴포넌트가 완료가 되었을 때, isAdmin 을 체크 후 실행 + useEffect(() => { + if (isAdmin) { + // 회원 탈퇴 신청자 목록을 파악 + loadDeleteMember(); + } + }, [isAdmin]); + + // 탈퇴 신청자 목록 파악 테이터 요청 + const loadDeleteMember = async (): Promise => { + try { + const { data, error } = await supabase + .from('account_deletion_requests') + .select('*') + .eq('status', 'pending') + .order('requested_at', { ascending: false }); + + if (error) { + console.log(`삭제 목록 요청 에러 : ${error.message}`); + return; + } + + // 삭제 요청 목록 보관 + setDeleteRequests(data || []); + } catch (err) { + console.log('삭제 요청 목록 오류', err); + } finally { + setLoading(false); + } + }; + + // 탈퇴 승인 + const approveDelete = async (id: string, updateUser: DeleteRequestUpdate): Promise => { + try { + const { error } = await supabase + .from('account_deletion_requests') + .update({ ...updateUser, status: 'approved' }) + .eq('id', id); + if (error) { + console.log(`탈퇴 업데이트 오류 : ${error.message}`); + return; + } + + alert(`사용자 ${id}의 계정이 삭제가 승인되었습니다. \n\n 관리자님 수동으로 삭제하세요.`); + + // 목록 다시 읽기 + loadDeleteMember(); + } catch (err) { + console.log('탈퇴승인 오류 : ', err); + } + }; + + // 탈퇴 거절 + const rejectDelete = async (id: string, updateUser: DeleteRequestUpdate): Promise => { + try { + const { error } = await supabase + .from('account_deletion_requests') + .update({ ...updateUser, status: 'rejected' }) + .eq('id', id); + + if (error) { + console.log(`탈퇴 업데이트 오류 : ${error.message}`); + return; + } + + alert(`사용자 ${id}의 계정이 삭제가 거부되었습니다.`); + + // 목록 다시 읽기 + loadDeleteMember(); + } catch (err) { + console.log('탈퇴거절 오류 : ', err); + } + }; + + // 1. 관리자 아이디가 불일치라면 + if (!isAdmin) { + return ( +
          +

          접근 권한이 없습니다.

          +

          관리자 페이지에 접근할 수 없습니다.

          +
          + ); + } + // 2. 로딩중 이라면 + if (loading) { + return ( +
          +

          로딩중...

          +
          + ); + } + + // tsx 자리 + return ( +
          +

          관리자 페이지

          +
          + {deleteRequests.length === 0 ? ( +

          대기 중인 삭제 요청이 없습니다.

          + ) : ( + deleteRequests.map(item => ( +
          +
          +

          사용자: {item.user_email}

          + + 대기 중 + +
          +
          +

          사용자 ID : {item.user_id}

          +

          요청시간 : {item.requested_at}

          +

          사유 : {item.reason}

          +
          +
          + + +
          +
          + )) + )} +
          +
          + ); +} + +export default AdminPage; diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 4d29edb..ddff413 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -12,7 +12,7 @@ import type { Profile, ProfileUpdate } from '../types/todoType'; function ProfilePage() { // 회원 기본 정보 - const { user } = useAuth(); + const { user, deleteAccount } = useAuth(); // 데이터 가져오는 동안의 로딩 const [loading, setLoading] = useState(true); // 사용자 프로필 @@ -77,13 +77,24 @@ function ProfilePage() { } }; + // 회원탈퇴 + const handleDeleteUser = () => { + const message: string = '계정을 완전히 삭제하시겠습니까? 복구가 불가능 합니다.'; + let isConfirm = false; + isConfirm = confirm(message); + + if (isConfirm) { + deleteAccount(); + } + }; + useEffect(() => { loadProfile(); }, []); if (loading) { return ( -
          +

          프로필 로딩중 ...

          ); @@ -91,54 +102,66 @@ function ProfilePage() { // error 메세지 출력하기 if (error) { return ( -
          -

          프로필

          -
          {error}
          - +
          +

          프로필

          +
          {error}
          +
          ); } return (
          -

          회원 정보

          +

          회원 정보

          {/* 사용자 기본 정보 */} -
          -

          기본 정보

          -
          이메일 : {user?.email}
          +
          +

          기본 정보

          +
          이메일 : {user?.email}
          가입일: {user?.created_at && new Date(user.created_at).toLocaleString()}
          {/* 사용자 추가 정보 */} -
          -

          사용자 추가 정보

          -
          아이디 : {profileData?.id}
          +
          +

          사용자 추가 정보

          +
          아이디 : {profileData?.id}
          {userEdit ? ( <> -
          +
          닉네임 : - setNickName(e.target.value)} /> + setNickName(e.target.value)} + className="ml-2 px-2 py-1 border rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-400" + />
          -
          +
          아바타 : {profileData?.avatar_url ? ( - + ) : ( - + )}
          ) : ( <> -
          닉네임 : {profileData?.nickname}
          -
          +
          닉네임 : {profileData?.nickname}
          +
          아바타 : {profileData?.avatar_url ? ( - + ) : ( )} -
          - 아바타 : - {profileData?.avatar_url ? ( - - ) : ( - - )} -
          -
          - 가입일 :{profileData?.created_at && new Date(profileData.created_at).toLocaleString()} +
          + 가입일 : {profileData?.created_at && new Date(profileData.created_at).toLocaleString()}
          {error && ( @@ -164,23 +179,39 @@ function ProfilePage() { {error}
          )} -
          +
          {userEdit ? ( <> - + ) : ( <> - - + + )}
          diff --git a/src/pages/SignInPage.tsx b/src/pages/SignInPage.tsx index 487f91f..694fbab 100644 --- a/src/pages/SignInPage.tsx +++ b/src/pages/SignInPage.tsx @@ -17,16 +17,42 @@ function SignInPage() { setMsg(`로그인이 성공하였습니다.`); } }; + return ( -
          -

          로그인

          -
          -
          - setEmail(e.target.value)} /> - setPw(e.target.value)} /> - -
          -

          {msg}

          +
          +
          +

          로그인

          +
          +
          + setEmail(e.target.value)} + placeholder="이메일" + className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + /> + setPw(e.target.value)} + placeholder="비밀번호" + className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + /> + +
          +

          + {msg} +

          +
          ); diff --git a/src/pages/SignUpPage.tsx b/src/pages/SignUpPage.tsx index e0ee06d..83d330a 100644 --- a/src/pages/SignUpPage.tsx +++ b/src/pages/SignUpPage.tsx @@ -68,37 +68,51 @@ function SignUpPage() { }; return ( -
          -

          Todo Service 회원 가입

          -
          -
          -
          - setEmail(e.target.value)} - placeholder="이메일" - /> -
          -
          - {/* */} - setPw(e.target.value)} - placeholder="비밀번호" - /> - setNickName(e.target.value)} - placeholder="닉네임" - /> -
          - {/* form 안에선 button type 지정해주기 */} - -
          -

          {msg}

          +
          +
          +

          + Todo Service 회원 가입 +

          +
          +
          + setEmail(e.target.value)} + placeholder="이메일" + className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + /> + {/* */} + setPw(e.target.value)} + placeholder="비밀번호" + className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + /> + setNickName(e.target.value)} + placeholder="닉네임" + className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + /> + {/* form 안에선 button type 지정해주기 */} + +
          +

          + {msg} +

          +
          ); diff --git a/src/pages/TodosPage.tsx b/src/pages/TodosPage.tsx index 336970c..67af8b0 100644 --- a/src/pages/TodosPage.tsx +++ b/src/pages/TodosPage.tsx @@ -5,7 +5,6 @@ import { TodoProvider } from '../contexts/TodoContext'; function TodosPage() { return (
          -

          할 일

          diff --git a/src/types/todoType.ts b/src/types/todoType.ts index 9640dff..9671453 100644 --- a/src/types/todoType.ts +++ b/src/types/todoType.ts @@ -19,6 +19,13 @@ export type Profile = Database['public']['Tables']['profiles']['Row']; export type ProfileInsert = Database['public']['Tables']['profiles']['Insert']; export type ProfileUpdate = Database['public']['Tables']['profiles']['Update']; +// 삭제 신청 목록 정보 +export type DeleteRequest = Database['public']['Tables']['account_deletion_requests']['Row']; +export type DeleteRequestInsert = + Database['public']['Tables']['account_deletion_requests']['Insert']; +export type DeleteRequestUpdate = + Database['public']['Tables']['account_deletion_requests']['Update']; + export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; export type Database = { @@ -29,6 +36,42 @@ export type Database = { }; public: { Tables: { + account_deletion_requests: { + Row: { + admin_notes: string | null; + id: string; + processed_at: string | null; + processed_by: string | null; + reason: string | null; + requested_at: string | null; + status: string | null; + user_email: string; + user_id: string | null; + }; + Insert: { + admin_notes?: string | null; + id?: string; + processed_at?: string | null; + processed_by?: string | null; + reason?: string | null; + requested_at?: string | null; + status?: string | null; + user_email: string; + user_id?: string | null; + }; + Update: { + admin_notes?: string | null; + id?: string; + processed_at?: string | null; + processed_by?: string | null; + reason?: string | null; + requested_at?: string | null; + status?: string | null; + user_email?: string; + user_id?: string | null; + }; + Relationships: []; + }; memos: { Row: { created_at: string; diff --git a/types_db.ts b/types_db.ts index 1e8a92d..ce74434 100644 --- a/types_db.ts +++ b/types_db.ts @@ -14,6 +14,42 @@ export type Database = { } public: { Tables: { + account_deletion_requests: { + Row: { + admin_notes: string | null + id: string + processed_at: string | null + processed_by: string | null + reason: string | null + requested_at: string | null + status: string | null + user_email: string + user_id: string | null + } + Insert: { + admin_notes?: string | null + id?: string + processed_at?: string | null + processed_by?: string | null + reason?: string | null + requested_at?: string | null + status?: string | null + user_email: string + user_id?: string | null + } + Update: { + admin_notes?: string | null + id?: string + processed_at?: string | null + processed_by?: string | null + reason?: string | null + requested_at?: string | null + status?: string | null + user_email?: string + user_id?: string | null + } + Relationships: [] + } memos: { Row: { created_at: string From 382faf0d78ac222e75b86f0689a12c2559d05aca Mon Sep 17 00:00:00 2001 From: suha720 Date: Mon, 8 Sep 2025 10:59:11 +0900 Subject: [PATCH 18/51] =?UTF-8?q?[docs]=20RLS=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1221 +++++++++++----------------- src/components/todos/TodoItem.tsx | 4 +- src/components/todos/TodoWrite.tsx | 2 +- src/pages/TodosPage.tsx | 30 + src/services/todoServices.ts | 24 +- src/types/todoType.ts | 3 + types_db.ts | 3 + 7 files changed, 535 insertions(+), 752 deletions(-) diff --git a/README.md b/README.md index b7a2295..8e3052b 100644 --- a/README.md +++ b/README.md @@ -1,813 +1,542 @@ -# Supabase 회원 탈퇴 +# Supabase 인증 연동 Todo Table -- 기본 제공되는 탈퇴 기능 - - `supabase.auth.admin.deleteUser()` - - 관리자 전용 ( 서버에서만 실행됨 ) - - react 는 클라이언트 즉, 웬브라우저 전용이라서 실행 불가 - - 보안상 위험 : 실수로 지울 가능성 - - 복구 불가 -- 탈퇴 기능 - - 사용자 비활성 - - 30일 후 삭제가 일반적으로 진행됨 +- `Row (행) Level Security ( RLS )` 를 적용 +- 자신만의 todos 조회, 생성, 수정, 삭제 ( CRUD ) +- 보안을 강화함 -## 1. React 에서는 관리자가 수작업으로 삭제 +## 1. RLS 적용 테이블 만들기 -- profiles 및 사용자가 등록한 테이블에서 제거 진행 -- 사용자 삭제 수작업 실행 -- `탈퇴 신청한 사용자 목록을 관리할 테이블`이 필요함 - -## 2. DB 테이블 생성 및 업데이트 진행 - -- 탈퇴 신청 사용자 테이블 ( SQL Editor ) +- 기존 todos 테이블 삭제 +- 추가 컬럼으로 auth.users 의 id (uuid) 를 FK 로 컬럼 설정 +- supabase → sql Editor ```sql --- 탈퇴 신청한 사용자 목록 테이블 -CREATE TABLE account_deletion_requests ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, -- PK, 중복이 되지 않는 ID 생성 ( DEFAULT gen_random_uuid() ) - user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, -- auth.users(id) 가 삭제 되면, 같이 삭제해줌 ( 관리자가 수작업으로 삭제하면 같이 삭제됨 ) - user_email TEXT NOT NULL, -- 탈퇴 신청자의 email 을 담아둠 - requested_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), -- 신청한 날짜 - reason TEXT, -- 사유 - -- status : 현재 탈퇴 신청 진행 상태 - -- 기본은 Pending (default값으로) : 처리중 - -- 탈퇴 승인 approved : 승인 - -- 탈퇴 거부 rejected : 거절 - status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), - admin_notes TEXT, -- 관리자가 메세지를 남겨서 승인 / 거절 사유 등을 기록함 - processed_at TIMESTAMP WITH TIME ZONE, -- 요청을 처리한 시간 - processed_by UUID REFERENCES auth.users(id) -- 요청을 처리한 관리자 ID +-- todos 테이블 구조 +CREATE TABLE todos ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + title VARCHAR NOT NULL, + completed BOOLEAN DEFAULT FALSE NOT NULL, + content TEXT, + updated_at TIMESTAMPTZ DEFAULT now(), + created_at TIMESTAMPTZ DEFAULT now() ); + +-- 인덱스 생성 +CREATE INDEX idx_todos_user_id ON todos(user_id); +CREATE INDEX idx_todos_created_at ON todos(created_at DESC); + +-- RLS 활성화 +ALTER TABLE todos ENABLE ROW LEVEL SECURITY; ``` +- 설명 + ```sql --- Supabase Dashboard에서 실행 -CREATE TABLE account_deletion_requests ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, - user_email TEXT NOT NULL, - requested_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - reason TEXT, - status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), - admin_notes TEXT, - processed_at TIMESTAMP WITH TIME ZONE, - processed_by UUID REFERENCES auth.users(id) +-- todos 테이블 구조 +CREATE TABLE todos ( -- todos 테이블 생성 + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, -- id 컬럼은 PK이고, 기본을 고유(IDENTITY)한 값으로 자동 증가 + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, -- user_id 는 uuid 로서, auth.users 의 id 를 참조하고, 사용자 제거시 같이 삭제함 + title VARCHAR NOT NULL, -- 타이틀 컬럼은 비어있으면 안되며, 글자임 + completed BOOLEAN DEFAULT FALSE NOT NULL, -- completed 는 boolean 으로 기본값이 false 이고, 비어있으면 안됨. + content TEXT, -- content 는 글자임 + updated_at TIMESTAMPTZ DEFAULT now(), -- 업데이트 날짜를 현재 시간으로 세팅함 + created_at TIMESTAMPTZ DEFAULT now() -- 생성 날짜를 현재 시간으로 세팅함 ); -``` -## 3. dummy 회원 가입 시키기 +-- 인덱스 생성 +-- 빠르게 검색 및 정렬을 위해서 순서를 활성화 시킴 + +CREATE INDEX idx_todos_user_id ON todos(user_id); -- user_id 기준으로 정렬함 +CREATE INDEX idx_todos_created_at ON todos(created_at DESC); -- created_at 최신 글 기준으로 정렬 -- https://tmailor.com/ko/ -- 5명 정도 가입 시켜봄 +-- RLS 활성화 +ALTER TABLE todos ENABLE ROW LEVEL SECURITY; -- todos 테이블은 활성화 RLS 로 적용하라 +``` -## 4. 관리자와 일반 회원을 구분함 +## 2. RLS 적용 후 Policy 정책을 진행해 주어야함 -- `실제 관리자 이메일` 을 설정하고 진행 +- todos 의 보안 정책을 작성하여, CRUD가 가능하도록 적용해야함 -```tsx -const isAdmin = user?.email === 'lynn9702@naver.com'; // 관리자 이메일 입력 -``` +```sql +-- 사용자는 자신의 todos만 조회 가능 +CREATE POLICY "Users can view own todos" ON todos + FOR SELECT USING (auth.uid() = user_id); -- App.tsx - -```tsx -import { Link, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; -import AuthCallbackPage from './pages/AuthCallbackPage'; -import HomePage from './pages/HomePage'; -import SignInPage from './pages/SignInPage'; -import SignUpPage from './pages/SignUpPage'; -import TodosPage from './pages/TodosPage'; -import Protected from './contexts/Protected'; -import { AuthProvider, useAuth } from './contexts/AuthContext'; -import ProfilePage from './pages/ProfilePage'; -import AdminPage from './pages/AdminPage'; - -const TopBar = () => { - const { signOut, user } = useAuth(); - // 관리자인 경우 메뉴 추가로 출력하기 - // isAdmin 에는 boolean 임. ( true / false ) - const isAdmin = user?.email === 'lynn9702@naver.com'; // 관리자 이메일 입력 - return ( - - ); -}; +-- 사용자는 자신의 todos만 생성 가능 +CREATE POLICY "Users can insert own todos" ON todos + FOR INSERT WITH CHECK (auth.uid() = user_id); -function App() { - return ( - -
          -

          Todo Service

          - - - - } /> - } /> - } /> - } /> - {/* Protected 로 감싸주기 */} - - - - } - /> - - - - } - /> - } /> - - -
          -
          - ); -} +-- 사용자는 자신의 todos만 수정 가능 +CREATE POLICY "Users can update own todos" ON todos + FOR UPDATE USING (auth.uid() = user_id); -export default App; +-- 사용자는 자신의 todos만 삭제 가능 +CREATE POLICY "Users can delete own todos" ON todos + FOR DELETE USING (auth.uid() = user_id); ``` -## 5. 관리자 페이지 생성 및 라우터 세팅 - -- /src/pages/AdminPage.tsx +## 3. 컬럼이 변하거나, 테이블이 추가 된다면 types 을 생성. -```tsx -import { useEffect, useState } from 'react'; -import { useAuth } from '../contexts/AuthContext'; -import { supabase } from '../lib/supabase'; -import type { DeleteRequest, DeleteRequestUpdate } from '../types/TodoType'; - -function AdminPage() { - // ts 자리 - const { user } = useAuth(); - // 삭제 요청 DB 목록 관리 - const [deleteRequests, setDeleteRequests] = useState([]); - // 로딩창 - const [loading, setLoading] = useState(true); - - // 관리자 확인 - const isAdmin = user?.email === 'tarolong@naver.com'; - useEffect(() => { - console.log(user?.email); - console.log(user?.id); - console.log(user); - }, [user]); - - // 컴포넌트가 완료가 되었을 때, isAdmin 을 체크 후 실행 - useEffect(() => { - if (isAdmin) { - // 회원 탈퇴 신청자 목록을 파악 - loadDeleteMember(); - } - }, [isAdmin]); - - // 탈퇴 신청자 목록 파악 테이터 요청 - const loadDeleteMember = async (): Promise => { - try { - const { data, error } = await supabase - .from('account_deletion_requests') - .select('*') - .eq('status', 'pending') - .order('requested_at', { ascending: false }); - - if (error) { - console.log(`삭제 목록 요청 에러 : ${error.message}`); - return; - } +```bash +npm run generate-types +``` - // 삭제 요청 목록 보관 - setDeleteRequests(data || []); - } catch (err) { - console.log('삭제 요청 목록 오류', err); - } finally { - setLoading(false); - } - }; - // 탈퇴 승인 - const approveDelete = async (id: string, updateUser: DeleteRequestUpdate): Promise => { - try { - const { error } = await supabase - .from('account_deletion_requests') - .update({ ...updateUser, status: 'approved' }) - .eq('id', id); - if (error) { - console.log(`탈퇴 업데이트 오류 : ${error.message}`); - return; - } +- 실행 후, types_db.ts 확인 (복사) +- 복사 후 todoTypes.ts ( 붙여넣기, 지정한 todo타입은 지우지 말것 ) - alert(`사용자 ${id}의 계정이 삭제가 승인되었습니다. \n\n 관리자님 수동으로 삭제하세요.`); +```ts +// newTodoType = todos +export type NewTodoType = { + id: string; + title: string; + completed: boolean; +}; - // 목록 다시 읽기 - loadDeleteMember(); - } catch (err) { - console.log('탈퇴승인 오류 : ', err); - } +// 해당 작업은 수작업 : 테이블명을 바꾸지 않는 이상 하단 타입은 변경되지 않음. (제너레이트란 명령을 주면 됨) +// 해당 작업 이후 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 Profile = Database['public']['Tables']['profiles']['Row']; +export type ProfileInsert = Database['public']['Tables']['profiles']['Insert']; +export type ProfileUpdate = Database['public']['Tables']['profiles']['Update']; + +// 삭제 신청 목록 정보 +export type DeleteRequest = Database['public']['Tables']['account_deletion_requests']['Row']; +export type DeleteRequestInsert = + Database['public']['Tables']['account_deletion_requests']['Insert']; +export type DeleteRequestUpdate = + Database['public']['Tables']['account_deletion_requests']['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: { + account_deletion_requests: { + Row: { + admin_notes: string | null; + id: string; + processed_at: string | null; + processed_by: string | null; + reason: string | null; + requested_at: string | null; + status: string | null; + user_email: string; + user_id: string | null; + }; + Insert: { + admin_notes?: string | null; + id?: string; + processed_at?: string | null; + processed_by?: string | null; + reason?: string | null; + requested_at?: string | null; + status?: string | null; + user_email: string; + user_id?: string | null; + }; + Update: { + admin_notes?: string | null; + id?: string; + processed_at?: string | null; + processed_by?: string | null; + reason?: string | null; + requested_at?: string | null; + status?: string | null; + user_email?: string; + user_id?: string | null; + }; + Relationships: []; + }; + memos: { + Row: { + created_at: string; + id: number; + memo: string | null; + }; + Insert: { + created_at?: string; + id?: number; + memo?: string | null; + }; + Update: { + created_at?: string; + id?: number; + memo?: string | null; + }; + Relationships: []; + }; + profiles: { + Row: { + avatar_url: string | null; + created_at: string | null; + id: string; + nickname: string | null; + }; + Insert: { + avatar_url?: string | null; + created_at?: string | null; + id: string; + nickname?: string | null; + }; + Update: { + avatar_url?: string | null; + created_at?: string | null; + id?: string; + nickname?: string | null; + }; + Relationships: []; + }; + todos: { + Row: { + completed: boolean; + content: string | null; + created_at: string | null; + id: number; + title: string; + updated_at: string | null; + user_id: string; + }; + Insert: { + completed?: boolean; + content?: string | null; + created_at?: string | null; + id?: number; + title: string; + updated_at?: string | null; + user_id: string; + }; + Update: { + completed?: boolean; + content?: string | null; + created_at?: string | null; + id?: number; + title?: string; + updated_at?: string | null; + user_id?: string; + }; + Relationships: []; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + [_ in never]: never; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; }; +}; - // 탈퇴 거절 - const rejectDelete = async (id: string, updateUser: DeleteRequestUpdate): Promise => { - try { - const { error } = await supabase - .from('account_deletion_requests') - .update({ ...updateUser, status: 'rejected' }) - .eq('id', id); - - if (error) { - console.log(`탈퇴 업데이트 오류 : ${error.message}`); - return; - } +type DatabaseWithoutInternals = Omit; - alert(`사용자 ${id}의 계정이 삭제가 거부되었습니다.`); +type DefaultSchema = DatabaseWithoutInternals[Extract]; - // 목록 다시 읽기 - loadDeleteMember(); - } catch (err) { - console.log('탈퇴거절 오류 : ', err); +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; } - }; - - // 1. 관리자 아이디가 불일치라면 - if (!isAdmin) { - return ( -
          -

          접근 권한이 없습니다.

          -

          관리자 페이지에 접근할 수 없습니다.

          -
          - ); + ? 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; } - // 2. 로딩중 이라면 - if (loading) { - return
          로딩중...
          ; + ? 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; } - - // tsx 자리 - return ( -
          -

          관리자 페이지

          -
          - {deleteRequests.length === 0 ? ( -

          대기 중인 삭제 요청이 없습니다.

          - ) : ( -
          - {deleteRequests.map(item => ( -
          -
          -

          사용자: {item.user_email}

          - 대기 중 -
          -
          -

          사용자 ID : {item.user_id}

          -

          요청시간 : {item.requested_at}

          -

          사유 : {item.reason}

          -
          -
          - - -
          -
          - ))} -
          - )} -
          -
          - ); + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; } - -export default AdminPage; -``` - -### 5.2 라우터 적용 - -- App.tsx - -```tsx -import { Link, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; -import AuthCallbackPage from './pages/AuthCallbackPage'; -import HomePage from './pages/HomePage'; -import SignInPage from './pages/SignInPage'; -import SignUpPage from './pages/SignUpPage'; -import TodosPage from './pages/TodosPage'; -import Protected from './contexts/Protected'; -import { AuthProvider, useAuth } from './contexts/AuthContext'; -import ProfilePage from './pages/ProfilePage'; -import AdminPage from './pages/AdminPage'; - -const TopBar = () => { - const { signOut, user } = useAuth(); - // 관리자인 경우 메뉴 추가로 출력하기 - // isAdmin 에는 boolean 임. ( true / false ) - const isAdmin = user?.email === 'lynn9702@naver.com'; // 관리자 이메일 입력 - return ( - - ); -}; - -function App() { - return ( - -
          -

          Todo Service

          - - - - } /> - } /> - } /> - } /> - {/* Protected 로 감싸주기 */} - - - - } - /> - - - - } - /> - - - - } - /> - - -
          -
          - ); + ? 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; } - -export default App; + ? 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; ``` -## 6. 회원 탈퇴 기능 +## 4. todoService.ts 일부 타입 수정 -### 6.1 AuthContext 기능 업데이트 +- Create 부분 -```tsx -/** - * 주요 기능 - * - 사용자 세션관리 - * - 로그인, 회원가입, 로그아웃 - * - 사용자 인증 정보 상태 변경 감시 - * - 전역 인증 상태를 컴포넌트에 반영 - */ +```ts +// Todo 생성 +// 로그인을 하고 나면 실제로 user_id 가 이미 파악이 됨 +// TodoInsert 에서 user_id : 값 을 생략하는 타입을 생성 +// 타입스크립트에서 Omit 을 이용하면, 특정 키를 제거할 수 있음. -import type { Session, User } from '@supabase/supabase-js'; -import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react'; -import { supabase } from '../lib/supabase'; -import type { DeleteRequestInsert } from '../types/todoType'; - -// 1. 인증 Context Type -type AuthContextType = { - // 현재 사용자의 세션 정보 ( 로그인 상태, 토큰 ) - session: Session | null; - // 현재 로그인 된 사용자 정보 - user: User | null; - // 회원 가입 함수 - 개발자가 직접 수기 작성 ( 사용자의 이메일, 비밀번호를 받음 ) : 비동기라서 Promise 로 들어옴 - signUp: (email: string, password: string) => Promise<{ error?: string }>; - // 회원 로그인 함수 - 개발자가 직접 수기 작성 ( 사용자의 이메일, 비밀번호를 받음 ) : 비동기라서 Promise 로 들어옴 - signIn: (email: string, password: string) => Promise<{ error?: string }>; - // 회원 로그아웃 - signOut: () => Promise; - // 회원 정보 로딩 상태 - loading: boolean; - // 회원 탈퇴 기능 - deleteAccount: () => Promise<{ error?: string; success?: boolean; message?: string }>; -}; +export const createTodo = async (newTodo: Omit): Promise => { + try { + // 현재 로그인 한 사용자 정보 가져오기 + const { + data: { user }, + } = await supabase.auth.getUser(); -// 2. 인증 Context 생성 ( 인증 기능을 Children들 Component 에서 활용하게 해줌 ) -const AuthContext = createContext(null); - -// 3. 인증 Context Provider -export const AuthProvider: React.FC = ({ children }) => { - // 현재 사용자 세션 - const [session, setSession] = useState(null); - // 현재 로그인한 사용자 정보 - const [user, setUser] = useState(null); - // 로딩 상태 추가 : 초기 실행시 loading 시킴, true - const [loading, setLoading] = useState(true); - - // 실행이 되자마자 ( 초기 세션 ) 로드 및 인증 상태 변경 감시 ( 새로고침을 하던 뭘 하던 바로 작동되게끔 ) - useEffect(() => { - // 세션을 초기에 로딩을 한 후 처리함 - const loadSession = async () => { - try { - setLoading(true); // 로딩중. 해당 코드는 굳이 안적어도 됨 - - const { data } = await supabase.auth.getSession(); - setSession(data.session ? data.session : null); - setUser(data.session?.user ?? null); - } catch (error) { - console.log(error); - } finally { - // finally : 성공해도 실행, 실패해도 실행 ( 과정이 끝나면 무조건 로딩완료함 ) - setLoading(false); - } - }; - loadSession(); - - // 인증상태 변경 이벤트를 체크함 ( 로그인, 로그아웃 , 토큰 갱신 등의 이벤트 실시간 감시 ) - const { data } = supabase.auth.onAuthStateChange((_event, newSession) => { - setSession(newSession); - setUser(newSession?.user ?? null); - }); - // Component 가 제거 되면, 이벤트 체크 해제함 : cleanUp ( return () => {} << 이렇게 생김 ) - return () => { - // 이벤트 감시 해제 - data.subscription.unsubscribe(); - }; - }, []); - - // 회원 가입 (이메일, 비밀번호) - const signUp: AuthContextType['signUp'] = async (email, password) => { - const { error } = await supabase.auth.signUp({ - email, - password, - options: { - // 회원 가입 후 이메일로 인증 확인 시 리다이렉트 될 URL - emailRedirectTo: `${window.location.origin}/auth/callback`, - }, - }); - if (error) { - return { error: error.message }; + if (!user) { + throw new Error('로그인이 필요합니다.'); } - // 우리는 이메일 확인을 활성화 시켰음 - // 이메일 확인 후 인증 전까지는 아무것도 넘어오지 않음 - return {}; - }; - // 회원 로그인 (이메일, 비밀번호) - const signIn: AuthContextType['signIn'] = async (email, password) => { - const { error } = await supabase.auth.signInWithPassword({ email, password, options: {} }); + const { data, error } = await supabase + .from('todos') + .insert([{ ...newTodo, completed: false, user_id: user.id }]) + .select() + .single(); if (error) { - return { error: error.message }; + throw new Error(`createTodo 오류 : ${error.message}`); } - return {}; - }; + return data; + } catch (error) { + console.log(error); + return null; + } +}; +``` - // 회원 로그아웃 - const signOut: AuthContextType['signOut'] = async () => { - await supabase.auth.signOut(); - }; +- Update 부분 + +```ts +// Todo 수정 +// 로그인을 하고 나면 실제로 user_id 가 이미 파악이 됨 +// TodoUpdate 에서 user_id : 값 을 생략하는 타입을 생성 +// 타입스크립트에서 Omit 을 이용하면, 특정 키를 제거할 수 있음. +export const updateTodo = async ( + id: number, + editTitle: Omit, +): Promise => { + try { + const { data, error } = await supabase + .from('todos') + .update({ ...editTitle, updated_at: new Date().toISOString() }) + .eq('id', id) + .select() + .single(); - // 회원 탈퇴 기능 - const deleteAccount: AuthContextType['deleteAccount'] = async () => { - try { - // 기존에 사용한 데이터들을 먼저 정리한다 - const { error: profileError } = await supabase.from('profiles').delete().eq('id', user?.id); - if (profileError) { - console.log('프로필 삭제 실패', profileError.message); - return { error: '프로필 삭제에 실패했습니다.' }; - } + if (error) { + throw new Error(`updateTodo 오류 : ${error.message}`); + } - // 탈퇴 신청 데이터 추가 - // account_deletion_requests 에 Pending 으로 Insert 함 - const deleteInfo: DeleteRequestInsert = { - user_email: user?.email as string, - user_id: user?.id, - reason: '사용자 요청', - status: 'pending', - }; - const { error: deleteRequestsError } = await supabase - .from('account_deletion_requests') - .insert([{ ...deleteInfo }]); + return data; + } catch (error) { + console.log(error); + return null; + } +}; +``` - if (deleteRequestsError) { - console.log('탈퇴 목록 추가에 실패', deleteRequestsError.message); - return { error: '탈퇴 목록 추가에 실패했습니다.' }; - } +## 5. 알아두기 - // 강제 로그아웃 시켜줌 - await signOut(); +- RLS 를 적용시 자동으로 필터링이 됨 - return { - success: true, - message: '계정 삭제가 요청되었습니다. 관리자 승인 후 완전히 삭제됩니다.', - }; - } catch (err) { - console.log('탈퇴 요청 기능 오류 : ', err); - return { error: '계정 탈퇴 처리 중 오류가 발생하였습니다.' }; - } - }; +```ts +const { data } = await supabase.from('todos').select('*'); +// RLS 가 적용이 되었으므로, 자동 필터링이 적용됨 +// 실제 실행은 아래 구문으로 실행됨 +// SELECT * FROM todos WHERE auth.uid() = user_id +``` - const value: AuthContextType = { signUp, signOut, signIn, user, session, loading, deleteAccount }; +## 6. 최종 todoService.ts 코드 - return {children}; -}; +```ts +import { supabase } from '../lib/supabase'; +import type { Todo, TodoInsert, TodoUpdate } from '../types/todoType'; -// const {signUp, signIn, signOut, user, session} = useAuth() -export const useAuth = () => { - const ctx = useContext(AuthContext); - if (!ctx) { - throw new Error('AuthContext 가 없습니다.'); +// 이것이 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 ctx; + return data || []; }; -``` - -### 6.2 ProfilePage 업데이트 - -- ProfilePage.tsx - -```tsx -/** - * 사용자 프로필 페이지 - * - 기본 정보 표시 - * - 정보 수정 - * - 회원 탈퇴 기능 : 반드시 확인을 거치고 진행해야함 - */ - -import { useEffect, useState } from 'react'; -import { useAuth } from '../contexts/AuthContext'; -import { getProfile, updateProfile } from '../lib/profile'; -import type { Profile, ProfileUpdate } from '../types/todoType'; - -function ProfilePage() { - // 회원 기본 정보 - const { user, deleteAccount } = useAuth(); - // 데이터 가져오는 동안의 로딩 - const [loading, setLoading] = useState(true); - // 사용자 프로필 - const [profileData, setProfileData] = useState(null); - // Error 메세지 - const [error, setError] = useState(''); - // 회원 정보 수정 - const [userEdit, setUserEdit] = useState(false); - // 회원 닉네임 보관 - const [nickName, setNickName] = useState(''); - - // 사용자 프로필 정보 가져오기 - const loadProfile = async () => { - if (!user?.id) { - // 사용자의 id 가 없으면 중지 - setError('사용자의 정보를 찾을 수 없습니다.'); - setLoading(false); - return; - } - try { - // 사용자 정보를 가져오기 ( null 일 수도 있음 ) - const tempData = await getProfile(user?.id); - if (!tempData) { - // null 일 경우 - setError('사용자의 프로필 정보를 찾을 수 없습니다.'); - return; - } - - // 사용자 정보가 있을 경우 - setNickName(tempData.nickname || ''); - setProfileData(tempData); - } catch (error) { - console.log(error); - setError('사용자의 프로필 정보 호출 오류'); - } finally { - setLoading(false); - } - }; +// Todo 생성 +// 로그인을 하고 나면 실제로 user_id 가 이미 파악이 됨 +// TodoInsert 에서 user_id : 값을 생략하는 타입을 생성 +// 타입스크립트에서 Omit을 이용하면, 특정 키를 제거 할 수 있음. +export const createTodo = async (newTodo: Omit): Promise => { + try { + // 현재 로그인 한 사용자 정보 가져오기 + const { + data: { user }, + } = await supabase.auth.getUser(); - // 프로필 데이터 업데이트 - const saveProfile = async () => { if (!user) { - return; - } - if (!profileData) { - return; + throw new Error('로그인이 필요합니다.'); } - try { - const tempUpdateData: ProfileUpdate = { nickname: nickName }; - const success = await updateProfile(tempUpdateData, user.id); - if (!success) { - console.log('프로필 업데이트에 실패하였습니다.'); - return; - } - - loadProfile(); - } catch (err) { - console.log('프로필 업데이트 오류', err); - } finally { - setUserEdit(false); + const { data, error } = await supabase + .from('todos') + .insert([{ ...newTodo, completed: false, user_id: user.id }]) + .select() + .single(); + if (error) { + throw new Error(`createTodo 오류 : ${error.message}`); } - }; - - // 회원탈퇴 - const handleDeleteUser = () => { - const message: string = '계정을 완전히 삭제하시겠습니까? 복구가 불가능 합니다.'; - let isConfirm = false; - isConfirm = confirm(message); + return data; + } catch (error) { + console.log(error); + return null; + } +}; +// Todo 수정 +// 로그인을 하고 나면 실제로 user_id 가 이미 파악이 됨 +// TodoInsert 에서 user_id : 값을 생략하는 타입을 생성 +// 타입스크립트에서 Omit을 이용하면, 특정 키를 제거 할 수 있음. +export const updateTodo = async ( + id: number, + editTitle: Omit, +): 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 (isConfirm) { - deleteAccount(); + if (error) { + throw new Error(`updateTodo 오류 : ${error.message}`); } - }; - - useEffect(() => { - loadProfile(); - }, []); - if (loading) { - return ( -
          -

          프로필 로딩중 ...

          -
          - ); + return data; + } catch (error) { + console.log(error); + return null; } - // error 메세지 출력하기 - if (error) { - return ( -
          -

          프로필

          -
          {error}
          - -
          - ); +}; +// 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); } +}; - return ( -
          -

          회원 정보

          - {/* 사용자 기본 정보 */} -
          -

          기본 정보

          -
          이메일 : {user?.email}
          -
          - 가입일: {user?.created_at && new Date(user.created_at).toLocaleString()} -
          -
          - {/* 사용자 추가 정보 */} -
          -

          사용자 추가 정보

          -
          아이디 : {profileData?.id}
          - {userEdit ? ( - <> -
          - 닉네임 : - setNickName(e.target.value)} - className="ml-2 px-2 py-1 border rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-400" - /> -
          -
          - 아바타 : - {profileData?.avatar_url ? ( - - ) : ( - - )} -
          - - ) : ( - <> -
          닉네임 : {profileData?.nickname}
          -
          - 아바타 : - {profileData?.avatar_url ? ( - - ) : ( - - )} -
          - - )} -
          - 가입일 : {profileData?.created_at && new Date(profileData.created_at).toLocaleString()} -
          -
          - {error && ( -
          - {error} -
          - )} -
          - {userEdit ? ( - <> - - - - ) : ( - <> - - - - )} -
          -
          - ); -} +// Complited 토글 = 어차피 toggle도 업데이트기 때문에 굳이 만들지 않아도 되지만 수업상 만듦 -export default ProfilePage; +export const toggleTodo = async (id: number, completed: boolean): Promise => { + return updateTodo(id, { completed }); +}; ``` diff --git a/src/components/todos/TodoItem.tsx b/src/components/todos/TodoItem.tsx index fd0fcd3..32dd15a 100644 --- a/src/components/todos/TodoItem.tsx +++ b/src/components/todos/TodoItem.tsx @@ -78,7 +78,7 @@ const TodoItem = ({ todo }: TodoItemProps) => { }; return ( -
        • +
          {isEdit ? (
          {
          )} -
        • +
          ); }; diff --git a/src/components/todos/TodoWrite.tsx b/src/components/todos/TodoWrite.tsx index 12fce4c..b1f95cb 100644 --- a/src/components/todos/TodoWrite.tsx +++ b/src/components/todos/TodoWrite.tsx @@ -30,7 +30,7 @@ const TodoWrite = ({}: TodoWriteProps): JSX.Element => { } try { - const newTodo: TodoInsert = { title, content }; + const newTodo = { title, content }; // Supabase 에 데이터를 Insert 함 // Insert 결과 const result = await createTodo(newTodo); diff --git a/src/pages/TodosPage.tsx b/src/pages/TodosPage.tsx index 67af8b0..747feca 100644 --- a/src/pages/TodosPage.tsx +++ b/src/pages/TodosPage.tsx @@ -1,10 +1,40 @@ +import { useEffect, useState } from 'react'; import TodoList from '../components/todos/TodoList'; import TodoWrite from '../components/todos/TodoWrite'; import { TodoProvider } from '../contexts/TodoContext'; +import type { Profile } from '../types/todoType'; +import { useAuth } from '../contexts/AuthContext'; +import { getProfile } from '../lib/profile'; function TodosPage() { + const { user } = useAuth(); + const [profile, setProfile] = useState(null); + + // 프로필 가져오기 + const loadProfile = async () => { + try { + if (user?.id) { + const userProfile = await getProfile(user.id); + // 방어 코드 (탈퇴한 회원이 작성하지 못하게끔) + if (!userProfile) { + alert('탈퇴한 회원입니다. 관리자에게 문의하세요.'); + } + setProfile(userProfile); + } + } catch (error) { + console.log('프로필 가져오기 ERROR : ', error); + } + }; + + useEffect(() => { + loadProfile(); + }, []); + return (
          +

          + {profile?.nickname} 님의 할 일 +

          diff --git a/src/services/todoServices.ts b/src/services/todoServices.ts index 581151c..84407aa 100644 --- a/src/services/todoServices.ts +++ b/src/services/todoServices.ts @@ -16,11 +16,23 @@ export const getTodos = async (): Promise => { return data || []; }; // Todo 생성 -export const createTodo = async (newTodo: TodoInsert): Promise => { +// 로그인을 하고 나면 실제로 user_id 가 이미 파악이 됨 +// TodoInsert 에서 user_id : 값을 생략하는 타입을 생성 +// 타입스크립트에서 Omit을 이용하면, 특정 키를 제거 할 수 있음. +export const createTodo = async (newTodo: Omit): Promise => { try { + // 현재 로그인 한 사용자 정보 가져오기 + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + throw new Error('로그인이 필요합니다.'); + } + const { data, error } = await supabase .from('todos') - .insert([{ ...newTodo, completed: false }]) + .insert([{ ...newTodo, completed: false, user_id: user.id }]) .select() .single(); if (error) { @@ -33,7 +45,13 @@ export const createTodo = async (newTodo: TodoInsert): Promise => { } }; // Todo 수정 -export const updateTodo = async (id: number, editTitle: TodoUpdate): Promise => { +// 로그인을 하고 나면 실제로 user_id 가 이미 파악이 됨 +// TodoInsert 에서 user_id : 값을 생략하는 타입을 생성 +// 타입스크립트에서 Omit을 이용하면, 특정 키를 제거 할 수 있음. +export const updateTodo = async ( + id: number, + editTitle: Omit, +): Promise => { try { // 업데이트 구문 : const { data, error } = await supabase ~ .select(); const { data, error } = await supabase diff --git a/src/types/todoType.ts b/src/types/todoType.ts index 9671453..068fea3 100644 --- a/src/types/todoType.ts +++ b/src/types/todoType.ts @@ -119,6 +119,7 @@ export type Database = { id: number; title: string; updated_at: string | null; + user_id: string; }; Insert: { completed?: boolean; @@ -127,6 +128,7 @@ export type Database = { id?: number; title: string; updated_at?: string | null; + user_id: string; }; Update: { completed?: boolean; @@ -135,6 +137,7 @@ export type Database = { id?: number; title?: string; updated_at?: string | null; + user_id?: string; }; Relationships: []; }; diff --git a/types_db.ts b/types_db.ts index ce74434..cfaf3bb 100644 --- a/types_db.ts +++ b/types_db.ts @@ -97,6 +97,7 @@ export type Database = { id: number title: string updated_at: string | null + user_id: string } Insert: { completed?: boolean @@ -105,6 +106,7 @@ export type Database = { id?: number title: string updated_at?: string | null + user_id: string } Update: { completed?: boolean @@ -113,6 +115,7 @@ export type Database = { id?: number title?: string updated_at?: string | null + user_id?: string } Relationships: [] } From b1f5280f4e674371e06a274ef4a22a4c53d36c23 Mon Sep 17 00:00:00 2001 From: suha720 Date: Mon, 8 Sep 2025 15:11:20 +0900 Subject: [PATCH 19/51] =?UTF-8?q?[docs]=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=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 | 552 ++------------------------------------ src/lib/profile.ts | 84 +++++- src/pages/ProfilePage.tsx | 178 ++++++++++-- 3 files changed, 263 insertions(+), 551 deletions(-) diff --git a/README.md b/README.md index 8e3052b..cf50779 100644 --- a/README.md +++ b/README.md @@ -1,542 +1,34 @@ -# Supabase 인증 연동 Todo Table +# Supabase Storage (Supabase 저장소) -- `Row (행) Level Security ( RLS )` 를 적용 -- 자신만의 todos 조회, 생성, 수정, 삭제 ( CRUD ) -- 보안을 강화함 +## 1. 저장소 생성 -## 1. RLS 적용 테이블 만들기 +- project → storage 메뉴 선택 +- new bucket 생성 → public, 5MB, image/\* 입력 +- create bucket 버튼 선택 -- 기존 todos 테이블 삭제 -- 추가 컬럼으로 auth.users 의 id (uuid) 를 FK 로 컬럼 설정 -- supabase → sql Editor +## 2. 권한 설정 -```sql --- todos 테이블 구조 -CREATE TABLE todos ( - id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - title VARCHAR NOT NULL, - completed BOOLEAN DEFAULT FALSE NOT NULL, - content TEXT, - updated_at TIMESTAMPTZ DEFAULT now(), - created_at TIMESTAMPTZ DEFAULT now() -); - --- 인덱스 생성 -CREATE INDEX idx_todos_user_id ON todos(user_id); -CREATE INDEX idx_todos_created_at ON todos(created_at DESC); - --- RLS 활성화 -ALTER TABLE todos ENABLE ROW LEVEL SECURITY; -``` +- sql Editor 에서 작성함 -- 설명 +### 2.1 bucket storage Policy 설정 ```sql --- todos 테이블 구조 -CREATE TABLE todos ( -- todos 테이블 생성 - id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, -- id 컬럼은 PK이고, 기본을 고유(IDENTITY)한 값으로 자동 증가 - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, -- user_id 는 uuid 로서, auth.users 의 id 를 참조하고, 사용자 제거시 같이 삭제함 - title VARCHAR NOT NULL, -- 타이틀 컬럼은 비어있으면 안되며, 글자임 - completed BOOLEAN DEFAULT FALSE NOT NULL, -- completed 는 boolean 으로 기본값이 false 이고, 비어있으면 안됨. - content TEXT, -- content 는 글자임 - updated_at TIMESTAMPTZ DEFAULT now(), -- 업데이트 날짜를 현재 시간으로 세팅함 - created_at TIMESTAMPTZ DEFAULT now() -- 생성 날짜를 현재 시간으로 세팅함 -); - --- 인덱스 생성 --- 빠르게 검색 및 정렬을 위해서 순서를 활성화 시킴 +-- 버킷 목록 조회 허용 +CREATE POLICY "Allow bucket listing" ON storage.buckets FOR SELECT USING (true); -CREATE INDEX idx_todos_user_id ON todos(user_id); -- user_id 기준으로 정렬함 -CREATE INDEX idx_todos_created_at ON todos(created_at DESC); -- created_at 최신 글 기준으로 정렬 - --- RLS 활성화 -ALTER TABLE todos ENABLE ROW LEVEL SECURITY; -- todos 테이블은 활성화 RLS 로 적용하라 +-- user-images 버킷의 객체에 대한 공개 접근 허용 +CREATE POLICY "Public object access" ON storage.objects FOR ALL USING (bucket_id = 'user-images'); ``` -## 2. RLS 적용 후 Policy 정책을 진행해 주어야함 - -- todos 의 보안 정책을 작성하여, CRUD가 가능하도록 적용해야함 +### 2.2 Profiles Policy 설정 ```sql --- 사용자는 자신의 todos만 조회 가능 -CREATE POLICY "Users can view own todos" ON todos - FOR SELECT USING (auth.uid() = user_id); - --- 사용자는 자신의 todos만 생성 가능 -CREATE POLICY "Users can insert own todos" ON todos - FOR INSERT WITH CHECK (auth.uid() = user_id); - --- 사용자는 자신의 todos만 수정 가능 -CREATE POLICY "Users can update own todos" ON todos - FOR UPDATE USING (auth.uid() = user_id); - --- 사용자는 자신의 todos만 삭제 가능 -CREATE POLICY "Users can delete own todos" ON todos - FOR DELETE USING (auth.uid() = user_id); -``` - -## 3. 컬럼이 변하거나, 테이블이 추가 된다면 types 을 생성. - -```bash -npm run generate-types -``` - -- 실행 후, types_db.ts 확인 (복사) -- 복사 후 todoTypes.ts ( 붙여넣기, 지정한 todo타입은 지우지 말것 ) - -```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 Profile = Database['public']['Tables']['profiles']['Row']; -export type ProfileInsert = Database['public']['Tables']['profiles']['Insert']; -export type ProfileUpdate = Database['public']['Tables']['profiles']['Update']; - -// 삭제 신청 목록 정보 -export type DeleteRequest = Database['public']['Tables']['account_deletion_requests']['Row']; -export type DeleteRequestInsert = - Database['public']['Tables']['account_deletion_requests']['Insert']; -export type DeleteRequestUpdate = - Database['public']['Tables']['account_deletion_requests']['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: { - account_deletion_requests: { - Row: { - admin_notes: string | null; - id: string; - processed_at: string | null; - processed_by: string | null; - reason: string | null; - requested_at: string | null; - status: string | null; - user_email: string; - user_id: string | null; - }; - Insert: { - admin_notes?: string | null; - id?: string; - processed_at?: string | null; - processed_by?: string | null; - reason?: string | null; - requested_at?: string | null; - status?: string | null; - user_email: string; - user_id?: string | null; - }; - Update: { - admin_notes?: string | null; - id?: string; - processed_at?: string | null; - processed_by?: string | null; - reason?: string | null; - requested_at?: string | null; - status?: string | null; - user_email?: string; - user_id?: string | null; - }; - Relationships: []; - }; - memos: { - Row: { - created_at: string; - id: number; - memo: string | null; - }; - Insert: { - created_at?: string; - id?: number; - memo?: string | null; - }; - Update: { - created_at?: string; - id?: number; - memo?: string | null; - }; - Relationships: []; - }; - profiles: { - Row: { - avatar_url: string | null; - created_at: string | null; - id: string; - nickname: string | null; - }; - Insert: { - avatar_url?: string | null; - created_at?: string | null; - id: string; - nickname?: string | null; - }; - Update: { - avatar_url?: string | null; - created_at?: string | null; - id?: string; - nickname?: string | null; - }; - Relationships: []; - }; - todos: { - Row: { - completed: boolean; - content: string | null; - created_at: string | null; - id: number; - title: string; - updated_at: string | null; - user_id: string; - }; - Insert: { - completed?: boolean; - content?: string | null; - created_at?: string | null; - id?: number; - title: string; - updated_at?: string | null; - user_id: string; - }; - Update: { - completed?: boolean; - content?: string | null; - created_at?: string | null; - id?: number; - title?: string; - updated_at?: string | null; - user_id?: string; - }; - 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; -``` - -## 4. todoService.ts 일부 타입 수정 - -- Create 부분 - -```ts -// Todo 생성 -// 로그인을 하고 나면 실제로 user_id 가 이미 파악이 됨 -// TodoInsert 에서 user_id : 값 을 생략하는 타입을 생성 -// 타입스크립트에서 Omit 을 이용하면, 특정 키를 제거할 수 있음. - -export const createTodo = async (newTodo: Omit): Promise => { - try { - // 현재 로그인 한 사용자 정보 가져오기 - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - throw new Error('로그인이 필요합니다.'); - } - - const { data, error } = await supabase - .from('todos') - .insert([{ ...newTodo, completed: false, user_id: user.id }]) - .select() - .single(); - if (error) { - throw new Error(`createTodo 오류 : ${error.message}`); - } - return data; - } catch (error) { - console.log(error); - return null; - } -}; -``` - -- Update 부분 - -```ts -// Todo 수정 -// 로그인을 하고 나면 실제로 user_id 가 이미 파악이 됨 -// TodoUpdate 에서 user_id : 값 을 생략하는 타입을 생성 -// 타입스크립트에서 Omit 을 이용하면, 특정 키를 제거할 수 있음. -export const updateTodo = async ( - id: number, - editTitle: Omit, -): Promise => { - try { - 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; - } -}; -``` - -## 5. 알아두기 - -- RLS 를 적용시 자동으로 필터링이 됨 - -```ts -const { data } = await supabase.from('todos').select('*'); -// RLS 가 적용이 되었으므로, 자동 필터링이 적용됨 -// 실제 실행은 아래 구문으로 실행됨 -// SELECT * FROM todos WHERE auth.uid() = user_id -``` - -## 6. 최종 todoService.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 생성 -// 로그인을 하고 나면 실제로 user_id 가 이미 파악이 됨 -// TodoInsert 에서 user_id : 값을 생략하는 타입을 생성 -// 타입스크립트에서 Omit을 이용하면, 특정 키를 제거 할 수 있음. -export const createTodo = async (newTodo: Omit): Promise => { - try { - // 현재 로그인 한 사용자 정보 가져오기 - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - throw new Error('로그인이 필요합니다.'); - } - - const { data, error } = await supabase - .from('todos') - .insert([{ ...newTodo, completed: false, user_id: user.id }]) - .select() - .single(); - if (error) { - throw new Error(`createTodo 오류 : ${error.message}`); - } - return data; - } catch (error) { - console.log(error); - return null; - } -}; -// Todo 수정 -// 로그인을 하고 나면 실제로 user_id 가 이미 파악이 됨 -// TodoInsert 에서 user_id : 값을 생략하는 타입을 생성 -// 타입스크립트에서 Omit을 이용하면, 특정 키를 제거 할 수 있음. -export const updateTodo = async ( - id: number, - editTitle: Omit, -): 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 }); -}; -``` +-- RLS 활성화 +ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; + +-- 정책 생성 +CREATE POLICY "Users can view own profile" ON profiles FOR SELECT USING (auth.uid() = id); +CREATE POLICY "Users can insert own profile" ON profiles FOR INSERT WITH CHECK (auth.uid() = id); +CREATE POLICY "Users can update own profile" ON profiles FOR UPDATE USING (auth.uid() = id); +CREATE POLICY "Users can delete own profile" ON profiles FOR DELETE USING (auth.uid() = id); +``` \ No newline at end of file diff --git a/src/lib/profile.ts b/src/lib/profile.ts index c49d73f..341f509 100644 --- a/src/lib/profile.ts +++ b/src/lib/profile.ts @@ -64,7 +64,89 @@ const updateProfile = async (editUserProfile: ProfileUpdate, userId: string): Pr const deleteProfile = async (): Promise => {}; // 사용자 프로필 이미지 업로드 -const uploadAvatar = async (): Promise => {}; +const uploadAvatar = async (file: File, userId: string): Promise => { + try { + // 파일 타입 검사 + // 파일 형식 검증 + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; + if (!allowedTypes.includes(file.type)) { + throw new Error(`지원하지 않는 파일 형식입니다. 허용 형식: ${allowedTypes.join(', ')}`); + } + // 파일 크기 검증 (5MB 제한) + const maxSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxSize) { + throw new Error(`파일 크기가 너무 큽니다. 최대 5MB까지 업로드 가능합니다.`); + } + + // 기존에 아바타 이미지가 있으면 무조건 삭제부터 함. + const result = await cleanupUserAvatars(userId); + if (!result) { + console.log(`파일 삭제에 실패하였습니다.`); + } + + // 파일명이 중복되지 않도록 이름을 생성함 + const fileExt = file.name.split('.').pop(); + const fileName = `${userId}-${Date.now()}.${fileExt}`; + const filePath = `avatars/${fileName}`; + + // storage 에 bucket 이 존재하는지 검사 + const { data: buckets, error: bucketError } = await supabase.storage.listBuckets(); + if (bucketError) { + throw new Error(`storage 버킷 확인 실패 : ${bucketError.message}`); + } + // bucket 들의 목록 전달 {} 형태로 나옴. user-images 라는 이름에 업로드 + let profileImagesBucket = buckets.find(item => item.name === 'user-images'); + if (!profileImagesBucket) { + throw new Error('user-images 버킷이 존재하지 않습니다. 버킷 생성 필요.'); + } + // 파일 업로드 : upload(파일명, 실제파일, 옵션) + const { data, error } = await supabase.storage.from('user-images').upload(filePath, file, { + cacheControl: '3600', // 3600 초는 1시간. 1시간동안 파일 캐시 적용함 + upsert: false, // 동일한 파일명은 덮어씌운다 + }); + if (error) { + throw new Error(`업로드 실패 : ${error.message}`); + } + // https 문자열로 주소를 알아내서 활용 + const { + data: { publicUrl }, + } = supabase.storage.from('user-images').getPublicUrl(filePath); + return publicUrl; + } catch (error) { + throw new Error(`업로드 오류가 발생했습니다. : ${error}`); + } +}; +// 아바타 이미지는 한장을 유지해야 하므로 모두 제거하는 기능이 필요함 +const cleanupUserAvatars = async (userId: string): Promise => { + try { + const { data, error: listError } = await supabase.storage + .from('user-images') + .list('avatars', { limit: 1000 }); + if (listError) { + console.log(`목록 요청 실패 : ${listError.message}`); + } + // userId 에 해당하는 것만 필터링하여 삭제해야함. (아무거나 다 지우면 안되는 것 방지) + if (data && data.length > 0) { + const userFile = data.filter(item => item.name.startsWith(`${userId}-`)); + if (userFile && userFile.length > 0) { + const filePath = userFile.map(item => `avatars/${item.name}`); + const { error: removeError } = await supabase.storage.from('user-images').remove(filePath); + if (removeError) { + console.log(`파일 삭제 실패 : ${removeError.message}`); + return false; + } + return true; + } + } + return true; + } catch (error) { + console.log(`아바타 이미지 전체 삭제 오류 : ${error}`); + return false; + } +}; + +// 사용자 프로필 이미지 제거 +const RemoveAvatar = async (): Promise => {}; // 내보내기 ( 하나하나 export 넣기 귀찮을 시 ) export { createProfile, getProfile, updateProfile, deleteProfile, uploadAvatar }; diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index ddff413..315e2d3 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -5,9 +5,9 @@ * - 회원 탈퇴 기능 : 반드시 확인을 거치고 진행해야함 */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useAuth } from '../contexts/AuthContext'; -import { getProfile, updateProfile } from '../lib/profile'; +import { getProfile, updateProfile, uploadAvatar } from '../lib/profile'; import type { Profile, ProfileUpdate } from '../types/todoType'; function ProfilePage() { @@ -24,6 +24,20 @@ function ProfilePage() { // 회원 닉네임 보관 const [nickName, setNickName] = useState(''); + // 사용자 아바타 이미지를 위한 상태 관리 + // 이미지 업로드 상태 표현 + const [uploading, setUploading] = useState(false); + // 미리보기 이미지 URL (문자열) + const [previewImage, setPreviewImage] = useState(null); + // 실제 파일 (바이너리) + const [selectedFile, setSelectedFile] = useState(null); + // 사용자가 새로운 이미지 선택 시 (편집중인 경우), 원본 URL 보관용 (문자열) + const [originalAvatarUrl, setOriginalAvatarUrl] = useState(null); + // 이미지 제거 요청 상태 (그러나, 실제 file 제거는 수정 확인 버튼을 눌렀을 때 처리함) + const [imageRemoverRequest, setImageRemoverRequest] = useState(false); + // input type='file' 태그 참조 + const fileInputRef = useRef(null); + // 사용자 프로필 정보 가져오기 const loadProfile = async () => { if (!user?.id) { @@ -60,8 +74,19 @@ function ProfilePage() { if (!profileData) { return; } + // 여러개가 업로드 되어선 안됨 + setLoading(true); try { + let imgUrl = originalAvatarUrl; // 원본 이미지 URL + // 아바타 이미지 제거라면? + if (imageRemoverRequest) { + // storage 에 실제 이미지를 제거함 + } else if (selectedFile) { + // 새로운 이미지가 업로드 된다면? + const uploadedImageUrl = await uploadAvatar(selectedFile, user.id); + } + const tempUpdateData: ProfileUpdate = { nickname: nickName }; const success = await updateProfile(tempUpdateData, user.id); if (!success) { @@ -79,7 +104,7 @@ function ProfilePage() { // 회원탈퇴 const handleDeleteUser = () => { - const message: string = '계정을 완전히 삭제하시겠습니까? 복구가 불가능 합니다.'; + const message: string = '계정을 완전히 삭제하시겠습니까? \n\n 복구가 불가능 합니다.'; let isConfirm = false; isConfirm = confirm(message); @@ -88,6 +113,63 @@ function ProfilePage() { } }; + // 이미지 선택 처리 (미리보기) + const handleImageSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) { + return; + } + // 파일 형식 검증 + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + if (!allowedTypes.includes(file.type)) { + alert(`지원하지 않는 파일 형식입니다. 허용 형식: ${allowedTypes.join(', ')}`); + return; + } + + // 파일 크기 검증 (5MB 제한) + const maxSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxSize) { + alert(`파일 크기가 너무 큽니다. 최대 5MB까지 업로드 가능합니다.`); + return; + } + + // 미리보기 생성 (파일을 문자열로 변환한 것) + const reader = new FileReader(); + reader.onload = e => { + setPreviewImage(e.target?.result as string); + }; + reader.readAsDataURL(file); + + setSelectedFile(file); + // 새 이미지 선택 시 이미지 제거 요청 상태 초기화 + setImageRemoverRequest(false); + }; + + // 이미지 파일 선택 취소 + const handleCancelUpload = () => { + setPreviewImage(null); + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + // 이미지 파일 제거 처리 + const handleRemoveImage = () => { + const ok = confirm('프로필 이미지를 제거 하시겠습니까?'); + if (!ok) { + return; + } + // 즉시 제거하지 않음 + // 제거 하라는 상태만 별도로 관리함 + setImageRemoverRequest(true); + setPreviewImage(null); + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + useEffect(() => { loadProfile(); }, []); @@ -142,30 +224,73 @@ function ProfilePage() { />
          - 아바타 : - {profileData?.avatar_url ? ( - - ) : ( - - )} +

          아바타 편집

          +
          + {previewImage ? ( +
          + +

          새로운 이미지 미리보기

          +
          + ) : imageRemoverRequest ? ( +
          이미지 제거됨.
          + ) : originalAvatarUrl ? ( +
          + + 현재 아바타 +
          + ) : ( +
          이미지 없음, 아바타 이미지를 설정 해보세요!
          + )} +
          +
          + +
          +
          +
          + {/* disabled : 비활성화 */} + + {previewImage && ( + + )} + {!previewImage && !imageRemoverRequest && originalAvatarUrl && ( + + )} + {imageRemoverRequest && ( + + )} +
          +
          +

          지원 형식 : JPEG, PNG, GIF (최대 5MB)

          ) : ( <>
          닉네임 : {profileData?.nickname}
          - 아바타 : +

          아바타 :

          {profileData?.avatar_url ? ( ) : ( - +
          기본 이미지
          )}
          @@ -183,15 +308,23 @@ function ProfilePage() { {userEdit ? ( <> */} + setPw(e.target.value)} + placeholder="비밀번호" + className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + /> + setNickName(e.target.value)} + placeholder="닉네임" + className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + /> + {/* form 안에선 button type 지정해주기 */} + + +

          + {msg} +

          +
          +
          +
          + ); +} + +export default SignUpPage; +``` + +- AuthCallbackPage.tsx + +```tsx +import React, { useEffect, useState } from 'react'; +import { supabase } from '../lib/supabase'; +import type { ProfileInsert } from '../types/todoType'; +import { createProfile } from '../lib/profile'; + +/** + * - 인증 콜백 URL 처리 + * - 사용자에게 인증 진행 상태 안내 + * - 자동 인증 처리 완료 안내 + */ + +function AuthCallback() { + const [msg, setMsg] = useState('인증 처리 중 ...'); + + // 사용자가 이메일 확인을 클릭하면 실행되는 곳 + // 인증 정보에 담겨진 nickName 을 알아내서 여기서 profiles를 insert (추가) 한다 + const handleAuthCallback = async (): Promise => { + try { + // URL 에서 session( `웹브라우저 정보 시 사라지는 데이터 - 임시로 저장됨` ) 에 담겨진 정보를 가져온다 + const { data, error } = await supabase.auth.getSession(); + if (error) { + setMsg(`인증 오류 : ${error.message}`); + return; + } + // 인증 데이터가 존재함 + if (data.session?.user) { + const user = data.session.user; + // 추가적인 정보 파악 가능 (metadata 라고 함) + const nickName = user.user_metadata.nickName; + // 먼저 프로필이 이미 존재하는지 확인이 필요함 + const { data: existingProfile } = await supabase + .from('profiles') + .select('id') + .eq('id', user.id) + .single(); + + // 존재하지 않는 id 이고, 내용이 있다면 profiles 에 insert 한다 + if (!existingProfile && nickName) { + // 프로필이 없고, 닉네임이 존재하므로 프로필 생성하기 + const newProfile: ProfileInsert = { id: user.id, nickname: nickName }; + const result = await createProfile(newProfile); + if (result) { + setMsg('이메일이 인증 되었습니다. 프로필 생성 성공. 홈으로 이동해주세요'); + } else { + setMsg('이메일이 인증 되었습니다. 프로필 생성 실패 : 관리자에게 문의하세요.'); + } + } else { + setMsg('이미 존재하는 프로필입니다.'); + } + } else { + setMsg('인증 정보가 없습니다. 다시 시도 해주세요.'); + } + } catch (err) { + console.log(`인증 callback 처리 오류 : ${err}`); + setMsg('인증 처리 중 오류가 발생 하였습니다.'); + } + }; + + useEffect(() => { + // setTimeout 은 1초 뒤에 함수 실행 + const timer = setTimeout(handleAuthCallback, 1000); + // 클린업 함수 + return () => { + clearTimeout(timer); + }; + }, []); + + return ( +
          +

          인증 페이지

          +
          {msg}
          +
          + ); +} + +export default AuthCallback; +``` + +- profile.ts + +```ts +/** + * 사용자 프로필 관리 ( profiles.ts 에서 관리 ) + * - 프로필 생성 + * - 프로필 정보 조회 + * - 프로필 정보 수정 + * - 프로필 정보 삭제 + * + * 주의 사항 + * - 반드시 사용자 인증 후에만 프로필 생성 + */ + +import type { Profile, ProfileInsert, ProfileUpdate } from '../types/todoType'; +import { supabase } from './supabase'; + +// 사용자 프로필 생성 +const createProfile = async (newUserProfile: ProfileInsert): Promise => { + try { + const { data, error } = await supabase.from('profiles').insert([{ ...newUserProfile }]); + if (error) { + console.log(`프로필 추가에 실패하였습니다 : `, { + message: error.message, + detail: error.details, + hint: error.hint, + code: error.code, + }); + return false; + } + console.log(`프로필 생성 성공 : `, data); + return true; + } catch (error) { + console.log(`프로필 생성 오류 : ${error}`); + return false; + } +}; + +// 사용자 프로필 조회 +const getProfile = async (userId: string): Promise => { + try { + const { error, data } = await supabase.from('profiles').select('*').eq('id', userId).single(); + if (error) { + console.log(error.message); + return null; + } + return data; + } catch (error) { + console.log(error); + return null; + } +}; + +// 사용자 프로필 수정 +const updateProfile = async (editUserProfile: ProfileUpdate, userId: string): Promise => { + try { + const { error } = await supabase + .from('profiles') + .update({ ...editUserProfile }) + .eq('id', userId); + if (error) { + console.log(error.message); + return false; + } + return true; + } catch (error) { + console.log(error); + return false; + } +}; + +// 사용자 프로필 삭제 +const deleteProfile = async (): Promise => {}; + +// 사용자 프로필 이미지 업로드 +const uploadAvatar = async (file: File, userId: string): Promise => { + try { + // 파일 타입 검사 + // 파일 형식 검증 + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; + if (!allowedTypes.includes(file.type)) { + throw new Error(`지원하지 않는 파일 형식입니다. 허용 형식: ${allowedTypes.join(', ')}`); + } + // 파일 크기 검증 (5MB 제한) + const maxSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxSize) { + throw new Error(`파일 크기가 너무 큽니다. 최대 5MB까지 업로드 가능합니다.`); + } + + // 기존에 아바타 이미지가 있으면 무조건 삭제부터 함. + const result = await cleanupUserAvatars(userId); + if (!result) { + console.log(`파일 삭제에 실패하였습니다.`); + } + + // 파일명이 중복되지 않도록 이름을 생성함 + const fileExt = file.name.split('.').pop(); + const fileName = `${userId}-${Date.now()}.${fileExt}`; + const filePath = `avatars/${fileName}`; + + // storage 에 bucket 이 존재하는지 검사 + const { data: buckets, error: bucketError } = await supabase.storage.listBuckets(); + if (bucketError) { + throw new Error(`storage 버킷 확인 실패 : ${bucketError.message}`); + } + // bucket 들의 목록 전달 {} 형태로 나옴. user-images 라는 이름에 업로드 + let profileImagesBucket = buckets.find(item => item.name === 'user-images'); + if (!profileImagesBucket) { + throw new Error('user-images 버킷이 존재하지 않습니다. 버킷 생성 필요.'); + } + // 파일 업로드 : upload(파일명, 실제파일, 옵션) + const { data, error } = await supabase.storage.from('user-images').upload(filePath, file, { + cacheControl: '3600', // 3600 초는 1시간. 1시간동안 파일 캐시 적용함 + upsert: false, // 동일한 파일명은 덮어씌운다 + }); + if (error) { + throw new Error(`업로드 실패 : ${error.message}`); + } + // https 문자열로 주소를 알아내서 활용 + const { + data: { publicUrl }, + } = supabase.storage.from('user-images').getPublicUrl(filePath); + return publicUrl; + } catch (error) { + throw new Error(`업로드 오류가 발생했습니다. : ${error}`); + } +}; +// 아바타 이미지는 한장을 유지해야 하므로 모두 제거하는 기능이 필요함 +const cleanupUserAvatars = async (userId: string): Promise => { + try { + const { data, error: listError } = await supabase.storage + .from('user-images') + .list('avatars', { limit: 1000 }); + if (listError) { + console.log(`목록 요청 실패 : ${listError.message}`); + } + // userId 에 해당하는 것만 필터링하여 삭제해야함. (아무거나 다 지우면 안되는 것 방지) + if (data && data.length > 0) { + const userFile = data.filter(item => item.name.startsWith(`${userId}-`)); + if (userFile && userFile.length > 0) { + const filePath = userFile.map(item => `avatars/${item.name}`); + const { error: removeError } = await supabase.storage.from('user-images').remove(filePath); + if (removeError) { + console.log(`파일 삭제 실패 : ${removeError.message}`); + return false; + } + return true; + } + } + return true; + } catch (error) { + console.log(`아바타 이미지 전체 삭제 오류 : ${error}`); + return false; + } +}; + +// 사용자 프로필 이미지 제거 +const removeAvatar = async (userId: string): Promise => { + try { + // 현재 로그인 한 사용자의 avatar_url 을 읽어와야함 + // 여기서 파일명을 추출함 + const profile = await getProfile(userId); + // 사용자가 avatar_url 이 없으면 + if (!profile?.avatar_url) { + return true; // 작업 완료 + } + // 1. 만약 avatar_url 이 존재한다면 이름 파악, 파일 삭제 + let deleteSuccess = false; + try { + // url 에 파일명을 찾아야함 (url 로 변환하면 path와 파일 구분이 수월함) + const url = new URL(profile.avatar_url); + const pathParts = url.pathname.split('/'); + const publicIndex = pathParts.indexOf('public'); + if (publicIndex !== -1 && publicIndex + 1 < pathParts.length) { + const bucketName = pathParts[publicIndex + 1]; + const filePath = pathParts.slice(publicIndex + 2).join('/'); + // 실제로 찾아낸 bucketName 과 filePath 로 삭제 + const { data, error } = await supabase.storage.from(bucketName).remove([filePath]); + if (error) { + throw new Error('파일을 찾았지만, 삭제에 실패하였습니다.'); + } + // 파일 삭제 성공 + deleteSuccess = true; + } + } catch (err) { + console.log(err); + } + + // 2. 만약 avatar_url 을 제대로 파싱하지 못했다면? + if (!deleteSuccess) { + try { + // 전체 목록을 일단 읽어옴 + const { data: files, error: listError } = await supabase.storage + .from('user-images') + .list('avatars', { limit: 1000 }); + if (!listError && files && files.length > 0) { + const userFiles = files.filter(item => item.name.startsWith(`${userId}-`)); + if (userFiles.length > 0) { + const filePath = userFiles.map(item => `avatars/${item.name}`); + const { error } = await supabase.storage.from('user-images').remove(filePath); + if (!error) { + deleteSuccess = true; + } + } + } + } catch (error) { + console.log(error); + } + } + return true; + } catch (error) { + console.log(error); + return false; + } +}; + +// 내보내기 ( 하나하나 export 넣기 귀찮을 시 ) +export { createProfile, getProfile, updateProfile, deleteProfile, uploadAvatar, removeAvatar }; +``` + +# 일반적 네비게이션 진행하기 + +- npm 써도 됨 +- npm : https://www.npmjs.com/package/react-paginate +- 수업은 직접 구현 진행해봄 + +## 1. 구현 시나리오 + +- 목표 : 한 화면에 10개의 목록을 표시함 +- 페이지 번호로 네비게이션 함 +- 전체 개수 및 현재 페이지 정보를 출력함 +- supabase 에 todos 를 이용함 + +## 2. 코드 구현 + +### 2.1 /src/service/todoService.ts + +- 페이지 번호와 제한 개수를 이용해서 추출하기 함수 + +```ts +// 페이지 단위로 조각내서 목록 출력하기 +// getTodosPaginated (페이지 번호, 10개) +// getTodosPaginated (1, 10개) +// getTodosPaginated (2, 10개) +export const getTodosPaginated = async ( + page: number = 1, + limit: number = 10, +): Promise<{ todos: Todo[]; totalCount: number; totalPages: number; currentPage: number }> => { + // 시작 지점 ( 만약 page = 2 라면, limit 은 10 ) + // (2-1)*10 = 10 + const from = (page - 1) * limit; + // 제한 지점 (종료) + // 10 + 10 - 1 = 19 + const to = from + limit - 1; + + // 전체 데이터 개수 (행row의 개수) + const { count } = await supabase.from('todos').select('*', { count: 'exact', head: true }); + + // from 부터 to 까지의 상세 데이터 가져오기 + const { data } = await supabase + .from('todos') + .select('*') + .order('created_at', { ascending: false }) + .range(from, to); + + // 편하게 활용하기 + const totalCount = count || 0; + // 몇 페이지인지 계산 (소수점은 올림) + const totalPages = Math.ceil(totalCount / limit); + return { + todos: data || [], + totalCount, + totalPages, + currentPage: page, + }; +}; +``` + +- 전체 todoService.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 생성 +// 로그인을 하고 나면 실제로 user_id 가 이미 파악이 됨 +// TodoInsert 에서 user_id : 값을 생략하는 타입을 생성 +// 타입스크립트에서 Omit을 이용하면, 특정 키를 제거 할 수 있음. +export const createTodo = async (newTodo: Omit): Promise => { + try { + // 현재 로그인 한 사용자 정보 가져오기 + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + throw new Error('로그인이 필요합니다.'); + } + + const { data, error } = await supabase + .from('todos') + .insert([{ ...newTodo, completed: false, user_id: user.id }]) + .select() + .single(); + if (error) { + throw new Error(`createTodo 오류 : ${error.message}`); + } + return data; + } catch (error) { + console.log(error); + return null; + } +}; +// Todo 수정 +// 로그인을 하고 나면 실제로 user_id 가 이미 파악이 됨 +// TodoInsert 에서 user_id : 값을 생략하는 타입을 생성 +// 타입스크립트에서 Omit을 이용하면, 특정 키를 제거 할 수 있음. +export const updateTodo = async ( + id: number, + editTitle: Omit, +): 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 }); +}; + +// 페이지 단위로 조각내서 목록 출력하기 +// getTodosPaginated (페이지 번호, 10개) +// getTodosPaginated (1, 10개) +// getTodosPaginated (2, 10개) +export const getTodosPaginated = async ( + page: number = 1, + limit: number = 10, +): Promise<{ todos: Todo[]; totalCount: number; totalPages: number; currentPage: number }> => { + // 시작 지점 ( 만약 page = 2 라면, limit 은 10 ) + // (2-1)*10 = 10 + const from = (page - 1) * limit; + // 제한 지점 (종료) + // 10 + 10 - 1 = 19 + const to = from + limit - 1; + + // 전체 데이터 개수 (행row의 개수) + const { count } = await supabase.from('todos').select('*', { count: 'exact', head: true }); + + // from 부터 to 까지의 상세 데이터 가져오기 + const { data } = await supabase + .from('todos') + .select('*') + .order('created_at', { ascending: false }) + .range(from, to); + + // 편하게 활용하기 + const totalCount = count || 0; + // 몇 페이지인지 계산 (소수점은 올림) + const totalPages = Math.ceil(totalCount / limit); + return { + todos: data || [], + totalCount, + totalPages, + currentPage: page, + }; +}; +``` + +### 2.2 /src/contexts/TodoContext.tsx + +```tsx +import React, { + createContext, + useContext, + useEffect, + useReducer, + type PropsWithChildren, +} from 'react'; +import type { Todo } from '../types/todoType'; +// 전체 DB 가져오기 +import { getTodos, getTodosPaginated } from '../services/todoServices'; + +/** 1) 상태 타입과 초기값: 항상 Todo[]만 유지 */ +// 초기값 형태가 페이지 객체 형태로 추가됨 +type TodosState = { + todos: Todo[]; + totalCount: number; + totalPages: number; + currentPage: number; +}; +const initialState: TodosState = { + todos: [], + totalCount: 0, + totalPages: 0, + currentPage: 1, +}; + +/** 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[]; totalCount: number; totalPages: number; currentPage: number }; +}; +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, totalCount, totalPages, currentPage } = action.payload; + return { ...state, todos, totalCount, totalPages, currentPage }; + } + default: + return state; + } +} + +// Context 타입 : todos는 Todo[]로 고정, addTodo도 Todo를 받도록 함 +// 만들어진 context 가 관리하는 value 의 모양 +type TodoContextValue = { + todos: Todo[]; + totalCount: number; + totalPages: number; + currentPage: number; + itemsPerPage: number; + addTodo: (todo: Todo) => void; + toggleTodo: (id: number) => void; + deleteTodo: (id: number) => void; + editTodo: (id: number, editTitle: string) => void; + loadTodos: (page: number, limit: number) => void; +}; + +const TodoContext = createContext(null); + +// 5. Provider +// type TodoProviderProps = { +// children: React.ReactNode; +// }; +// export const TodoProvider = ({ children }: TodoProviderProps) => { + +// export const TodoProvider = ({ children }: React.PropsWithChildren) => { + +// props 정의하기 (방법 1번 - 비추천) +// interface TodoProviderProps { +// children?: React.ReactNode; +// currentPage?: number; +// limit?: number; +// } + +// props 정의하기 (방법 2번 - 상속형 추천) +interface TodoProviderProps extends PropsWithChildren { + currentPage?: number; + limit?: number; +} + +export const TodoProvider: React.FC = ({ + children, + currentPage = 1, + limit = 10, +}): JSX.Element => { + // useReducer 로 상태 관리 + 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[], totalCount: number, totalPages: number, currentPage: number) => { + dispatch({ + type: TodoActionType.SET_TODOS, + payload: { todos, totalCount, totalPages, currentPage }, + }); + }; + // Supabase 의 목록 읽기 함수 표현식 + // 비동기 데이터베이스 접근 + // const loadTodos = async (): Promise => { + // try { + // const result = await getTodos(); + // setTodos(result ?? []); + // } catch (error) { + // console.error('[loadTodos] 실패:', error); + // } + // }; + const loadTodos = async (page: number, limit: number): Promise => { + try { + const result = await getTodosPaginated(page, limit); + // 현재 페이지가 비어있고, 첫 페이지가 아니라면 이전 페이지를 출력 + if (result.todos.length === 0 && result.totalPages > 0 && page > 1) { + const prevPageResult = await getTodosPaginated(page - 1, limit); + setTodos( + prevPageResult.todos, + prevPageResult.totalCount, + prevPageResult.totalPages, + prevPageResult.currentPage, + ); + } else { + setTodos(result.todos, result.totalCount, result.totalPages, result.currentPage); + } + } catch (error) { + console.log(`목록 가져오기 오류 : ${error}`); + } + }; + + // 페이지가 바뀌면 다시 실행하도록 해야 함 + useEffect(() => { + void loadTodos(currentPage, limit); + }, [currentPage, limit]); + + // value 전달할 값 + const value: TodoContextValue = { + todos: state.todos, + totalCount: state.totalCount, + totalPages: state.totalPages, + currentPage: state.currentPage, + itemsPerPage: limit, + addTodo, + toggleTodo, + deleteTodo, + editTodo, + loadTodos, + }; + return {children}; +}; + +// 6. custom hook 생성 +export function useTodos(): TodoContextValue { + const ctx = useContext(TodoContext); + if (!ctx) { + throw new Error('context를 찾을 수 없습니다.'); + } + return ctx; // value 를 리턴함 +} ``` -### 2.2 Profiles Policy 설정 +### 2.3 pagenation 을 위한 컴포넌트 생성 -```sql --- RLS 활성화 -ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; +- /src/components/Pagenation.tsx 생성 (재활용 가능) --- 정책 생성 -CREATE POLICY "Users can view own profile" ON profiles FOR SELECT USING (auth.uid() = id); -CREATE POLICY "Users can insert own profile" ON profiles FOR INSERT WITH CHECK (auth.uid() = id); -CREATE POLICY "Users can update own profile" ON profiles FOR UPDATE USING (auth.uid() = id); -CREATE POLICY "Users can delete own profile" ON profiles FOR DELETE USING (auth.uid() = id); -``` \ No newline at end of file +### 2.4 /src/pages/TodosPage.tsx diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 0000000..d3057b7 --- /dev/null +++ b/src/components/Pagination.tsx @@ -0,0 +1,112 @@ +import React from 'react'; + +interface PaginationProps { + totalCount: number; + totalPages: number; + currentPage: number; + itemsPerPage: number; + handleChangePage: (page: number) => void; +} +const Pagination = ({ + totalCount, + totalPages, + currentPage, + itemsPerPage, + handleChangePage, +}: PaginationProps): JSX.Element => { + // ts 자리 + // 시작 번호를 생성함. + const startItem = (currentPage - 1) * itemsPerPage + 1; + // 마지막 번호를 생성함. + const endItem = Math.min(currentPage * itemsPerPage, totalCount); + + // 페이지 번호 버튼 배열을 생성함 + const getPageNumbers = () => { + const pages = []; + // 한 화면에 몇개의 버튼들을 출력할 것인가? + const maxVisiblePages = 5; + + if (totalPages <= maxVisiblePages) { + // 현재 5 페이지 보다 적은 경우 + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + // 현재 5 페이지 보다 큰 경우 + // 시나리오 + // ... currentpage-2 currentpage-1 currentpage currentpage+1 currentpage+2 ... + // 현재 페이지를 중심으로 앞뒤 2개씩 표현 + const startPage = Math.max(1, currentPage - 2); + const endPage = Math.min(totalPages, currentPage + 2); + + // 시작페이지가 1 보다 크면 첫 페이지와 ... 추가 + if (startPage > 1) { + pages.push(1); + // [1] + if (startPage > 2) { + pages.push('...'); + // [1, "..."] + } + } + // 중간 페이지를 추가 + for (let i = startPage; i <= endPage; i++) { + pages.push(i); + } + + // 끝 페이지가 마지막 보다 작으면 ... 과 페이지 추가 + if (endPage < totalPages) { + if (endPage < totalPages - 1) { + pages.push('...'); + } + pages.push(totalPages); + } + } + + return pages; + }; + + const pageNumbers = getPageNumbers(); + + // 페이지네이션이 무조건 나오는 것은 아닙니다. + if (totalPages <= 1) { + return <>; + } + + // tsx 자리 + return ( +
          + {/* 페이지 정보 */} +
          + 총 {totalCount}개 중 {startItem} ~ {endItem} 개 표시 +
          + {/* 페이지 번호들 */} +
          + + {/* 버튼들 출력 */} + {pageNumbers.map((item, index) => ( + + {item === '...' ? ( + ... + ) : ( + + )} + + ))} + + +
          +
          + ); +}; + +export default Pagination; diff --git a/src/components/todos/TodoItem.tsx b/src/components/todos/TodoItem.tsx index 32dd15a..18d544f 100644 --- a/src/components/todos/TodoItem.tsx +++ b/src/components/todos/TodoItem.tsx @@ -10,10 +10,13 @@ import { type TodoItemProps = { todo: Todo; + index: number; }; -const TodoItem = ({ todo }: TodoItemProps) => { - const { toggleTodo, editTodo, deleteTodo } = useTodos(); +const TodoItem = ({ todo, index }: TodoItemProps) => { + const { toggleTodo, editTodo, deleteTodo, currentPage, itemsPerPage, totalCount } = useTodos(); + // 순서 번호 매기기 + const globalIndex = totalCount - ((currentPage - 1) * itemsPerPage + index); // 수정중인지 const [isEdit, setIsEdit] = useState(false); @@ -79,6 +82,7 @@ const TodoItem = ({ todo }: TodoItemProps) => { return (
          + {globalIndex} {isEdit ? (
          { {todos.length === 0 ? (
        • 할 일이 없습니다.
        • ) : ( - todos.map((item: Todo) => ( + todos.map((item: Todo, index: number) => (
        • - +
        • )) )} diff --git a/src/contexts/TodoContext.tsx b/src/contexts/TodoContext.tsx index c56529d..4f008c2 100644 --- a/src/contexts/TodoContext.tsx +++ b/src/contexts/TodoContext.tsx @@ -7,14 +7,21 @@ import React, { } from 'react'; import type { Todo } from '../types/todoType'; // 전체 DB 가져오기 -import { getTodos } from '../services/todoServices'; +import { getTodos, getTodosPaginated } from '../services/todoServices'; /** 1) 상태 타입과 초기값: 항상 Todo[]만 유지 */ +// 초기값 형태가 페이지 객체 형태로 추가됨 type TodosState = { todos: Todo[]; + totalCount: number; + totalPages: number; + currentPage: number; }; const initialState: TodosState = { todos: [], + totalCount: 0, + totalPages: 0, + currentPage: 1, }; /** 2) 액션 타입 */ @@ -34,7 +41,10 @@ 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 SetTodosAction = { + type: TodoActionType.SET_TODOS; + payload: { todos: Todo[]; totalCount: number; totalPages: number; currentPage: number }; +}; type TodoAction = AddAction | ToggleAction | DeleteAction | EditAction | SetTodosAction; // 3. Reducer : 반환 타입을 명시해 주면 더 명확해짐 @@ -64,8 +74,8 @@ function reducer(state: TodosState, action: TodoAction): TodosState { } // Supabase 에 목록 읽기 case TodoActionType.SET_TODOS: { - const { todos } = action.payload; - return { ...state, todos }; + const { todos, totalCount, totalPages, currentPage } = action.payload; + return { ...state, todos, totalCount, totalPages, currentPage }; } default: return state; @@ -76,10 +86,15 @@ function reducer(state: TodosState, action: TodoAction): TodosState { // 만들어진 context 가 관리하는 value 의 모양 type TodoContextValue = { todos: Todo[]; + totalCount: number; + totalPages: number; + currentPage: number; + itemsPerPage: number; addTodo: (todo: Todo) => void; toggleTodo: (id: number) => void; deleteTodo: (id: number) => void; editTodo: (id: number, editTitle: string) => void; + loadTodos: (page: number, limit: number) => void; }; const TodoContext = createContext(null); @@ -92,7 +107,25 @@ const TodoContext = createContext(null); // export const TodoProvider = ({ children }: React.PropsWithChildren) => { -export const TodoProvider: React.FC = ({ children }): JSX.Element => { +// props 정의하기 (방법 1번 - 비추천) +// interface TodoProviderProps { +// children?: React.ReactNode; +// currentPage?: number; +// limit?: number; +// } + +// props 정의하기 (방법 2번 - 상속형 추천) +interface TodoProviderProps extends PropsWithChildren { + currentPage?: number; + limit?: number; +} + +export const TodoProvider: React.FC = ({ + children, + currentPage = 1, + limit = 10, +}): JSX.Element => { + // useReducer 로 상태 관리 const [state, dispatch] = useReducer(reducer, initialState); // dispatch 를 위한 함수 표현식 모음 @@ -112,30 +145,59 @@ export const TodoProvider: React.FC = ({ children }): JSX.Ele }; // 실행시 state { todos }를 업데이트함 // reducer 함수를 실행함 - const setTodos = (todos: Todo[]) => { - dispatch({ type: TodoActionType.SET_TODOS, payload: { todos } }); + const setTodos = (todos: Todo[], totalCount: number, totalPages: number, currentPage: number) => { + dispatch({ + type: TodoActionType.SET_TODOS, + payload: { todos, totalCount, totalPages, currentPage }, + }); }; // Supabase 의 목록 읽기 함수 표현식 // 비동기 데이터베이스 접근 - const loadTodos = async (): Promise => { + // const loadTodos = async (): Promise => { + // try { + // const result = await getTodos(); + // setTodos(result ?? []); + // } catch (error) { + // console.error('[loadTodos] 실패:', error); + // } + // }; + const loadTodos = async (page: number, limit: number): Promise => { try { - const result = await getTodos(); - setTodos(result ?? []); + const result = await getTodosPaginated(page, limit); + // 현재 페이지가 비어있고, 첫 페이지가 아니라면 이전 페이지를 출력 + if (result.todos.length === 0 && result.totalPages > 0 && page > 1) { + const prevPageResult = await getTodosPaginated(page - 1, limit); + setTodos( + prevPageResult.todos, + prevPageResult.totalCount, + prevPageResult.totalPages, + prevPageResult.currentPage, + ); + } else { + setTodos(result.todos, result.totalCount, result.totalPages, result.currentPage); + } } catch (error) { - console.error('[loadTodos] 실패:', error); + console.log(`목록 가져오기 오류 : ${error}`); } }; + + // 페이지가 바뀌면 다시 실행하도록 해야 함 useEffect(() => { - void loadTodos(); - }, []); + void loadTodos(currentPage, limit); + }, [currentPage, limit]); // value 전달할 값 const value: TodoContextValue = { todos: state.todos, + totalCount: state.totalCount, + totalPages: state.totalPages, + currentPage: state.currentPage, + itemsPerPage: limit, addTodo, toggleTodo, deleteTodo, editTodo, + loadTodos, }; return {children}; }; diff --git a/src/lib/profile.ts b/src/lib/profile.ts index a5d2aaa..5e578fc 100644 --- a/src/lib/profile.ts +++ b/src/lib/profile.ts @@ -15,11 +15,17 @@ import { supabase } from './supabase'; // 사용자 프로필 생성 const createProfile = async (newUserProfile: ProfileInsert): Promise => { try { - const { error } = await supabase.from('profiles').insert([{ ...newUserProfile }]); + const { data, error } = await supabase.from('profiles').insert([{ ...newUserProfile }]); if (error) { - console.log(`프로필 추가에 실패하였습니다 : ${error.message}`); + console.log(`프로필 추가에 실패하였습니다 : `, { + message: error.message, + detail: error.details, + hint: error.hint, + code: error.code, + }); return false; } + console.log(`프로필 생성 성공 : `, data); return true; } catch (error) { console.log(`프로필 생성 오류 : ${error}`); diff --git a/src/pages/AuthCallbackPage.tsx b/src/pages/AuthCallbackPage.tsx index 44d1840..3ef69ce 100644 --- a/src/pages/AuthCallbackPage.tsx +++ b/src/pages/AuthCallbackPage.tsx @@ -1,4 +1,7 @@ import React, { useEffect, useState } from 'react'; +import { supabase } from '../lib/supabase'; +import type { ProfileInsert } from '../types/todoType'; +import { createProfile } from '../lib/profile'; /** * - 인증 콜백 URL 처리 @@ -8,11 +11,54 @@ import React, { useEffect, useState } from 'react'; function AuthCallback() { const [msg, setMsg] = useState('인증 처리 중 ...'); - useEffect(() => { - const timer = setTimeout(() => { - setMsg('이메일 인증 완료. 홈으로 이동해주세요'); - }, 1500); + // 사용자가 이메일 확인을 클릭하면 실행되는 곳 + // 인증 정보에 담겨진 nickName 을 알아내서 여기서 profiles를 insert (추가) 한다 + const handleAuthCallback = async (): Promise => { + try { + // URL 에서 session( `웹브라우저 정보 시 사라지는 데이터 - 임시로 저장됨` ) 에 담겨진 정보를 가져온다 + const { data, error } = await supabase.auth.getSession(); + if (error) { + setMsg(`인증 오류 : ${error.message}`); + return; + } + // 인증 데이터가 존재함 + if (data.session?.user) { + const user = data.session.user; + // 추가적인 정보 파악 가능 (metadata 라고 함) + const nickName = user.user_metadata.nickName; + // 먼저 프로필이 이미 존재하는지 확인이 필요함 + const { data: existingProfile } = await supabase + .from('profiles') + .select('id') + .eq('id', user.id) + .single(); + + // 존재하지 않는 id 이고, 내용이 있다면 profiles 에 insert 한다 + if (!existingProfile && nickName) { + // 프로필이 없고, 닉네임이 존재하므로 프로필 생성하기 + const newProfile: ProfileInsert = { id: user.id, nickname: nickName }; + const result = await createProfile(newProfile); + if (result) { + setMsg('이메일이 인증 되었습니다. 프로필 생성 성공. 홈으로 이동해주세요'); + } else { + setMsg('이메일이 인증 되었습니다. 프로필 생성 실패 : 관리자에게 문의하세요.'); + } + } else { + setMsg('이미 존재하는 프로필입니다.'); + } + } else { + setMsg('인증 정보가 없습니다. 다시 시도 해주세요.'); + } + } catch (err) { + console.log(`인증 callback 처리 오류 : ${err}`); + setMsg('인증 처리 중 오류가 발생 하였습니다.'); + } + }; + + useEffect(() => { + // setTimeout 은 1초 뒤에 함수 실행 + const timer = setTimeout(handleAuthCallback, 1000); // 클린업 함수 return () => { clearTimeout(timer); diff --git a/src/pages/SignUpPage.tsx b/src/pages/SignUpPage.tsx index 83d330a..aafd57b 100644 --- a/src/pages/SignUpPage.tsx +++ b/src/pages/SignUpPage.tsx @@ -43,27 +43,19 @@ function SignUpPage() { options: { // 회원 가입 후 이메일로 인증 확인시 리다이렉트 될 URL emailRedirectTo: `${window.location.origin}/auth/callback`, + // 잠시 추가 정보를 보관함 + // supabase 에서 auth 에는 추가적인 정보를 저장하는 객체가 존재함 + // 이메일 인증 후 프로필 생성시에 사용하려고 보관함 + data: { nickName: nickName }, // 공식적인 명칭 : `metadata` 라고 함 }, }); if (error) { setMsg(`회원가입 오류 : ${error}`); } else { - // 회원가입이 성공했으므로 profiles 도 채워줌 - if (data?.user?.id) { - // 프로필을 추가함 - const newUser: ProfileInsert = { id: data.user.id, nickname: nickName }; - const result = await createProfile(newUser); - if (result) { - // 프로필 추가가 성공한 경우 - setMsg(`회원 가입 및 프로필 생성 성공. 이메일을 확인 해주세요.`); - } else { - // 프로필 추가를 실패한 경우 - setMsg(`회원가입은 성공하였으나, 프로필 생성에 실패하였습니다.`); - } - } else { - setMsg(`이메일이 발송 되었습니다. 이메일을 확인 해주세요.`); - } + setMsg( + '회원 가입이 성공했습니다. 이메일을 확인해주세요. 인증 완료 후 프로필이 자동으로 생성됩니다.', + ); } }; diff --git a/src/pages/TodosPage.tsx b/src/pages/TodosPage.tsx index 747feca..9e7dd4a 100644 --- a/src/pages/TodosPage.tsx +++ b/src/pages/TodosPage.tsx @@ -1,13 +1,59 @@ -import { useEffect, useState } from 'react'; -import TodoList from '../components/todos/TodoList'; +import React, { useEffect, useState } from 'react'; +import { TodoProvider, useTodos } from '../contexts/TodoContext'; import TodoWrite from '../components/todos/TodoWrite'; -import { TodoProvider } from '../contexts/TodoContext'; -import type { Profile } from '../types/todoType'; +import TodoList from '../components/todos/TodoList'; + import { useAuth } from '../contexts/AuthContext'; import { getProfile } from '../lib/profile'; +import Pagination from '../components/Pagination'; +import type { Profile } from '../types/todoType'; + +// 용서하세요. 컴포넌트는 여기서 작성하겠습니다. +// 필요하시면 이동 부탁합니다. +interface TodosContentProps { + currentPage: number; + itemsPerPage: number; + handleChangePage: (page: number) => void; +} +const TodosContent = ({ + currentPage, + itemsPerPage, + handleChangePage, +}: TodosContentProps): JSX.Element => { + const { totalCount, totalPages } = useTodos(); + return ( +
          +
          + +
          +
          + +
          +
          + +
          +
          + ); +}; function TodosPage() { const { user } = useAuth(); + + // 페이지네이션 관련 + const [currentPage, setCurrentPage] = useState(1); + // const itemsPerPage = 10; 으로 넣어도 됨 + const [itemsPerPage, setItemsPerPage] = useState(10); + // 페이지 변경 핸들러 + const handleChangePage = (page: number) => { + setCurrentPage(page); + }; + const [profile, setProfile] = useState(null); // 프로필 가져오기 @@ -15,14 +61,13 @@ function TodosPage() { try { if (user?.id) { const userProfile = await getProfile(user.id); - // 방어 코드 (탈퇴한 회원이 작성하지 못하게끔) if (!userProfile) { - alert('탈퇴한 회원입니다. 관리자에게 문의하세요.'); + alert('탈퇴한 회원입니다. 관리자님에게 요청하세요.'); } setProfile(userProfile); } } catch (error) { - console.log('프로필 가져오기 ERROR : ', error); + console.log('프로필 가져오기 Error : ', error); } }; @@ -32,16 +77,13 @@ function TodosPage() { return (
          -

          - {profile?.nickname} 님의 할 일 -

          - -
          - -
          -
          - -
          +

          {profile?.nickname}할 일

          + +
          ); diff --git a/src/services/todoServices.ts b/src/services/todoServices.ts index 84407aa..03a5905 100644 --- a/src/services/todoServices.ts +++ b/src/services/todoServices.ts @@ -84,7 +84,50 @@ export const deleteTodo = async (id: number): Promise => { }; // Complited 토글 = 어차피 toggle도 업데이트기 때문에 굳이 만들지 않아도 되지만 수업상 만듦 - export const toggleTodo = async (id: number, completed: boolean): Promise => { return updateTodo(id, { completed }); }; + +type PaginatedTodos = { + todos: Todo[]; + totalCount: number; + totalPages: number; + currentPage: number; +}; + +// 페이지 단위로 조각내서 목록 출력하기 +// getTodosPaginated (페이지 번호, 10개) +// getTodosPaginated (1, 10개) +// getTodosPaginated (2, 10개) +export const getTodosPaginated = async ( + page: number = 1, + limit: number = 10, +): Promise<{ todos: Todo[]; totalCount: number; totalPages: number; currentPage: number }> => { + // 시작 지점 ( 만약 page = 2 라면, limit 은 10 ) + // (2-1)*10 = 10 + const from = (page - 1) * limit; + // 제한 지점 (종료) + // 10 + 10 - 1 = 19 + const to = from + limit - 1; + + // 전체 데이터 개수 (행row의 개수) + const { count } = await supabase.from('todos').select('*', { count: 'exact', head: true }); + + // from 부터 to 까지의 상세 데이터 가져오기 + const { data } = await supabase + .from('todos') + .select('*') + .order('created_at', { ascending: false }) + .range(from, to); + + // 편하게 활용하기 + const totalCount = count || 0; + // 몇 페이지인지 계산 (소수점은 올림) + const totalPages = Math.ceil(totalCount / limit); + return { + todos: data || [], + totalCount, + totalPages, + currentPage: page, + }; +}; From 992bc4ebe6e56daff0152c0a4b112894f58a444f Mon Sep 17 00:00:00 2001 From: suha720 Date: Thu, 11 Sep 2025 09:55:33 +0900 Subject: [PATCH 22/51] =?UTF-8?q?[docs]=20navigation=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/components/todos/TodoItem.tsx | 3 ++- src/components/todos/TodoWrite.tsx | 5 ++++- src/pages/TodosPage.tsx | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/todos/TodoItem.tsx b/src/components/todos/TodoItem.tsx index 18d544f..2f2aae6 100644 --- a/src/components/todos/TodoItem.tsx +++ b/src/components/todos/TodoItem.tsx @@ -82,7 +82,8 @@ const TodoItem = ({ todo, index }: TodoItemProps) => { return (
          - {globalIndex} + {/* 출력 번호 */} + {globalIndex} {isEdit ? (
          void; }; -const TodoWrite = ({}: TodoWriteProps): JSX.Element => { +const TodoWrite = ({ handleChangePage }: TodoWriteProps): JSX.Element => { // Context 를 사용함. const { addTodo } = useTodos(); @@ -37,6 +38,8 @@ const TodoWrite = ({}: TodoWriteProps): JSX.Element => { if (result) { // Context 에 데이터를 추가해 줌. addTodo(result); + // 현재 페이지를 1 페이지로 이동 + handleChangePage(1); } // 현재 Write 컴포넌트 state 초기화 diff --git a/src/pages/TodosPage.tsx b/src/pages/TodosPage.tsx index 9e7dd4a..b84c353 100644 --- a/src/pages/TodosPage.tsx +++ b/src/pages/TodosPage.tsx @@ -24,7 +24,8 @@ const TodosContent = ({ return (
          - + {/* 새 글 등록 시 1 페이지로 이동 후 목록 새로고침 */} +
          From 990b214a1ab0d10ddaf2cde7d83b7af37914c78b Mon Sep 17 00:00:00 2001 From: suha720 Date: Thu, 11 Sep 2025 16:26:15 +0900 Subject: [PATCH 23/51] =?UTF-8?q?[docs]=20loop=20=EB=B0=B0=EC=9A=B0?= =?UTF-8?q?=EB=8A=94=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1337 +++++++++++++----------- src/App.tsx | 12 + src/contexts/InfinityScrollContext.tsx | 276 +++++ src/pages/TodosInfinityPage.tsx | 170 +++ src/services/todoServices.ts | 49 +- 5 files changed, 1211 insertions(+), 633 deletions(-) create mode 100644 src/contexts/InfinityScrollContext.tsx create mode 100644 src/pages/TodosInfinityPage.tsx diff --git a/README.md b/README.md index 54f5a4b..11f9c55 100644 --- a/README.md +++ b/README.md @@ -1,492 +1,59 @@ -# profiles 테이블에 추가 정보 시 오류 발생 +# Infinity Scroll Loof List -- RLS 정책으로 회원이 아니면 CRUD 를 하지 못한다. +- 스크롤 시 추가 목록 구현 ( UI가 SNS 서비스에 좋음 ) +- 무한하게 진행되는 ( 스크롤 내리면 계속 보여지는 ) 스크롤 바 -## 1. 기존 방식 +## 1. /src/services/todoService.ts -- 회원가입 → profiles 에 insert 진행함 ( 오류 발생 ) -- 회원가입 → 이메일 인증 → 인증 확인 → profiles 에 insert 필요 +- 무한 스크롤 todos 목록 조회 기능 추가 -## 2. 회원가입 진행 과정 개선 - -- /src/pages/SignUpPage.tsx 수정 - -```tsx -import { useState } from 'react'; -import { useAuth } from '../contexts/AuthContext'; -import { supabase } from '../lib/supabase'; -import { createProfile } from '../lib/profile'; -import type { ProfileInsert } from '../types/todoType'; - -function SignUpPage() { - const { signUp } = useAuth(); - - const [email, setEmail] = useState(''); - const [pw, setPw] = useState(''); - - // 추가 정보 ( 닉네임 ) - const [nickName, setNickName] = useState(''); - const [msg, setMsg] = useState(''); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); // 해당 코드 필수 : 웹브라우저 갱신 막아주기 - // 유효성 검사 - if (!email.trim()) { - alert('이메일을 입력하세요.'); - return; - } - - if (!pw.trim()) { - alert('비밀번호를 입력하세요.'); - return; - } - if (pw.length < 6) { - alert('비밀번호는 최소 6자 이상입니다.'); - return; - } - - if (!nickName.trim()) { - alert('닉네임을 입력하세요.'); - return; - } - - // 회원 가입 및 추가 정보 입력하기 - const { error, data } = await supabase.auth.signUp({ - email, - password: pw, - options: { - // 회원 가입 후 이메일로 인증 확인시 리다이렉트 될 URL - emailRedirectTo: `${window.location.origin}/auth/callback`, - // 잠시 추가 정보를 보관함 - // supabase 에서 auth 에는 추가적인 정보를 저장하는 객체가 존재함 - // 이메일 인증 후 프로필 생성시에 사용하려고 보관함 - data: { nickName: nickName }, // 공식적인 명칭 : `metadata` 라고 함 - }, - }); - - if (error) { - setMsg(`회원가입 오류 : ${error}`); - } else { - setMsg( - '회원 가입이 성공했습니다. 이메일을 확인해주세요. 인증 완료 후 프로필이 자동으로 생성됩니다.', - ); - } - }; - - return ( -
          -
          -

          - Todo Service 회원 가입 -

          -
          -
          - setEmail(e.target.value)} - placeholder="이메일" - className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" - /> - {/* */} - setPw(e.target.value)} - placeholder="비밀번호" - className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" - /> - setNickName(e.target.value)} - placeholder="닉네임" - className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" - /> - {/* form 안에선 button type 지정해주기 */} - -
          -

          - {msg} -

          -
          -
          -
          - ); -} - -export default SignUpPage; -``` - -- AuthCallbackPage.tsx - -```tsx -import React, { useEffect, useState } from 'react'; -import { supabase } from '../lib/supabase'; -import type { ProfileInsert } from '../types/todoType'; -import { createProfile } from '../lib/profile'; - -/** - * - 인증 콜백 URL 처리 - * - 사용자에게 인증 진행 상태 안내 - * - 자동 인증 처리 완료 안내 - */ - -function AuthCallback() { - const [msg, setMsg] = useState('인증 처리 중 ...'); - - // 사용자가 이메일 확인을 클릭하면 실행되는 곳 - // 인증 정보에 담겨진 nickName 을 알아내서 여기서 profiles를 insert (추가) 한다 - const handleAuthCallback = async (): Promise => { - try { - // URL 에서 session( `웹브라우저 정보 시 사라지는 데이터 - 임시로 저장됨` ) 에 담겨진 정보를 가져온다 - const { data, error } = await supabase.auth.getSession(); - if (error) { - setMsg(`인증 오류 : ${error.message}`); - return; - } - // 인증 데이터가 존재함 - if (data.session?.user) { - const user = data.session.user; - // 추가적인 정보 파악 가능 (metadata 라고 함) - const nickName = user.user_metadata.nickName; - // 먼저 프로필이 이미 존재하는지 확인이 필요함 - const { data: existingProfile } = await supabase - .from('profiles') - .select('id') - .eq('id', user.id) - .single(); - - // 존재하지 않는 id 이고, 내용이 있다면 profiles 에 insert 한다 - if (!existingProfile && nickName) { - // 프로필이 없고, 닉네임이 존재하므로 프로필 생성하기 - const newProfile: ProfileInsert = { id: user.id, nickname: nickName }; - const result = await createProfile(newProfile); - if (result) { - setMsg('이메일이 인증 되었습니다. 프로필 생성 성공. 홈으로 이동해주세요'); - } else { - setMsg('이메일이 인증 되었습니다. 프로필 생성 실패 : 관리자에게 문의하세요.'); - } - } else { - setMsg('이미 존재하는 프로필입니다.'); - } - } else { - setMsg('인증 정보가 없습니다. 다시 시도 해주세요.'); - } - } catch (err) { - console.log(`인증 callback 처리 오류 : ${err}`); - setMsg('인증 처리 중 오류가 발생 하였습니다.'); - } - }; - - useEffect(() => { - // setTimeout 은 1초 뒤에 함수 실행 - const timer = setTimeout(handleAuthCallback, 1000); - // 클린업 함수 - return () => { - clearTimeout(timer); - }; - }, []); - - return ( -
          -

          인증 페이지

          -
          {msg}
          -
          - ); -} - -export default AuthCallback; -``` - -- profile.ts +- todoService.ts ```ts -/** - * 사용자 프로필 관리 ( profiles.ts 에서 관리 ) - * - 프로필 생성 - * - 프로필 정보 조회 - * - 프로필 정보 수정 - * - 프로필 정보 삭제 - * - * 주의 사항 - * - 반드시 사용자 인증 후에만 프로필 생성 - */ - -import type { Profile, ProfileInsert, ProfileUpdate } from '../types/todoType'; -import { supabase } from './supabase'; - -// 사용자 프로필 생성 -const createProfile = async (newUserProfile: ProfileInsert): Promise => { +// 무한 스크롤 todo 목록 조회 +export const getTodosInfinity = async ( + offset: number = 0, + limit: number = 5, +): Promise<{ todos: Todo[]; hasMore: boolean; totalCount: number }> => { try { - const { data, error } = await supabase.from('profiles').insert([{ ...newUserProfile }]); - if (error) { - console.log(`프로필 추가에 실패하였습니다 : `, { - message: error.message, - detail: error.details, - hint: error.hint, - code: error.code, - }); - return false; - } - console.log(`프로필 생성 성공 : `, data); - return true; - } catch (error) { - console.log(`프로필 생성 오류 : ${error}`); - return false; - } -}; - -// 사용자 프로필 조회 -const getProfile = async (userId: string): Promise => { - try { - const { error, data } = await supabase.from('profiles').select('*').eq('id', userId).single(); - if (error) { - console.log(error.message); - return null; - } - return data; - } catch (error) { - console.log(error); - return null; - } -}; - -// 사용자 프로필 수정 -const updateProfile = async (editUserProfile: ProfileUpdate, userId: string): Promise => { - try { - const { error } = await supabase - .from('profiles') - .update({ ...editUserProfile }) - .eq('id', userId); - if (error) { - console.log(error.message); - return false; - } - return true; - } catch (error) { - console.log(error); - return false; - } -}; - -// 사용자 프로필 삭제 -const deleteProfile = async (): Promise => {}; - -// 사용자 프로필 이미지 업로드 -const uploadAvatar = async (file: File, userId: string): Promise => { - try { - // 파일 타입 검사 - // 파일 형식 검증 - const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; - if (!allowedTypes.includes(file.type)) { - throw new Error(`지원하지 않는 파일 형식입니다. 허용 형식: ${allowedTypes.join(', ')}`); - } - // 파일 크기 검증 (5MB 제한) - const maxSize = 5 * 1024 * 1024; // 5MB - if (file.size > maxSize) { - throw new Error(`파일 크기가 너무 큽니다. 최대 5MB까지 업로드 가능합니다.`); - } - - // 기존에 아바타 이미지가 있으면 무조건 삭제부터 함. - const result = await cleanupUserAvatars(userId); - if (!result) { - console.log(`파일 삭제에 실패하였습니다.`); + // 전체 todos 의 Row 개수 + const { count, error: countError } = await supabase + .from('todos') + .select('*', { count: 'exact', head: true }); + if (countError) { + throw new Error(`getTodosInfinity count 오류 : ${countError.message}`); } - // 파일명이 중복되지 않도록 이름을 생성함 - const fileExt = file.name.split('.').pop(); - const fileName = `${userId}-${Date.now()}.${fileExt}`; - const filePath = `avatars/${fileName}`; + // 무한 스크롤 데이터 조회 + const { data, error: limitError } = await supabase + .from('todos') + .select('*') + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1); - // storage 에 bucket 이 존재하는지 검사 - const { data: buckets, error: bucketError } = await supabase.storage.listBuckets(); - if (bucketError) { - throw new Error(`storage 버킷 확인 실패 : ${bucketError.message}`); - } - // bucket 들의 목록 전달 {} 형태로 나옴. user-images 라는 이름에 업로드 - let profileImagesBucket = buckets.find(item => item.name === 'user-images'); - if (!profileImagesBucket) { - throw new Error('user-images 버킷이 존재하지 않습니다. 버킷 생성 필요.'); - } - // 파일 업로드 : upload(파일명, 실제파일, 옵션) - const { data, error } = await supabase.storage.from('user-images').upload(filePath, file, { - cacheControl: '3600', // 3600 초는 1시간. 1시간동안 파일 캐시 적용함 - upsert: false, // 동일한 파일명은 덮어씌운다 - }); - if (error) { - throw new Error(`업로드 실패 : ${error.message}`); - } - // https 문자열로 주소를 알아내서 활용 - const { - data: { publicUrl }, - } = supabase.storage.from('user-images').getPublicUrl(filePath); - return publicUrl; - } catch (error) { - throw new Error(`업로드 오류가 발생했습니다. : ${error}`); - } -}; -// 아바타 이미지는 한장을 유지해야 하므로 모두 제거하는 기능이 필요함 -const cleanupUserAvatars = async (userId: string): Promise => { - try { - const { data, error: listError } = await supabase.storage - .from('user-images') - .list('avatars', { limit: 1000 }); - if (listError) { - console.log(`목록 요청 실패 : ${listError.message}`); + if (limitError) { + throw new Error(`getTodosInfinite limit 오류 : ${limitError.message}`); } - // userId 에 해당하는 것만 필터링하여 삭제해야함. (아무거나 다 지우면 안되는 것 방지) - if (data && data.length > 0) { - const userFile = data.filter(item => item.name.startsWith(`${userId}-`)); - if (userFile && userFile.length > 0) { - const filePath = userFile.map(item => `avatars/${item.name}`); - const { error: removeError } = await supabase.storage.from('user-images').remove(filePath); - if (removeError) { - console.log(`파일 삭제 실패 : ${removeError.message}`); - return false; - } - return true; - } - } - return true; - } catch (error) { - console.log(`아바타 이미지 전체 삭제 오류 : ${error}`); - return false; - } -}; + // 전체 개수 + const totalCount = count || 0; -// 사용자 프로필 이미지 제거 -const removeAvatar = async (userId: string): Promise => { - try { - // 현재 로그인 한 사용자의 avatar_url 을 읽어와야함 - // 여기서 파일명을 추출함 - const profile = await getProfile(userId); - // 사용자가 avatar_url 이 없으면 - if (!profile?.avatar_url) { - return true; // 작업 완료 - } - // 1. 만약 avatar_url 이 존재한다면 이름 파악, 파일 삭제 - let deleteSuccess = false; - try { - // url 에 파일명을 찾아야함 (url 로 변환하면 path와 파일 구분이 수월함) - const url = new URL(profile.avatar_url); - const pathParts = url.pathname.split('/'); - const publicIndex = pathParts.indexOf('public'); - if (publicIndex !== -1 && publicIndex + 1 < pathParts.length) { - const bucketName = pathParts[publicIndex + 1]; - const filePath = pathParts.slice(publicIndex + 2).join('/'); - // 실제로 찾아낸 bucketName 과 filePath 로 삭제 - const { data, error } = await supabase.storage.from(bucketName).remove([filePath]); - if (error) { - throw new Error('파일을 찾았지만, 삭제에 실패하였습니다.'); - } - // 파일 삭제 성공 - deleteSuccess = true; - } - } catch (err) { - console.log(err); - } + // 앞으로 더 가져올 데이터가 있는지? + const hasMore = offset + limit < totalCount; - // 2. 만약 avatar_url 을 제대로 파싱하지 못했다면? - if (!deleteSuccess) { - try { - // 전체 목록을 일단 읽어옴 - const { data: files, error: listError } = await supabase.storage - .from('user-images') - .list('avatars', { limit: 1000 }); - if (!listError && files && files.length > 0) { - const userFiles = files.filter(item => item.name.startsWith(`${userId}-`)); - if (userFiles.length > 0) { - const filePath = userFiles.map(item => `avatars/${item.name}`); - const { error } = await supabase.storage.from('user-images').remove(filePath); - if (!error) { - deleteSuccess = true; - } - } - } - } catch (error) { - console.log(error); - } - } - return true; + // 최종 값을 리턴함 + return { + todos: data || [], + hasMore, + totalCount, + }; } catch (error) { - console.log(error); - return false; + console.log(`getTodosInfinite 오류 : ${error}`); + throw new Error(`getTodosInfinite 오류 : ${error}`); } }; - -// 내보내기 ( 하나하나 export 넣기 귀찮을 시 ) -export { createProfile, getProfile, updateProfile, deleteProfile, uploadAvatar, removeAvatar }; ``` -# 일반적 네비게이션 진행하기 - -- npm 써도 됨 -- npm : https://www.npmjs.com/package/react-paginate -- 수업은 직접 구현 진행해봄 - -## 1. 구현 시나리오 - -- 목표 : 한 화면에 10개의 목록을 표시함 -- 페이지 번호로 네비게이션 함 -- 전체 개수 및 현재 페이지 정보를 출력함 -- supabase 에 todos 를 이용함 - -## 2. 코드 구현 - -### 2.1 /src/service/todoService.ts - -- 페이지 번호와 제한 개수를 이용해서 추출하기 함수 - -```ts -// 페이지 단위로 조각내서 목록 출력하기 -// getTodosPaginated (페이지 번호, 10개) -// getTodosPaginated (1, 10개) -// getTodosPaginated (2, 10개) -export const getTodosPaginated = async ( - page: number = 1, - limit: number = 10, -): Promise<{ todos: Todo[]; totalCount: number; totalPages: number; currentPage: number }> => { - // 시작 지점 ( 만약 page = 2 라면, limit 은 10 ) - // (2-1)*10 = 10 - const from = (page - 1) * limit; - // 제한 지점 (종료) - // 10 + 10 - 1 = 19 - const to = from + limit - 1; - - // 전체 데이터 개수 (행row의 개수) - const { count } = await supabase.from('todos').select('*', { count: 'exact', head: true }); - - // from 부터 to 까지의 상세 데이터 가져오기 - const { data } = await supabase - .from('todos') - .select('*') - .order('created_at', { ascending: false }) - .range(from, to); - - // 편하게 활용하기 - const totalCount = count || 0; - // 몇 페이지인지 계산 (소수점은 올림) - const totalPages = Math.ceil(totalCount / limit); - return { - todos: data || [], - totalCount, - totalPages, - currentPage: page, - }; -}; -``` - -- 전체 todoService.ts +- todoService.ts 전체코드 ```ts import { supabase } from '../lib/supabase'; @@ -615,227 +182,745 @@ export const getTodosPaginated = async ( currentPage: page, }; }; + +// 무한 스크롤 todo 목록 조회 +export const getTodosInfinity = async ( + offset: number = 0, + limit: number = 5, +): Promise<{ todos: Todo[]; hasMore: boolean; totalCount: number }> => { + try { + // 전체 todos 의 Row 개수 + const { count, error: countError } = await supabase + .from('todos') + .select('*', { count: 'exact', head: true }); + if (countError) { + throw new Error(`getTodosInfinity count 오류 : ${countError.message}`); + } + + // 무한 스크롤 데이터 조회 + const { data, error: limitError } = await supabase + .from('todos') + .select('*') + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1); + + if (limitError) { + throw new Error(`getTodosInfinite limit 오류 : ${limitError.message}`); + } + // 전체 개수 + const totalCount = count || 0; + + // 앞으로 더 가져올 데이터가 있는지? + const hasMore = offset + limit < totalCount; + + // 최종 값을 리턴함 + return { + todos: data || [], + hasMore, + totalCount, + }; + } catch (error) { + console.log(`getTodosInfinite 오류 : ${error}`); + throw new Error(`getTodosInfinite 오류 : ${error}`); + } +}; +``` + +## 2. 상태 관리 ( Context State ) + +- 별도로 구성해서 진행해봄 +- /src/contexts/InfinityScrollContext.tsx + +- 첫번째 : 초기값 세팅 + +```tsx +import type { Todo } from '../types/todoType'; + +// 1. 초기값 +type InfinityScrollState = { + todos: Todo[]; + hasMore: boolean; + totalCount: number; + loading: boolean; + loadingMore: boolean; +}; + +const initialState: InfinityScrollState = { + todos: [], + hasMore: false, + totalCount: 0, + loading: false, + loadingMore: false, +}; +``` + +- 2번째 : Action types 정의 + +```tsx +// 2. Action 타입 정의 +enum InfinityScrollActionType { + SET_TODOS = 'SET_TODOS', + SET_LOADING = 'SET_LOADING', + SET_LOADING_MORE = 'SET_LOADING_MORE', + APPEND_TODOS = 'APPEND_TODOS', + ADD_TODO = 'ADD_TODO', + TOGGLE_TODO = 'TOGGLE_TODO', + DELETE_TODO = 'DELETE_TODO', + EDIT_TODO = 'EDIT_TODO', + RESET = 'RESET', +} + +type SetLoadingAction = { type: InfinityScrollActionType.SET_LOADING; payload: boolean }; +type SetLoadingMoreAction = { type: InfinityScrollActionType.SET_LOADING_MORE; payload: boolean }; +type SetTodosAction = { + type: InfinityScrollActionType.SET_TODOS; + payload: { todos: Todo[]; hasMore: boolean; totalCount: number }; +}; +type AppendTodosAction = { + type: InfinityScrollActionType.APPEND_TODOS; + payload: { todos: Todo[]; hasMore: boolean }; +}; +type AddAction = { + type: InfinityScrollActionType.ADD_TODO; + payload: { todo: Todo }; +}; +type ToggleAction = { + type: InfinityScrollActionType.TOGGLE_TODO; + payload: { id: number }; +}; +type DeleteAction = { + type: InfinityScrollActionType.DELETE_TODO; + payload: { id: number }; +}; +type EditAction = { + type: InfinityScrollActionType.EDIT_TODO; + payload: { id: number; title: string }; +}; +type ResetAction = { + type: InfinityScrollActionType.RESET; +}; + +type InfinityScrollAction = + | SetLoadingAction + | SetLoadingMoreAction + | SetTodosAction + | AppendTodosAction + | AddAction + | ToggleAction + | DeleteAction + | EditAction + | ResetAction; +``` + +- 3번째 : reducer 함수 만들기 + +```tsx +// 3. Reducer 함수 +function reducer(state: InfinityScrollState, action: InfinityScrollAction): InfinityScrollState { + switch (action.type) { + case InfinityScrollActionType.SET_LOADING: + return { ...state, loading: action.payload }; + case InfinityScrollActionType.SET_LOADING_MORE: + return { ...state, loadingMore: action.payload }; + case InfinityScrollActionType.SET_TODOS: + return { + ...state, + todos: action.payload.todos, + hasMore: action.payload.hasMore, + totalCount: action.payload.totalCount, + loading: false, + loadingMore: false, + }; + case InfinityScrollActionType.APPEND_TODOS: + // 추가 + return { + ...state, + todos: [...action.payload.todos, ...state.todos], + hasMore: action.payload.hasMore, + loadingMore: false, + }; + case InfinityScrollActionType.ADD_TODO: + return { + ...state, + todos: [action.payload.todo, ...state.todos], + totalCount: state.totalCount + 1, + }; + case InfinityScrollActionType.TOGGLE_TODO: + return { + ...state, + todos: state.todos.map(item => + item.id === action.payload.id ? { ...item, completed: !item.completed } : item, + ), + }; + case InfinityScrollActionType.DELETE_TODO: + return { + ...state, + todos: state.todos.filter(item => item.id !== action.payload.id), + }; + case InfinityScrollActionType.EDIT_TODO: + return { + ...state, + todos: state.todos.map(item => + item.id === action.payload.id ? { ...item, title: action.payload.title } : item, + ), + }; + case InfinityScrollActionType.RESET: + return initialState; + default: + return state; + } +} +``` + +- 4번째 : Context 생성 + +```tsx +// 4. Context 생성 +type InfinityScrollContextValue = { + todos: Todo[]; + hasMore: boolean; + totalCount: number; + loading: boolean; + loadingMore: boolean; + loadingInitialTodos: () => Promise; + loadMoreTodos: () => Promise; + addTodo: (todo: Todo) => void; + toggleTodo: (id: number) => void; + deleteTodo: (id: number) => void; + editTodo: (id: number, title: string) => void; + reset: () => void; +}; +const InfiniteScrollContext = createContext(null); +``` + +- 5번째 : Provider 만들기 + +```tsx +// 5. Provider 생성 +// interface InfinityScrollProviderProps { +// children?: React.ReactNode; +// itemsPerPage: number; +// } +interface InfinityScrollProviderProps extends PropsWithChildren { + itemsPerPage?: number; +} +export const InfinityScrollProvider: React.FC = ({ + children, + itemsPerPage = 5, +}) => { + // ts 자리 + // useReducer 를 활용 + const [state, dispatch] = useReducer(reducer, initialState); + + // 초기 데이터 로드 + const loadingInitialTodos = async (): Promise => { + try { + // 초기 로딩 활성화 시켜줌 + dispatch({ type: InfinityScrollActionType.SET_LOADING, payload: true }); + const result = await getTodosInfinity(0, itemsPerPage); + console.log( + '초기 로드 된 데이터', + result.todos.map(item => ({ + id: item.id, + title: item.title, + created_at: item.created_at, + user_id: item.user_id, + })), + ); + + dispatch({ + type: InfinityScrollActionType.SET_TODOS, + payload: { todos: result.todos, hasMore: result.hasMore, totalCount: result.totalCount }, + }); + } catch (error) { + console.log(`초기 데이터 로드 실패 : ${error}`); + dispatch({ type: InfinityScrollActionType.SET_LOADING, payload: false }); + } + }; + + // 데이터 더보기 기능 + const loadMoreTodos = async (): Promise => { + try { + dispatch({ type: InfinityScrollActionType.SET_LOADING_MORE, payload: true }); + const result = await getTodosInfinity(state.todos.length, itemsPerPage); + console.log( + '추가 로드 된 데이터', + result.todos.map(item => ({ + id: item.id, + title: item.title, + created_at: item.created_at, + user_id: item.user_id, + })), + ); + + dispatch({ + type: InfinityScrollActionType.APPEND_TODOS, + payload: { todos: result.todos, hasMore: result.hasMore }, + }); + } catch (error) { + console.log(`추가 데이터 로드 실패 : ${error}`); + dispatch({ type: InfinityScrollActionType.SET_LOADING_MORE, payload: false }); + } + }; + + // Todo 추가 + const addTodo = (todo: Todo): void => { + dispatch({ type: InfinityScrollActionType.ADD_TODO, payload: { todo } }); + }; + // Todo 토글 + const toggleTodo = (id: number): void => { + dispatch({ type: InfinityScrollActionType.TOGGLE_TODO, payload: { id } }); + }; + // Todo 삭제 + const deleteTodo = (id: number): void => { + dispatch({ type: InfinityScrollActionType.DELETE_TODO, payload: { id } }); + }; + // Todo 수정 + const editTodo = (id: number, title: string): void => { + dispatch({ type: InfinityScrollActionType.EDIT_TODO, payload: { id, title } }); + }; + // Context 상태 초기화 + const reset = (): void => { + dispatch({ type: InfinityScrollActionType.RESET }); + }; + + // 최초 실행시 데이터 로드 + useEffect(() => { + loadingInitialTodos(); + }, []); + + const value: InfinityScrollContextValue = { + todos: state.todos, + hasMore: state.hasMore, + totalCount: state.totalCount, + loading: state.loading, + loadingMore: state.loadingMore, + loadingInitialTodos, + loadMoreTodos, + addTodo, + toggleTodo, + deleteTodo, + editTodo, + reset, + }; + + // tsx 자리 + return {children}; +}; ``` -### 2.2 /src/contexts/TodoContext.tsx +- 6번째 : Custom Hook ```tsx -import React, { +// 6. 커스텀 훅 +export function useInfinityScrollTodos(): InfinityScrollContextValue { + const ctx = useContext(InfinityScrollContext); + if (!ctx) { + throw new Error('InfinityScrollContext가 없습니다.'); + } + return ctx; +} +``` + +## 3. 전체 코드 + +- 전체 InfinityScrollContext.tsx + +```tsx +import { createContext, useContext, useEffect, useReducer, + useState, type PropsWithChildren, } from 'react'; import type { Todo } from '../types/todoType'; -// 전체 DB 가져오기 -import { getTodos, getTodosPaginated } from '../services/todoServices'; +import { getTodosInfinity } from '../services/todoServices'; -/** 1) 상태 타입과 초기값: 항상 Todo[]만 유지 */ -// 초기값 형태가 페이지 객체 형태로 추가됨 -type TodosState = { +// 1. 초기값 +type InfinityScrollState = { todos: Todo[]; + hasMore: boolean; totalCount: number; - totalPages: number; - currentPage: number; + loading: boolean; + loadingMore: boolean; }; -const initialState: TodosState = { + +const initialState: InfinityScrollState = { todos: [], + hasMore: false, totalCount: 0, - totalPages: 0, - currentPage: 1, + loading: false, + loadingMore: false, }; -/** 2) 액션 타입 */ -enum TodoActionType { - ADD = 'ADD', - TOGGLE = 'TOGGLE', - DELETE = 'DELETE', - EDIT = 'EDIT', - // Supabase todos 의 목록을 읽어오는 Action Type +// 2. Action 타입 정의 +enum InfinityScrollActionType { SET_TODOS = 'SET_TODOS', + SET_LOADING = 'SET_LOADING', + SET_LOADING_MORE = 'SET_LOADING_MORE', + APPEND_TODOS = 'APPEND_TODOS', + ADD_TODO = 'ADD_TODO', + TOGGLE_TODO = 'TOGGLE_TODO', + DELETE_TODO = 'DELETE_TODO', + EDIT_TODO = 'EDIT_TODO', + RESET = 'RESET', } -// 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 SetLoadingAction = { type: InfinityScrollActionType.SET_LOADING; payload: boolean }; +type SetLoadingMoreAction = { type: InfinityScrollActionType.SET_LOADING_MORE; payload: boolean }; type SetTodosAction = { - type: TodoActionType.SET_TODOS; - payload: { todos: Todo[]; totalCount: number; totalPages: number; currentPage: number }; + type: InfinityScrollActionType.SET_TODOS; + payload: { todos: Todo[]; hasMore: boolean; totalCount: number }; +}; +type AppendTodosAction = { + type: InfinityScrollActionType.APPEND_TODOS; + payload: { todos: Todo[]; hasMore: boolean }; +}; +type AddAction = { + type: InfinityScrollActionType.ADD_TODO; + payload: { todo: Todo }; +}; +type ToggleAction = { + type: InfinityScrollActionType.TOGGLE_TODO; + payload: { id: number }; }; -type TodoAction = AddAction | ToggleAction | DeleteAction | EditAction | SetTodosAction; +type DeleteAction = { + type: InfinityScrollActionType.DELETE_TODO; + payload: { id: number }; +}; +type EditAction = { + type: InfinityScrollActionType.EDIT_TODO; + payload: { id: number; title: string }; +}; +type ResetAction = { + type: InfinityScrollActionType.RESET; +}; + +type InfinityScrollAction = + | SetLoadingAction + | SetLoadingMoreAction + | SetTodosAction + | AppendTodosAction + | AddAction + | ToggleAction + | DeleteAction + | EditAction + | ResetAction; + +// 3. Reducer 함수 -// 3. Reducer : 반환 타입을 명시해 주면 더 명확해짐 -// action 은 {type:"문자열", payload: 재료 } 형태 -function reducer(state: TodosState, action: TodoAction): TodosState { +function reducer(state: InfinityScrollState, action: InfinityScrollAction): InfinityScrollState { 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, totalCount, totalPages, currentPage } = action.payload; - return { ...state, todos, totalCount, totalPages, currentPage }; - } + case InfinityScrollActionType.SET_LOADING: + return { ...state, loading: action.payload }; + case InfinityScrollActionType.SET_LOADING_MORE: + return { ...state, loadingMore: action.payload }; + case InfinityScrollActionType.SET_TODOS: + return { + ...state, + todos: action.payload.todos, + hasMore: action.payload.hasMore, + totalCount: action.payload.totalCount, + loading: false, + loadingMore: false, + }; + case InfinityScrollActionType.APPEND_TODOS: + // 추가 + return { + ...state, + todos: [...action.payload.todos, ...state.todos], + hasMore: action.payload.hasMore, + loadingMore: false, + }; + case InfinityScrollActionType.ADD_TODO: + return { + ...state, + todos: [action.payload.todo, ...state.todos], + totalCount: state.totalCount + 1, + }; + case InfinityScrollActionType.TOGGLE_TODO: + return { + ...state, + todos: state.todos.map(item => + item.id === action.payload.id ? { ...item, completed: !item.completed } : item, + ), + }; + case InfinityScrollActionType.DELETE_TODO: + return { + ...state, + todos: state.todos.filter(item => item.id !== action.payload.id), + }; + case InfinityScrollActionType.EDIT_TODO: + return { + ...state, + todos: state.todos.map(item => + item.id === action.payload.id ? { ...item, title: action.payload.title } : item, + ), + }; + case InfinityScrollActionType.RESET: + return initialState; default: return state; } } -// Context 타입 : todos는 Todo[]로 고정, addTodo도 Todo를 받도록 함 -// 만들어진 context 가 관리하는 value 의 모양 -type TodoContextValue = { +// 4. Context 생성 +type InfinityScrollContextValue = { todos: Todo[]; + hasMore: boolean; totalCount: number; - totalPages: number; - currentPage: number; - itemsPerPage: number; + loading: boolean; + loadingMore: boolean; + loadingInitialTodos: () => Promise; + loadMoreTodos: () => Promise; addTodo: (todo: Todo) => void; toggleTodo: (id: number) => void; deleteTodo: (id: number) => void; - editTodo: (id: number, editTitle: string) => void; - loadTodos: (page: number, limit: number) => void; + editTodo: (id: number, title: string) => void; + reset: () => void; }; +const InfinityScrollContext = createContext(null); -const TodoContext = createContext(null); - -// 5. Provider -// type TodoProviderProps = { -// children: React.ReactNode; -// }; -// export const TodoProvider = ({ children }: TodoProviderProps) => { - -// export const TodoProvider = ({ children }: React.PropsWithChildren) => { - -// props 정의하기 (방법 1번 - 비추천) -// interface TodoProviderProps { +// 5. Provider 생성 +// interface InfinityScrollProviderProps { // children?: React.ReactNode; -// currentPage?: number; -// limit?: number; +// itemsPerPage: number; // } - -// props 정의하기 (방법 2번 - 상속형 추천) -interface TodoProviderProps extends PropsWithChildren { - currentPage?: number; - limit?: number; +interface InfinityScrollProviderProps extends PropsWithChildren { + itemsPerPage?: number; } - -export const TodoProvider: React.FC = ({ +export const InfinityScrollProvider: React.FC = ({ children, - currentPage = 1, - limit = 10, -}): JSX.Element => { - // useReducer 로 상태 관리 + itemsPerPage = 5, +}) => { + // ts 자리 + // useReducer 를 활용 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 loadingInitialTodos = async (): Promise => { + try { + // 초기 로딩 활성화 시켜줌 + dispatch({ type: InfinityScrollActionType.SET_LOADING, payload: true }); + const result = await getTodosInfinity(0, itemsPerPage); + console.log( + '초기 로드 된 데이터', + result.todos.map(item => ({ + id: item.id, + title: item.title, + created_at: item.created_at, + user_id: item.user_id, + })), + ); + + dispatch({ + type: InfinityScrollActionType.SET_TODOS, + payload: { todos: result.todos, hasMore: result.hasMore, totalCount: result.totalCount }, + }); + } catch (error) { + console.log(`초기 데이터 로드 실패 : ${error}`); + dispatch({ type: InfinityScrollActionType.SET_LOADING, payload: false }); + } + }; + + // 데이터 더보기 기능 + const loadMoreTodos = async (): Promise => { + try { + dispatch({ type: InfinityScrollActionType.SET_LOADING_MORE, payload: true }); + const result = await getTodosInfinity(state.todos.length, itemsPerPage); + console.log( + '추가 로드 된 데이터', + result.todos.map(item => ({ + id: item.id, + title: item.title, + created_at: item.created_at, + user_id: item.user_id, + })), + ); + + dispatch({ + type: InfinityScrollActionType.APPEND_TODOS, + payload: { todos: result.todos, hasMore: result.hasMore }, + }); + } catch (error) { + console.log(`추가 데이터 로드 실패 : ${error}`); + dispatch({ type: InfinityScrollActionType.SET_LOADING_MORE, payload: false }); + } }; - const toggleTodo = (id: number) => { - dispatch({ type: TodoActionType.TOGGLE, payload: { id } }); + + // Todo 추가 + const addTodo = (todo: Todo): void => { + dispatch({ type: InfinityScrollActionType.ADD_TODO, payload: { todo } }); }; - const deleteTodo = (id: number) => { - dispatch({ type: TodoActionType.DELETE, payload: { id } }); + // Todo 토글 + const toggleTodo = (id: number): void => { + dispatch({ type: InfinityScrollActionType.TOGGLE_TODO, payload: { id } }); }; - const editTodo = (id: number, editTitle: string) => { - dispatch({ type: TodoActionType.EDIT, payload: { id, title: editTitle } }); + // Todo 삭제 + const deleteTodo = (id: number): void => { + dispatch({ type: InfinityScrollActionType.DELETE_TODO, payload: { id } }); }; - // 실행시 state { todos }를 업데이트함 - // reducer 함수를 실행함 - const setTodos = (todos: Todo[], totalCount: number, totalPages: number, currentPage: number) => { - dispatch({ - type: TodoActionType.SET_TODOS, - payload: { todos, totalCount, totalPages, currentPage }, - }); + // Todo 수정 + const editTodo = (id: number, title: string): void => { + dispatch({ type: InfinityScrollActionType.EDIT_TODO, payload: { id, title } }); }; - // Supabase 의 목록 읽기 함수 표현식 - // 비동기 데이터베이스 접근 - // const loadTodos = async (): Promise => { - // try { - // const result = await getTodos(); - // setTodos(result ?? []); - // } catch (error) { - // console.error('[loadTodos] 실패:', error); - // } - // }; - const loadTodos = async (page: number, limit: number): Promise => { - try { - const result = await getTodosPaginated(page, limit); - // 현재 페이지가 비어있고, 첫 페이지가 아니라면 이전 페이지를 출력 - if (result.todos.length === 0 && result.totalPages > 0 && page > 1) { - const prevPageResult = await getTodosPaginated(page - 1, limit); - setTodos( - prevPageResult.todos, - prevPageResult.totalCount, - prevPageResult.totalPages, - prevPageResult.currentPage, - ); - } else { - setTodos(result.todos, result.totalCount, result.totalPages, result.currentPage); - } - } catch (error) { - console.log(`목록 가져오기 오류 : ${error}`); - } + // Context 상태 초기화 + const reset = (): void => { + dispatch({ type: InfinityScrollActionType.RESET }); }; - // 페이지가 바뀌면 다시 실행하도록 해야 함 + // 최초 실행시 데이터 로드 useEffect(() => { - void loadTodos(currentPage, limit); - }, [currentPage, limit]); + loadingInitialTodos(); + }, []); - // value 전달할 값 - const value: TodoContextValue = { + const value: InfinityScrollContextValue = { todos: state.todos, + hasMore: state.hasMore, totalCount: state.totalCount, - totalPages: state.totalPages, - currentPage: state.currentPage, - itemsPerPage: limit, + loading: state.loading, + loadingMore: state.loadingMore, + loadingInitialTodos, + loadMoreTodos, addTodo, toggleTodo, deleteTodo, editTodo, - loadTodos, + reset, }; - return {children}; + + // tsx 자리 + return {children}; }; -// 6. custom hook 생성 -export function useTodos(): TodoContextValue { - const ctx = useContext(TodoContext); +// 6. 커스텀 훅 +export function useInfinityScrollTodos(): InfinityScrollContextValue { + const ctx = useContext(InfinityScrollContext); if (!ctx) { - throw new Error('context를 찾을 수 없습니다.'); + throw new Error('InfinityScrollContext가 없습니다.'); } - return ctx; // value 를 리턴함 + return ctx; } ``` -### 2.3 pagenation 을 위한 컴포넌트 생성 +## 4. 활용 + +- /src/pages/TodosInfinityPage.tsx 생성 + +## 5. Router 추가 + +- App.tsx + +```tsx +import { Link, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; +import AuthCallbackPage from './pages/AuthCallbackPage'; +import HomePage from './pages/HomePage'; +import SignInPage from './pages/SignInPage'; +import SignUpPage from './pages/SignUpPage'; +import TodosPage from './pages/TodosPage'; +import Protected from './contexts/Protected'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; +import ProfilePage from './pages/ProfilePage'; +import AdminPage from './pages/AdminPage'; +import TodosInfinityPage from './pages/TodosInfinityPage'; + +const TopBar = () => { + const { signOut, user } = useAuth(); + // 관리자인 경우 메뉴 추가로 출력하기 + // isAdmin 에는 boolean 임. ( true / false ) + const isAdmin = user?.email === 'lynn9702@naver.com'; // 관리자 이메일 입력 + return ( + + ); +}; -- /src/components/Pagenation.tsx 생성 (재활용 가능) +function App() { + return ( + +
          + + + + } /> + } /> + } /> + } /> + {/* Protected 로 감싸주기 */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + +
          +
          + ); +} -### 2.4 /src/pages/TodosPage.tsx +export default App; +``` diff --git a/src/App.tsx b/src/App.tsx index 1de4d55..378ab7b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import Protected from './contexts/Protected'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import ProfilePage from './pages/ProfilePage'; import AdminPage from './pages/AdminPage'; +import TodosInfinityPage from './pages/TodosInfinityPage'; const TopBar = () => { const { signOut, user } = useAuth(); @@ -28,6 +29,9 @@ const TopBar = () => { 할 일 + + 무한 스크롤 할 일 + 내 프로필 @@ -71,6 +75,14 @@ function App() { } /> + + + + } + /> + item.id === action.payload.id ? { ...item, completed: !item.completed } : item, + ), + }; + case InfinityScrollActionType.DELETE_TODO: + return { + ...state, + todos: state.todos.filter(item => item.id !== action.payload.id), + }; + case InfinityScrollActionType.EDIT_TODO: + return { + ...state, + todos: state.todos.map(item => + item.id === action.payload.id ? { ...item, title: action.payload.title } : item, + ), + }; + case InfinityScrollActionType.RESET: + return initialState; + default: + return state; + } +} + +// 4. Context 생성 +type InfinityScrollContextValue = { + todos: Todo[]; + hasMore: boolean; + totalCount: number; + loading: boolean; + loadingMore: boolean; + loadingInitialTodos: () => Promise; + loadMoreTodos: () => Promise; + addTodo: (todo: Todo) => void; + toggleTodo: (id: number) => void; + deleteTodo: (id: number) => void; + editTodo: (id: number, title: string) => void; + reset: () => void; +}; +const InfinityScrollContext = createContext(null); + +// 5. Provider 생성 +// interface InfinityScrollProviderProps { +// children?: React.ReactNode; +// itemsPerPage: number; +// } +interface InfinityScrollProviderProps extends PropsWithChildren { + itemsPerPage?: number; +} +export const InfinityScrollProvider: React.FC = ({ + children, + itemsPerPage = 5, +}) => { + // ts 자리 + // useReducer 를 활용 + const [state, dispatch] = useReducer(reducer, initialState); + + // 초기 데이터 로드 + const loadingInitialTodos = async (): Promise => { + try { + // 초기 로딩 활성화 시켜줌 + dispatch({ type: InfinityScrollActionType.SET_LOADING, payload: true }); + const result = await getTodosInfinity(0, itemsPerPage); + console.log( + '초기 로드 된 데이터', + result.todos.map(item => ({ + id: item.id, + title: item.title, + created_at: item.created_at, + user_id: item.user_id, + })), + ); + + dispatch({ + type: InfinityScrollActionType.SET_TODOS, + payload: { todos: result.todos, hasMore: result.hasMore, totalCount: result.totalCount }, + }); + } catch (error) { + console.log(`초기 데이터 로드 실패 : ${error}`); + dispatch({ type: InfinityScrollActionType.SET_LOADING, payload: false }); + } + }; + + // 데이터 더보기 기능 + const loadMoreTodos = async (): Promise => { + try { + dispatch({ type: InfinityScrollActionType.SET_LOADING_MORE, payload: true }); + const result = await getTodosInfinity(state.todos.length, itemsPerPage); + console.log( + '추가 로드 된 데이터', + result.todos.map(item => ({ + id: item.id, + title: item.title, + created_at: item.created_at, + user_id: item.user_id, + })), + ); + + dispatch({ + type: InfinityScrollActionType.APPEND_TODOS, + payload: { todos: result.todos, hasMore: result.hasMore }, + }); + } catch (error) { + console.log(`추가 데이터 로드 실패 : ${error}`); + dispatch({ type: InfinityScrollActionType.SET_LOADING_MORE, payload: false }); + } + }; + + // Todo 추가 + const addTodo = (todo: Todo): void => { + dispatch({ type: InfinityScrollActionType.ADD_TODO, payload: { todo } }); + }; + // Todo 토글 + const toggleTodo = (id: number): void => { + dispatch({ type: InfinityScrollActionType.TOGGLE_TODO, payload: { id } }); + }; + // Todo 삭제 + const deleteTodo = (id: number): void => { + dispatch({ type: InfinityScrollActionType.DELETE_TODO, payload: { id } }); + }; + // Todo 수정 + const editTodo = (id: number, title: string): void => { + dispatch({ type: InfinityScrollActionType.EDIT_TODO, payload: { id, title } }); + }; + // Context 상태 초기화 + const reset = (): void => { + dispatch({ type: InfinityScrollActionType.RESET }); + }; + + // 최초 실행시 데이터 로드 + useEffect(() => { + loadingInitialTodos(); + }, []); + + const value: InfinityScrollContextValue = { + todos: state.todos, + hasMore: state.hasMore, + totalCount: state.totalCount, + loading: state.loading, + loadingMore: state.loadingMore, + loadingInitialTodos, + loadMoreTodos, + addTodo, + toggleTodo, + deleteTodo, + editTodo, + reset, + }; + + // tsx 자리 + return {children}; +}; + +// 6. 커스텀 훅 +export function useInfinityScroll(): InfinityScrollContextValue { + const ctx = useContext(InfinityScrollContext); + if (!ctx) { + throw new Error('InfinityScrollContext가 없습니다.'); + } + return ctx; +} diff --git a/src/pages/TodosInfinityPage.tsx b/src/pages/TodosInfinityPage.tsx new file mode 100644 index 0000000..0c09b2e --- /dev/null +++ b/src/pages/TodosInfinityPage.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import { InfinityScrollProvider, useInfinityScroll } from '../contexts/InfinityScrollContext'; +import type { Profile } from '../types/todoType'; +import { getProfile } from '../lib/profile'; + +// 용서하세요. 입력창 컴포넌트임다 컴포넌트라 const +const InfinityTodoWrite = () => { + return
          입력창
          ; +}; + +// 용서하세요..ㅋㅋ 목록 컴포넌트 +const InfinityTodoList = () => { + const { loading, todos, totalCount, editTodo, toggleTodo, deleteTodo } = useInfinityScroll(); + const { user } = useAuth(); + const [profile, setProfile] = useState(null); + + // 사용자 프로필 가져오기 + useEffect(() => { + const loadProifle = async () => { + if (user?.id) { + const userProfile = await getProfile(user.id); + setProfile(userProfile); + } + }; + loadProifle(); + }, [user?.id]); + + // 번호 계산 함수 (최신글이 높은 번호를 가지도록) + const getGlobalIndex = (index: number) => { + // 무한 스크롤 시에 계산해서 번호 출력 + const globalIndex = totalCount - index; + console.log( + `번호 계산 - index : ${index}, totalCount : ${totalCount}, globalIndex: ${globalIndex}`, + ); + return globalIndex; + }; + + // 날짜 포맷팅 함수 ( 자주 쓰여서 유틸 폴더 만들어서 관리해도 좋음 ) + const formatDate = (dateString: string | null): string => { + if (!dateString) return '날짜 없음'; + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }; + + // 수정 상태 관리 + const [editingId, setEditingId] = useState(null); + const [editingTitle, setEditingTitle] = useState(''); + + // 수정 시작 + const handleEditStart = (todo: any) => { + setEditingId(todo.id); + setEditingTitle(todo.title); + }; + + // 수정 취소 + const handleEditCancel = () => { + setEditingId(null); + setEditingTitle(''); + }; + + // 수정 저장 + const handleEditSave = async (id: number) => { + if (!editingTitle.trim()) { + alert('제목을 입력하세요.'); + return; + } + try { + editTodo(id, editingTitle); + setEditingId(null); + setEditingTitle(''); + } catch (error) { + console.log(error); + alert('수정에 실패했습니다.'); + } + }; + + if (loading) { + return
          데이터 로딩중...
          ; + } + return ( +
          +

          + TodoList (무한스크롤){profile?.nickname && {profile.nickname} 님의 할 일} + {todos.length === 0 ? ( +

          등록된 할 일이 없습니다.

          + ) : ( +
          +
            + {todos.map((item, index) => ( +
          • + {/* 번호 표시 */} + {getGlobalIndex(index)} + {/* 체크 박스 */} + + {/* 제목과 날짜 출력 */} +
            + {editingId === item.id ? ( + setEditingTitle(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + handleEditSave(item.id); + } else if (e.key === 'Escape') { + handleEditCancel(); + } + }} + /> + ) : ( + {item.title} + )} + + 작성일 : {formatDate(item.created_at)} +
            + {/* 버튼들 */} + {editingId === item.id ? ( + <> + + + + ) : ( + <> + + + + )} +
          • + ))} +
          +
          + )} +

          +
          + ); +}; + +function TodosInfinityPage() { + return ( +
          + +
          +

          무한 스크롤 Todo 목록

          +
          + +
          +
          + +
          +
          +
          +
          + ); +} + +export default TodosInfinityPage; diff --git a/src/services/todoServices.ts b/src/services/todoServices.ts index 03a5905..5883d14 100644 --- a/src/services/todoServices.ts +++ b/src/services/todoServices.ts @@ -88,13 +88,6 @@ export const toggleTodo = async (id: number, completed: boolean): Promise => { + try { + // 전체 todos 의 Row 개수 + const { count, error: countError } = await supabase + .from('todos') + .select('*', { count: 'exact', head: true }); + if (countError) { + throw new Error(`getTodosInfinity count 오류 : ${countError.message}`); + } + + // 무한 스크롤 데이터 조회 + const { data, error: limitError } = await supabase + .from('todos') + .select('*') + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1); + + if (limitError) { + throw new Error(`getTodosInfinite limit 오류 : ${limitError.message}`); + } + // 전체 개수 + const totalCount = count || 0; + + // 앞으로 더 가져올 데이터가 있는지? + const hasMore = offset + limit < totalCount; + + // 최종 값을 리턴함 + return { + todos: data || [], + hasMore, + totalCount, + }; + } catch (error) { + console.log(`getTodosInfinite 오류 : ${error}`); + throw new Error(`getTodosInfinite 오류 : ${error}`); + } +}; From 7fb03d9bc02951bb0f77c3b50fcbffc1a8443b2d Mon Sep 17 00:00:00 2001 From: suha720 Date: Fri, 12 Sep 2025 13:11:35 +0900 Subject: [PATCH 24/51] =?UTF-8?q?[docs]=20loop=20=EC=9D=B4=ED=95=B4?= =?UTF-8?q?=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 638 +++++++++++++++++++++++++ src/contexts/InfinityScrollContext.tsx | 75 ++- src/pages/TodosInfinityPage.tsx | 300 +++++++++--- 3 files changed, 939 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 11f9c55..3d012e1 100644 --- a/README.md +++ b/README.md @@ -809,6 +809,254 @@ export function useInfinityScrollTodos(): InfinityScrollContextValue { - /src/pages/TodosInfinityPage.tsx 생성 +```tsx +import { useEffect, useState } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import { InfinityScrollProvider, useInfinityScroll } from '../contexts/InfinityScrollContext'; +import type { Profile } from '../types/todoType'; +import { getProfile } from '../lib/profile'; + +// 용서하세요. 입력창 컴포넌트임다 컴포넌트라 const +const InfinityTodoWrite = () => { + const { addTodo, loadingInitialTodos } = useInfinityScroll(); + + const [title, setTitle] = useState(''); + const handleChange = async (e: React.ChangeEvent) => { + setTitle(e.target.value); + }; + const handleKeyDown = async (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSave(); + } + }; + const handleSave = async (): Promise => { + if (!title.trim()) { + alert('제목을 입력하세요'); + return; + } + try { + // 새 할 일 추가 + await addTodo(title); + // 다시 데이터를 로딩함 + await loadingInitialTodos(); + setTitle(''); + } catch (error) { + console.log('등록에 오류가 발생 : ', error); + alert(`등록에 오류가 발생 : ${error}`); + } + }; + + return ( +
          +

          할 일 작성

          +
          + handleChange(e)} + onKeyDown={e => handleKeyDown(e)} + placeholder="할 일을 입력 해주세요." + className="border" + /> + +
          +
          + ); +}; + +// 용서하세요..ㅋㅋ 목록 컴포넌트 +const InfinityTodoList = () => { + const { loading, todos, totalCount, editTodo, toggleTodo, deleteTodo, loadingInitialTodos } = + useInfinityScroll(); + const { user } = useAuth(); + const [profile, setProfile] = useState(null); + + // 사용자 프로필 가져오기 + useEffect(() => { + const loadProifle = async () => { + if (user?.id) { + const userProfile = await getProfile(user.id); + setProfile(userProfile); + } + }; + loadProifle(); + }, [user?.id]); + + // 번호 계산 함수 (최신글이 높은 번호를 가지도록) + const getGlobalIndex = (index: number) => { + // 무한 스크롤 시에 계산해서 번호 출력 + const globalIndex = totalCount - index; + // console.log( + // `번호 계산 - index : ${index}, totalCount : ${totalCount}, globalIndex: ${globalIndex}`, + // ); + return globalIndex; + }; + + // 날짜 포맷팅 함수 ( 자주 쓰여서 유틸 폴더 만들어서 관리해도 좋음 ) + const formatDate = (dateString: string | null): string => { + if (!dateString) return '날짜 없음'; + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }; + + // 수정 상태 관리 + const [editingId, setEditingId] = useState(null); + const [editingTitle, setEditingTitle] = useState(''); + + // 수정 시작 + const handleEditStart = (todo: any) => { + setEditingId(todo.id); + setEditingTitle(todo.title); + }; + + // 수정 취소 + const handleEditCancel = () => { + setEditingId(null); + setEditingTitle(''); + }; + + // 수정 저장 + const handleEditSave = async (id: number) => { + if (!editingTitle.trim()) { + alert('제목을 입력하세요.'); + return; + } + try { + editTodo(id, editingTitle); + setEditingId(null); + setEditingTitle(''); + } catch (error) { + console.log(error); + alert('수정에 실패했습니다.'); + } + }; + + const handleToggle = async (id: number) => { + try { + // Context 의 state 를 업데이트함 + await toggleTodo(id); + } catch (error) { + console.log('토글 실패 :', error); + alert('상태 변경에 실패하였습니다.'); + } + }; + + const handleDelete = async (id: number) => { + if (window.confirm('정말 삭제하시겠습니까?')) { + try { + // id 를 삭제 + await deleteTodo(id); + // 삭제 이후 번호를 갱신해서 정리해줌 + await loadingInitialTodos(); + } catch (error) { + console.log('삭제에 실패하였습니다.'); + alert('삭제에 실패하였습니다.'); + } + } + }; + + if (loading) { + return
          데이터 로딩중...
          ; + } + return ( +
          +

          + TodoList (무한스크롤){profile?.nickname && {profile.nickname} 님의 할 일} + {todos.length === 0 ? ( +

          등록된 할 일이 없습니다.

          + ) : ( +
          +
            + {todos.map((item, index) => ( +
          • + {/* 번호 표시 */} + {getGlobalIndex(index)} + {/* 체크 박스 */} + handleToggle(item.id)} + /> + {/* 제목과 날짜 출력 */} +
            + {editingId === item.id ? ( + setEditingTitle(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + handleEditSave(item.id); + } else if (e.key === 'Escape') { + handleEditCancel(); + } + }} + /> + ) : ( + {item.title} + )} + + 작성일 : {formatDate(item.created_at)} +
            + {/* 버튼들 */} + {editingId === item.id ? ( + <> + + + + ) : ( + <> + + + + )} +
          • + ))} +
          +
          + )} +

          +
          + ); +}; + +function TodosInfinityPage() { + return ( +
          + +
          +

          무한 스크롤 Todo 목록

          +
          + +
          +
          + +
          +
          +
          +
          + ); +} + +export default TodosInfinityPage; +``` + ## 5. Router 추가 - App.tsx @@ -924,3 +1172,393 @@ function App() { export default App; ``` + +- InfinityContext.tsx + +```tsx +import { + createContext, + useContext, + useEffect, + useReducer, + useState, + type PropsWithChildren, +} from 'react'; +import type { Todo } from '../types/todoType'; +import { + getTodosInfinity, + updateTodo, + deleteTodo as updateDeletedServiceTodo, + toggleTodo as updateServiceToggTodo, + createTodo, +} from '../services/todoServices'; +import { supabase } from '../lib/supabase'; + +// 1. 초기값 +type InfinityScrollState = { + todos: Todo[]; + hasMore: boolean; + totalCount: number; + loading: boolean; + loadingMore: boolean; +}; + +const initialState: InfinityScrollState = { + todos: [], + hasMore: false, + totalCount: 0, + loading: false, + loadingMore: false, +}; + +// 2. Action 타입 정의 +enum InfinityScrollActionType { + SET_TODOS = 'SET_TODOS', + SET_LOADING = 'SET_LOADING', + SET_LOADING_MORE = 'SET_LOADING_MORE', + APPEND_TODOS = 'APPEND_TODOS', + ADD_TODO = 'ADD_TODO', + TOGGLE_TODO = 'TOGGLE_TODO', + DELETE_TODO = 'DELETE_TODO', + EDIT_TODO = 'EDIT_TODO', + RESET = 'RESET', +} + +type SetLoadingAction = { type: InfinityScrollActionType.SET_LOADING; payload: boolean }; +type SetLoadingMoreAction = { type: InfinityScrollActionType.SET_LOADING_MORE; payload: boolean }; +type SetTodosAction = { + type: InfinityScrollActionType.SET_TODOS; + payload: { todos: Todo[]; hasMore: boolean; totalCount: number }; +}; +type AppendTodosAction = { + type: InfinityScrollActionType.APPEND_TODOS; + payload: { todos: Todo[]; hasMore: boolean }; +}; +type AddAction = { + type: InfinityScrollActionType.ADD_TODO; + payload: { todo: Todo }; +}; +type ToggleAction = { + type: InfinityScrollActionType.TOGGLE_TODO; + payload: { id: number }; +}; +type DeleteAction = { + type: InfinityScrollActionType.DELETE_TODO; + payload: { id: number }; +}; +type EditAction = { + type: InfinityScrollActionType.EDIT_TODO; + payload: { id: number; title: string }; +}; +type ResetAction = { + type: InfinityScrollActionType.RESET; +}; + +type InfinityScrollAction = + | SetLoadingAction + | SetLoadingMoreAction + | SetTodosAction + | AppendTodosAction + | AddAction + | ToggleAction + | DeleteAction + | EditAction + | ResetAction; + +// 3. Reducer 함수 + +function reducer(state: InfinityScrollState, action: InfinityScrollAction): InfinityScrollState { + switch (action.type) { + case InfinityScrollActionType.SET_LOADING: + return { ...state, loading: action.payload }; + case InfinityScrollActionType.SET_LOADING_MORE: + return { ...state, loadingMore: action.payload }; + case InfinityScrollActionType.SET_TODOS: + return { + ...state, + todos: action.payload.todos, + hasMore: action.payload.hasMore, + totalCount: action.payload.totalCount, + loading: false, + loadingMore: false, + }; + case InfinityScrollActionType.APPEND_TODOS: + // 추가 + return { + ...state, + todos: [...action.payload.todos, ...state.todos], + hasMore: action.payload.hasMore, + loadingMore: false, + }; + case InfinityScrollActionType.ADD_TODO: + return { + ...state, + todos: [action.payload.todo, ...state.todos], + totalCount: state.totalCount + 1, + }; + case InfinityScrollActionType.TOGGLE_TODO: + return { + ...state, + todos: state.todos.map(item => + item.id === action.payload.id ? { ...item, completed: !item.completed } : item, + ), + }; + case InfinityScrollActionType.DELETE_TODO: + return { + ...state, + todos: state.todos.filter(item => item.id !== action.payload.id), + }; + case InfinityScrollActionType.EDIT_TODO: + return { + ...state, + todos: state.todos.map(item => + item.id === action.payload.id ? { ...item, title: action.payload.title } : item, + ), + }; + case InfinityScrollActionType.RESET: + return initialState; + default: + return state; + } +} + +// 4. Context 생성 +type InfinityScrollContextValue = { + todos: Todo[]; + hasMore: boolean; + totalCount: number; + loading: boolean; + loadingMore: boolean; + loadingInitialTodos: () => Promise; + loadMoreTodos: () => Promise; + addTodo: (title: string) => Promise; + toggleTodo: (id: number) => Promise; + deleteTodo: (id: number) => Promise; + editTodo: (id: number, title: string) => Promise; + reset: () => void; +}; +const InfinityScrollContext = createContext(null); + +// 5. Provider 생성 +// interface InfinityScrollProviderProps { +// children?: React.ReactNode; +// itemsPerPage: number; +// } +interface InfinityScrollProviderProps extends PropsWithChildren { + itemsPerPage?: number; +} +export const InfinityScrollProvider: React.FC = ({ + children, + itemsPerPage = 5, +}) => { + // ts 자리 + // useReducer 를 활용 + const [state, dispatch] = useReducer(reducer, initialState); + + // 초기 데이터 로드 + const loadingInitialTodos = async (): Promise => { + try { + // 초기 로딩 활성화 시켜줌 + dispatch({ type: InfinityScrollActionType.SET_LOADING, payload: true }); + const result = await getTodosInfinity(0, itemsPerPage); + console.log( + '초기 로드 된 데이터', + result.todos.map(item => ({ + id: item.id, + title: item.title, + created_at: item.created_at, + user_id: item.user_id, + })), + ); + + dispatch({ + type: InfinityScrollActionType.SET_TODOS, + payload: { todos: result.todos, hasMore: result.hasMore, totalCount: result.totalCount }, + }); + } catch (error) { + console.log(`초기 데이터 로드 실패 : ${error}`); + dispatch({ type: InfinityScrollActionType.SET_LOADING, payload: false }); + } + }; + + // 데이터 더보기 기능 + const loadMoreTodos = async (): Promise => { + try { + dispatch({ type: InfinityScrollActionType.SET_LOADING_MORE, payload: true }); + const result = await getTodosInfinity(state.todos.length, itemsPerPage); + console.log( + '추가 로드 된 데이터', + result.todos.map(item => ({ + id: item.id, + title: item.title, + created_at: item.created_at, + user_id: item.user_id, + })), + ); + + dispatch({ + type: InfinityScrollActionType.APPEND_TODOS, + payload: { todos: result.todos, hasMore: result.hasMore }, + }); + } catch (error) { + console.log(`추가 데이터 로드 실패 : ${error}`); + dispatch({ type: InfinityScrollActionType.SET_LOADING_MORE, payload: false }); + } + }; + + // Todo 추가 + const addTodo = async (title: string): Promise => { + try { + const result = await createTodo({ title }); + if (!result) { + console.log('글 등록에 실패 하였습니다.'); + return; + } + // DB 업데이트 후 State 업데이트 + dispatch({ type: InfinityScrollActionType.ADD_TODO, payload: { todo: result } }); + } catch (error) { + console.log(`새 Todo 등록 오류 : ${error}`); + } + }; + // Todo 토글 + const toggleTodo = async (id: number): Promise => { + try { + // 현재 전달 된 id 에 해당하는 todo 항목의 completed 를 파악한다. + const currentTodo = state.todos.find(item => item.id === id); + if (!currentTodo) { + console.log('Todo 를 찾지 못했습니다. :', id); + return; + } + const result = await updateServiceToggTodo(id, !currentTodo.completed); + if (result) { + // DB 업데이트 후 state 업데이트 + dispatch({ type: InfinityScrollActionType.TOGGLE_TODO, payload: { id } }); + } else { + console.log('할 일 상태 업데이트 실패'); + } + } catch (error) { + console.log(`상태 변경 오류 : ${error}`); + } + }; + // Todo 삭제 + const deleteTodo = async (id: number): Promise => { + try { + await updateDeletedServiceTodo(id); + // DB 업데이트 후 state 처리 + dispatch({ type: InfinityScrollActionType.DELETE_TODO, payload: { id } }); + } catch (error) { + console.log(`삭제 오류 : ${error}`); + } + }; + // Todo 수정 + const editTodo = async (id: number, title: string): Promise => { + try { + const updatedTodo = await updateTodo(id, { title }); + if (updatedTodo) { + // 아래는 그냥 state 만 업데이트함. (실제 DB에 업데이트 하고 => state 업데이트 과정이 필요함.) + dispatch({ type: InfinityScrollActionType.EDIT_TODO, payload: { id, title } }); + } else { + console.log('업데이트에 실패하였습니다.'); + } + } catch (error) { + console.log(`업데이트 오류 : ${error}`); + } + }; + // Context 상태 초기화 + const reset = (): void => { + dispatch({ type: InfinityScrollActionType.RESET }); + }; + + // 최초 실행시 데이터 로드 + useEffect(() => { + loadingInitialTodos(); + }, []); + + const value: InfinityScrollContextValue = { + todos: state.todos, + hasMore: state.hasMore, + totalCount: state.totalCount, + loading: state.loading, + loadingMore: state.loadingMore, + loadingInitialTodos, + loadMoreTodos, + addTodo, + toggleTodo, + deleteTodo, + editTodo, + reset, + }; + + // tsx 자리 + return {children}; +}; + +// 6. 커스텀 훅 +export function useInfinityScroll(): InfinityScrollContextValue { + const ctx = useContext(InfinityScrollContext); + if (!ctx) { + throw new Error('InfinityScrollContext가 없습니다.'); + } + return ctx; +} +``` + +## 6. 무한 스크롤 구현 + +- IntersectionObserver 를 이용함 +- `웹브라우저에 내장` 된 API 중 하나임 +- 요소 즉, 대상이 되는 태그(element)가 + - viewport(화면에 보이는 영역), + - 또는 특정 스크롤 영역과 교차(intersect)하는지 감시하는 도구임. + - intersect 는 DOM 요소가 화면에 보이거나, 사라지게 하는 등 을 말함 +- `스크롤 이벤트를 사용하지 않고도 자동으로 화면에 보이는 순간을 체크`할 수 있음. + +### 6.1 기본 문법 + +```js +const observer = new IntersectionObserver((entries, observer) => { + // entries: 관찰 중인 모든 요소의 교차 상태 목록 + // observer: 지금 만든 옵저버 자기 자신 +}); + +// 특정 DOM 요소 관찰 시작 +observer.observe(domElement); + +// 관찰 해제 +observer.unobserve(domElement); + +// 모든 관찰 중지 +observer.disconnect(); +``` + +### 6.2 예제 + +- `
          ` + +```js +const target = document.getElementById('target'); + +const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + console.log('화면에 보임!', entry); + } else { + console.log('화면에서 나감!', entry); + } + }); +}); + +observer.observe(target); +``` + +### 6.3 옵션 + +```js +const options = { + root: null, // 관찰 기준 영역 (null이면 브라우저 뷰포트) + rootMargin: '0px', // root 바깥쪽 여백 (미리 감지하고 싶을 때 '200px' 같은 값) + threshold: 0.5, // 요소가 50% 보였을 때만 트리거 +}; + +const observer = new IntersectionObserver(callback, options); +``` diff --git a/src/contexts/InfinityScrollContext.tsx b/src/contexts/InfinityScrollContext.tsx index 270c4d3..dddcc35 100644 --- a/src/contexts/InfinityScrollContext.tsx +++ b/src/contexts/InfinityScrollContext.tsx @@ -7,7 +7,14 @@ import { type PropsWithChildren, } from 'react'; import type { Todo } from '../types/todoType'; -import { getTodosInfinity } from '../services/todoServices'; +import { + getTodosInfinity, + updateTodo, + deleteTodo as updateDeletedServiceTodo, + toggleTodo as updateServiceToggTodo, + createTodo, +} from '../services/todoServices'; +import { supabase } from '../lib/supabase'; // 1. 초기값 type InfinityScrollState = { @@ -146,10 +153,10 @@ type InfinityScrollContextValue = { loadingMore: boolean; loadingInitialTodos: () => Promise; loadMoreTodos: () => Promise; - addTodo: (todo: Todo) => void; - toggleTodo: (id: number) => void; - deleteTodo: (id: number) => void; - editTodo: (id: number, title: string) => void; + addTodo: (title: string) => Promise; + toggleTodo: (id: number) => Promise; + deleteTodo: (id: number) => Promise; + editTodo: (id: number, title: string) => Promise; reset: () => void; }; const InfinityScrollContext = createContext(null); @@ -222,20 +229,62 @@ export const InfinityScrollProvider: React.FC = ({ }; // Todo 추가 - const addTodo = (todo: Todo): void => { - dispatch({ type: InfinityScrollActionType.ADD_TODO, payload: { todo } }); + const addTodo = async (title: string): Promise => { + try { + const result = await createTodo({ title }); + if (!result) { + console.log('글 등록에 실패 하였습니다.'); + return; + } + // DB 업데이트 후 State 업데이트 + dispatch({ type: InfinityScrollActionType.ADD_TODO, payload: { todo: result } }); + } catch (error) { + console.log(`새 Todo 등록 오류 : ${error}`); + } }; // Todo 토글 - const toggleTodo = (id: number): void => { - dispatch({ type: InfinityScrollActionType.TOGGLE_TODO, payload: { id } }); + const toggleTodo = async (id: number): Promise => { + try { + // 현재 전달 된 id 에 해당하는 todo 항목의 completed 를 파악한다. + const currentTodo = state.todos.find(item => item.id === id); + if (!currentTodo) { + console.log('Todo 를 찾지 못했습니다. :', id); + return; + } + const result = await updateServiceToggTodo(id, !currentTodo.completed); + if (result) { + // DB 업데이트 후 state 업데이트 + dispatch({ type: InfinityScrollActionType.TOGGLE_TODO, payload: { id } }); + } else { + console.log('할 일 상태 업데이트 실패'); + } + } catch (error) { + console.log(`상태 변경 오류 : ${error}`); + } }; // Todo 삭제 - const deleteTodo = (id: number): void => { - dispatch({ type: InfinityScrollActionType.DELETE_TODO, payload: { id } }); + const deleteTodo = async (id: number): Promise => { + try { + await updateDeletedServiceTodo(id); + // DB 업데이트 후 state 처리 + dispatch({ type: InfinityScrollActionType.DELETE_TODO, payload: { id } }); + } catch (error) { + console.log(`삭제 오류 : ${error}`); + } }; // Todo 수정 - const editTodo = (id: number, title: string): void => { - dispatch({ type: InfinityScrollActionType.EDIT_TODO, payload: { id, title } }); + const editTodo = async (id: number, title: string): Promise => { + try { + const updatedTodo = await updateTodo(id, { title }); + if (updatedTodo) { + // 아래는 그냥 state 만 업데이트함. (실제 DB에 업데이트 하고 => state 업데이트 과정이 필요함.) + dispatch({ type: InfinityScrollActionType.EDIT_TODO, payload: { id, title } }); + } else { + console.log('업데이트에 실패하였습니다.'); + } + } catch (error) { + console.log(`업데이트 오류 : ${error}`); + } }; // Context 상태 초기화 const reset = (): void => { diff --git a/src/pages/TodosInfinityPage.tsx b/src/pages/TodosInfinityPage.tsx index 0c09b2e..d5a5f4d 100644 --- a/src/pages/TodosInfinityPage.tsx +++ b/src/pages/TodosInfinityPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useAuth } from '../contexts/AuthContext'; import { InfinityScrollProvider, useInfinityScroll } from '../contexts/InfinityScrollContext'; import type { Profile } from '../types/todoType'; @@ -6,12 +6,66 @@ import { getProfile } from '../lib/profile'; // 용서하세요. 입력창 컴포넌트임다 컴포넌트라 const const InfinityTodoWrite = () => { - return
          입력창
          ; + const { addTodo, loadingInitialTodos } = useInfinityScroll(); + + 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 = async (): Promise => { + if (!title.trim()) { + alert('제목을 입력하세요'); + return; + } + try { + // 새 할 일 추가 + await addTodo(title); + // 다시 데이터를 로딩함 + await loadingInitialTodos(); + setTitle(''); + } catch (error) { + console.log('등록에 오류가 발생 : ', error); + alert(`등록에 오류가 발생 : ${error}`); + } + }; + + return ( +
          +

          할 일 작성

          +
          + handleChange(e)} + onKeyDown={e => handleKeyDown(e)} + placeholder="할 일을 입력 해주세요." + className="border" + /> + +
          +
          + ); }; // 용서하세요..ㅋㅋ 목록 컴포넌트 const InfinityTodoList = () => { - const { loading, todos, totalCount, editTodo, toggleTodo, deleteTodo } = useInfinityScroll(); + const { + loading, + loadingMore, + loadMoreTodos, + hasMore, + todos, + totalCount, + editTodo, + toggleTodo, + deleteTodo, + loadingInitialTodos, + } = useInfinityScroll(); const { user } = useAuth(); const [profile, setProfile] = useState(null); @@ -26,13 +80,92 @@ const InfinityTodoList = () => { loadProifle(); }, [user?.id]); + // IntersectionObserver 를 이용한 무한 스크롤 + + // 1. IntersectionObserver 를 저장하는 ref + const observerRef = useRef(null); + // 2. 목록 더보기 할 때 보여줄 로딩 창 + const loadingRef = useRef(null); + // 3. 연속 로딩 방지를 위한 타이머 ref + const debounceTimerRef = useRef(null); + // 4. 데이터 로드 스크롤 바 하단에 위치 문제로 연속 호출 되는 부분 제어 + const [isInCooldown, setIsInCooldown] = useState(false); + const cooldownTimerRef = useRef(null); + + useEffect(() => { + // 화면에서 사라질 때 메모리 정리 : 클린업 함수 + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + if (cooldownTimerRef.current) { + clearTimeout(cooldownTimerRef.current); + } + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, []); + + // 연속 로딩 방지 + useEffect(() => { + if (loadingMore) { + if (cooldownTimerRef.current) clearTimeout(cooldownTimerRef.current); + } else { + // observerRef 를 비활성화 하기 위해서 + setIsInCooldown(true); + cooldownTimerRef.current = setTimeout(() => { + setIsInCooldown(false); + }, 1000); + } + }, [loadingMore]); + + /** + * 목록에 마지막 요소를 등록할 것임. + * 목록의 마지막 요소가 화면에 들어오면 isIntersecting 을 true 로 바꿈 + * 아직 더 불러올 데이터가 있으면 loadMore 을 실행하고 ==> 데이터를 추가함 + * 새로운 목록이 랜더링 되면 새로운 마지막 요소에 다시 옵저버를 붙임 + * 위의 과정을 반복해서 ==> 데이터의 끝까지 반복함 + */ + + // 마지막 todo 항목이 화면에 보이면 자동으로 다음 데이터를 불러들이는 함수 + // 여기서는 useCallback 을 사용합니다. + // - 함수가 리랜더링 될 때마다 새롭게 만들면 성능 이슈가 있음 + // - 함수가 새로 만들어져야 하는 경우는 의존성 배열에 추가하겠다 + // 의존성 배열에는 loadingMore, hasMore, loadMoreTodos 변경될 때 + + const lastTodoElementRef = useCallback( + (node: HTMLElement | null) => { + if (observerRef.current) observerRef.current.disconnect(); + if (loadingMore || !hasMore || !node || isInCooldown) return; + + observerRef.current = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting && hasMore && !loadingMore && !isInCooldown) { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + debounceTimerRef.current = setTimeout(() => { + if (!loadingMore && hasMore && !isInCooldown) { + loadMoreTodos(); + } + }, 500); + } + }, + { + threshold: 0.8, + }, + ); + + observerRef.current.observe(node); + }, + [loadingMore, hasMore, loadMoreTodos, isInCooldown], + ); + // 번호 계산 함수 (최신글이 높은 번호를 가지도록) const getGlobalIndex = (index: number) => { // 무한 스크롤 시에 계산해서 번호 출력 const globalIndex = totalCount - index; - console.log( - `번호 계산 - index : ${index}, totalCount : ${totalCount}, globalIndex: ${globalIndex}`, - ); return globalIndex; }; @@ -81,6 +214,32 @@ const InfinityTodoList = () => { } }; + // 토글 + const handleToggle = async (id: number) => { + try { + // Context 의 state 를 업데이트함 + await toggleTodo(id); + } catch (error) { + console.log('토글 실패 :', error); + alert('상태 변경에 실패하였습니다.'); + } + }; + + // 삭제 + const handleDelete = async (id: number) => { + if (window.confirm('정말 삭제하시겠습니까?')) { + try { + // id 를 삭제 + await deleteTodo(id); + // 삭제 이후 번호를 갱신해서 정리해줌 + await loadingInitialTodos(); + } catch (error) { + console.log('삭제에 실패하였습니다.'); + alert('삭제에 실패하였습니다.'); + } + } + }; + if (loading) { return
          데이터 로딩중...
          ; } @@ -88,63 +247,82 @@ const InfinityTodoList = () => {

          TodoList (무한스크롤){profile?.nickname && {profile.nickname} 님의 할 일} - {todos.length === 0 ? ( -

          등록된 할 일이 없습니다.

          - ) : ( -
          -
            - {todos.map((item, index) => ( -
          • - {/* 번호 표시 */} - {getGlobalIndex(index)} - {/* 체크 박스 */} - - {/* 제목과 날짜 출력 */} -
            - {editingId === item.id ? ( - setEditingTitle(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') { - handleEditSave(item.id); - } else if (e.key === 'Escape') { - handleEditCancel(); - } - }} - /> - ) : ( - {item.title} - )} - - 작성일 : {formatDate(item.created_at)} -
            - {/* 버튼들 */} +

          + {todos.length === 0 ? ( +

          등록된 할 일이 없습니다.

          + ) : ( +
          +
            + {todos.map((item, index) => ( + // 마지막 요소 태그인지를 연결함. (마지막 배열의 index 인지 비교하면 됨.) +
          • + {/* 번호 표시 */} + {getGlobalIndex(index)} + {/* 체크 박스 */} + handleToggle(item.id)} + /> + {/* 제목과 날짜 출력 */} +
            {editingId === item.id ? ( - <> - - - + setEditingTitle(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + handleEditSave(item.id); + } else if (e.key === 'Escape') { + handleEditCancel(); + } + }} + /> ) : ( - <> - - - + {item.title} )} -
          • - ))} -
          -
          - )} - + + 작성일 : {formatDate(item.created_at)} +
          + {/* 버튼들 */} + {editingId === item.id ? ( + <> + + + + ) : ( + <> + + + + )} + + ))} +
        +
        + )} + {/* 무한 목록 로딩용 인디케이터 */} + {loadingMore && ( +
        + 더 많은 할 일을 불러오는 중... +
        + )} + + {/* 더이상 로드할 데이터가 없을 때 */} + {todos.length > 0 && !hasMore && ( +
        모든 데이터를 불러왔습니다.
        + )}
        ); }; @@ -152,7 +330,7 @@ const InfinityTodoList = () => { function TodosInfinityPage() { return (
        - +

        무한 스크롤 Todo 목록

        From e5bcffdd97211768ea18edc92efbc9f53e7cb7a1 Mon Sep 17 00:00:00 2001 From: suha720 Date: Fri, 12 Sep 2025 15:08:58 +0900 Subject: [PATCH 25/51] =?UTF-8?q?[docs]=20Infinite=20Loop=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 | 12 ++ package-lock.json | 22 ++++ package.json | 1 + src/App.tsx | 8 +- src/contexts/InfinityScrollContext.tsx | 164 +++++++++++++------------ src/pages/TodosInfinityPage.tsx | 130 ++++---------------- src/services/todoServices.ts | 4 +- 7 files changed, 151 insertions(+), 190 deletions(-) diff --git a/README.md b/README.md index 3d012e1..a51a13d 100644 --- a/README.md +++ b/README.md @@ -1562,3 +1562,15 @@ const options = { const observer = new IntersectionObserver(callback, options); ``` + +## 7. 무한 스크롤 구현 + +- https://www.npmjs.com/package/react-infinite-scroll-component +- https://blog.itcode.dev/posts/2024/07/22/react-component-infinite-scroll +- https://goddino.tistory.com/entry/react-react-infinite-scroll-component-%EC%82%AC%EC%9A%A9%EB%B2%95-ft-%EB%AC%B4%ED%95%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4 + +### 7.1. npm 설치 + +```bash +npm i react-infinite-scroll-component +``` diff --git a/package-lock.json b/package-lock.json index 4f23a3d..f4133b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@supabase/supabase-js": "^2.56.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-infinite-scroll-component": "^6.1.0", "react-router-dom": "^6.30.1" }, "devDependencies": { @@ -5626,6 +5627,18 @@ "react": "^18.3.1" } }, + "node_modules/react-infinite-scroll-component": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz", + "integrity": "sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==", + "license": "MIT", + "dependencies": { + "throttle-debounce": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6508,6 +6521,15 @@ "node": ">=0.8" } }, + "node_modules/throttle-debounce": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz", + "integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", diff --git a/package.json b/package.json index 8a3264f..efc4b57 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@supabase/supabase-js": "^2.56.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-infinite-scroll-component": "^6.1.0", "react-router-dom": "^6.30.1" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index 378ab7b..67c8c21 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,7 @@ import Protected from './contexts/Protected'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import ProfilePage from './pages/ProfilePage'; import AdminPage from './pages/AdminPage'; -import TodosInfinityPage from './pages/TodosInfinityPage'; +import TodosInfinitePage from './pages/TodosInfinityPage'; const TopBar = () => { const { signOut, user } = useAuth(); @@ -29,7 +29,7 @@ const TopBar = () => { 할 일 - + 무한 스크롤 할 일 @@ -76,10 +76,10 @@ function App() { } /> - + } /> diff --git a/src/contexts/InfinityScrollContext.tsx b/src/contexts/InfinityScrollContext.tsx index dddcc35..0e9e114 100644 --- a/src/contexts/InfinityScrollContext.tsx +++ b/src/contexts/InfinityScrollContext.tsx @@ -1,43 +1,41 @@ import { + act, createContext, useContext, useEffect, useReducer, - useState, type PropsWithChildren, } from 'react'; import type { Todo } from '../types/todoType'; import { - getTodosInfinity, + getTodosInfinite, updateTodo, - deleteTodo as updateDeletedServiceTodo, - toggleTodo as updateServiceToggTodo, + toggleTodo as updatedServiceToggleTodo, + deleteTodo as deletedServiceTodo, createTodo, } from '../services/todoServices'; -import { supabase } from '../lib/supabase'; // 1. 초기값 -type InfinityScrollState = { +type InfiniteScrollState = { todos: Todo[]; hasMore: boolean; totalCount: number; loading: boolean; loadingMore: boolean; }; - -const initialState: InfinityScrollState = { +const initialState: InfiniteScrollState = { todos: [], - hasMore: false, + hasMore: true, totalCount: 0, loading: false, loadingMore: false, }; // 2. Action 타입 정의 -enum InfinityScrollActionType { - SET_TODOS = 'SET_TODOS', +enum InfiniteScrollActionType { SET_LOADING = 'SET_LOADING', SET_LOADING_MORE = 'SET_LOADING_MORE', + SET_TODOS = 'SET_TODOS', APPEND_TODOS = 'APPEND_TODOS', ADD_TODO = 'ADD_TODO', TOGGLE_TODO = 'TOGGLE_TODO', @@ -46,56 +44,55 @@ enum InfinityScrollActionType { RESET = 'RESET', } -type SetLoadingAction = { type: InfinityScrollActionType.SET_LOADING; payload: boolean }; -type SetLoadingMoreAction = { type: InfinityScrollActionType.SET_LOADING_MORE; payload: boolean }; +type SetLoadingAction = { type: InfiniteScrollActionType.SET_LOADING; payload: boolean }; +type SetLoadingMoreAction = { type: InfiniteScrollActionType.SET_LOADING_MORE; payload: boolean }; type SetTodosAction = { - type: InfinityScrollActionType.SET_TODOS; + type: InfiniteScrollActionType.SET_TODOS; payload: { todos: Todo[]; hasMore: boolean; totalCount: number }; }; type AppendTodosAction = { - type: InfinityScrollActionType.APPEND_TODOS; + type: InfiniteScrollActionType.APPEND_TODOS; payload: { todos: Todo[]; hasMore: boolean }; }; type AddAction = { - type: InfinityScrollActionType.ADD_TODO; + type: InfiniteScrollActionType.ADD_TODO; payload: { todo: Todo }; }; type ToggleAction = { - type: InfinityScrollActionType.TOGGLE_TODO; + type: InfiniteScrollActionType.TOGGLE_TODO; payload: { id: number }; }; type DeleteAction = { - type: InfinityScrollActionType.DELETE_TODO; + type: InfiniteScrollActionType.DELETE_TODO; payload: { id: number }; }; type EditAction = { - type: InfinityScrollActionType.EDIT_TODO; + type: InfiniteScrollActionType.EDIT_TODO; payload: { id: number; title: string }; }; type ResetAction = { - type: InfinityScrollActionType.RESET; + type: InfiniteScrollActionType.RESET; }; -type InfinityScrollAction = +type InfiniteScrollAction = | SetLoadingAction | SetLoadingMoreAction | SetTodosAction | AppendTodosAction | AddAction | ToggleAction - | DeleteAction | EditAction + | DeleteAction | ResetAction; -// 3. Reducer 함수 - -function reducer(state: InfinityScrollState, action: InfinityScrollAction): InfinityScrollState { +// 3. 리듀서 함수 +function reducer(state: InfiniteScrollState, action: InfiniteScrollAction): InfiniteScrollState { switch (action.type) { - case InfinityScrollActionType.SET_LOADING: + case InfiniteScrollActionType.SET_LOADING: return { ...state, loading: action.payload }; - case InfinityScrollActionType.SET_LOADING_MORE: + case InfiniteScrollActionType.SET_LOADING_MORE: return { ...state, loadingMore: action.payload }; - case InfinityScrollActionType.SET_TODOS: + case InfiniteScrollActionType.SET_TODOS: return { ...state, todos: action.payload.todos, @@ -104,48 +101,51 @@ function reducer(state: InfinityScrollState, action: InfinityScrollAction): Infi loading: false, loadingMore: false, }; - case InfinityScrollActionType.APPEND_TODOS: + case InfiniteScrollActionType.APPEND_TODOS: // 추가 return { ...state, - todos: [...action.payload.todos, ...state.todos], + todos: [...state.todos, ...action.payload.todos], hasMore: action.payload.hasMore, loadingMore: false, }; - case InfinityScrollActionType.ADD_TODO: + case InfiniteScrollActionType.ADD_TODO: return { ...state, todos: [action.payload.todo, ...state.todos], totalCount: state.totalCount + 1, }; - case InfinityScrollActionType.TOGGLE_TODO: + case InfiniteScrollActionType.TOGGLE_TODO: return { ...state, todos: state.todos.map(item => item.id === action.payload.id ? { ...item, completed: !item.completed } : item, ), }; - case InfinityScrollActionType.DELETE_TODO: + case InfiniteScrollActionType.DELETE_TODO: return { ...state, todos: state.todos.filter(item => item.id !== action.payload.id), + totalCount: Math.max(0, state.totalCount - 1), }; - case InfinityScrollActionType.EDIT_TODO: + + case InfiniteScrollActionType.EDIT_TODO: return { ...state, todos: state.todos.map(item => item.id === action.payload.id ? { ...item, title: action.payload.title } : item, ), }; - case InfinityScrollActionType.RESET: + + case InfiniteScrollActionType.RESET: return initialState; + default: return state; } } - // 4. Context 생성 -type InfinityScrollContextValue = { +type InfiniteScrollContextValue = { todos: Todo[]; hasMore: boolean; totalCount: number; @@ -159,17 +159,18 @@ type InfinityScrollContextValue = { editTodo: (id: number, title: string) => Promise; reset: () => void; }; -const InfinityScrollContext = createContext(null); +const InfiniteScrollContext = createContext(null); // 5. Provider 생성 -// interface InfinityScrollProviderProps { +// interface InfiniteScrollProviderProps { // children?: React.ReactNode; // itemsPerPage: number; // } -interface InfinityScrollProviderProps extends PropsWithChildren { +interface InfiniteScrollProviderProps extends PropsWithChildren { itemsPerPage?: number; } -export const InfinityScrollProvider: React.FC = ({ + +export const InfiniteScrollProvider: React.FC = ({ children, itemsPerPage = 5, }) => { @@ -180,51 +181,58 @@ export const InfinityScrollProvider: React.FC = ({ // 초기 데이터 로드 const loadingInitialTodos = async (): Promise => { try { - // 초기 로딩 활성화 시켜줌 - dispatch({ type: InfinityScrollActionType.SET_LOADING, payload: true }); - const result = await getTodosInfinity(0, itemsPerPage); + // 초기로딩 활성화 + dispatch({ type: InfiniteScrollActionType.SET_LOADING, payload: true }); + const result = await getTodosInfinite(0, itemsPerPage); + console.log( - '초기 로드 된 데이터', + '초기로드 된 데이터 ', result.todos.map(item => ({ id: item.id, title: item.title, - created_at: item.created_at, + create_at: item.created_at, user_id: item.user_id, })), ); dispatch({ - type: InfinityScrollActionType.SET_TODOS, + type: InfiniteScrollActionType.SET_TODOS, payload: { todos: result.todos, hasMore: result.hasMore, totalCount: result.totalCount }, }); } catch (error) { console.log(`초기 데이터 로드 실패 : ${error}`); - dispatch({ type: InfinityScrollActionType.SET_LOADING, payload: false }); + dispatch({ type: InfiniteScrollActionType.SET_LOADING, payload: false }); } }; - // 데이터 더보기 기능 + // 데이터 더 보기 기능 const loadMoreTodos = async (): Promise => { + // 이미 로딩 중이거나 더 이상 불러올 데이터가 없으면 중단 + if (state.loadingMore || !state.hasMore) { + return; + } + try { - dispatch({ type: InfinityScrollActionType.SET_LOADING_MORE, payload: true }); - const result = await getTodosInfinity(state.todos.length, itemsPerPage); + dispatch({ type: InfiniteScrollActionType.SET_LOADING_MORE, payload: true }); + const result = await getTodosInfinite(state.todos.length, itemsPerPage); console.log( - '추가 로드 된 데이터', + '추가로 로드된 데이터 ', result.todos.map(item => ({ id: item.id, title: item.title, - created_at: item.created_at, + create_at: item.created_at, user_id: item.user_id, })), ); + // 데이터가 실제로 로드되었을 때만 상태 업데이트 dispatch({ - type: InfinityScrollActionType.APPEND_TODOS, + type: InfiniteScrollActionType.APPEND_TODOS, payload: { todos: result.todos, hasMore: result.hasMore }, }); } catch (error) { console.log(`추가 데이터 로드 실패 : ${error}`); - dispatch({ type: InfinityScrollActionType.SET_LOADING_MORE, payload: false }); + dispatch({ type: InfiniteScrollActionType.SET_LOADING_MORE, payload: false }); } }; @@ -233,62 +241,66 @@ export const InfinityScrollProvider: React.FC = ({ try { const result = await createTodo({ title }); if (!result) { - console.log('글 등록에 실패 하였습니다.'); + console.log('글 등록에 실패하였습니다.'); return; } // DB 업데이트 후 State 업데이트 - dispatch({ type: InfinityScrollActionType.ADD_TODO, payload: { todo: result } }); + dispatch({ type: InfiniteScrollActionType.ADD_TODO, payload: { todo: result } }); } catch (error) { - console.log(`새 Todo 등록 오류 : ${error}`); + console.log(`새 Todo 등록 오류 : ${error} `); } }; + // Todo 토글 const toggleTodo = async (id: number): Promise => { try { - // 현재 전달 된 id 에 해당하는 todo 항목의 completed 를 파악한다. + // 현재 전달된 id 에 해당하는 todo 항목의 completed 를 파악한다. const currentTodo = state.todos.find(item => item.id === id); if (!currentTodo) { - console.log('Todo 를 찾지 못했습니다. :', id); + console.log('Todo 를 찾지 못했습니다 : ', id); return; } - const result = await updateServiceToggTodo(id, !currentTodo.completed); + const result = await updatedServiceToggleTodo(id, !currentTodo.completed); if (result) { // DB 업데이트 후 state 업데이트 - dispatch({ type: InfinityScrollActionType.TOGGLE_TODO, payload: { id } }); + dispatch({ type: InfiniteScrollActionType.TOGGLE_TODO, payload: { id } }); } else { - console.log('할 일 상태 업데이트 실패'); + console.log('할일 상태 업데이트 실패 '); } } catch (error) { - console.log(`상태 변경 오류 : ${error}`); + console.log(`상태변경 오류 : ${error} `); } }; + // Todo 삭제 const deleteTodo = async (id: number): Promise => { try { - await updateDeletedServiceTodo(id); + await deletedServiceTodo(id); // DB 업데이트 후 state 처리 - dispatch({ type: InfinityScrollActionType.DELETE_TODO, payload: { id } }); + dispatch({ type: InfiniteScrollActionType.DELETE_TODO, payload: { id } }); } catch (error) { - console.log(`삭제 오류 : ${error}`); + console.log(`삭제 오류 : ${error} `); } }; + // Todo 수정 const editTodo = async (id: number, title: string): Promise => { try { const updatedTodo = await updateTodo(id, { title }); if (updatedTodo) { - // 아래는 그냥 state 만 업데이트함. (실제 DB에 업데이트 하고 => state 업데이트 과정이 필요함.) - dispatch({ type: InfinityScrollActionType.EDIT_TODO, payload: { id, title } }); + // 아래는 그냥 state 만 업데이트 한다. (실제 DB에 업데이트하고 ==> State) + dispatch({ type: InfiniteScrollActionType.EDIT_TODO, payload: { id, title } }); } else { console.log('업데이트에 실패하였습니다.'); } } catch (error) { - console.log(`업데이트 오류 : ${error}`); + console.log(`업데이트 오류 : ${error} `); } }; + // Context 상태 초기화 const reset = (): void => { - dispatch({ type: InfinityScrollActionType.RESET }); + dispatch({ type: InfiniteScrollActionType.RESET }); }; // 최초 실행시 데이터 로드 @@ -296,7 +308,7 @@ export const InfinityScrollProvider: React.FC = ({ loadingInitialTodos(); }, []); - const value: InfinityScrollContextValue = { + const value: InfiniteScrollContextValue = { todos: state.todos, hasMore: state.hasMore, totalCount: state.totalCount, @@ -312,14 +324,14 @@ export const InfinityScrollProvider: React.FC = ({ }; // tsx 자리 - return {children}; + return {children}; }; // 6. 커스텀 훅 -export function useInfinityScroll(): InfinityScrollContextValue { - const ctx = useContext(InfinityScrollContext); +export function useInfiniteScroll(): InfiniteScrollContextValue { + const ctx = useContext(InfiniteScrollContext); if (!ctx) { - throw new Error('InfinityScrollContext가 없습니다.'); + throw new Error('InfiniteScrollContext 컨텍스트가 없어요.'); } return ctx; } diff --git a/src/pages/TodosInfinityPage.tsx b/src/pages/TodosInfinityPage.tsx index d5a5f4d..c7b7269 100644 --- a/src/pages/TodosInfinityPage.tsx +++ b/src/pages/TodosInfinityPage.tsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useAuth } from '../contexts/AuthContext'; -import { InfinityScrollProvider, useInfinityScroll } from '../contexts/InfinityScrollContext'; import type { Profile } from '../types/todoType'; import { getProfile } from '../lib/profile'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import { InfiniteScrollProvider, useInfiniteScroll } from '../contexts/InfinityScrollContext'; // 용서하세요. 입력창 컴포넌트임다 컴포넌트라 const -const InfinityTodoWrite = () => { - const { addTodo, loadingInitialTodos } = useInfinityScroll(); +const InfiniteTodoWrite = () => { + const { addTodo, loadingInitialTodos } = useInfiniteScroll(); const [title, setTitle] = useState(''); const handleChange = (e: React.ChangeEvent) => { @@ -53,7 +54,7 @@ const InfinityTodoWrite = () => { }; // 용서하세요..ㅋㅋ 목록 컴포넌트 -const InfinityTodoList = () => { +const InfiniteTodoList = () => { const { loading, loadingMore, @@ -65,7 +66,7 @@ const InfinityTodoList = () => { toggleTodo, deleteTodo, loadingInitialTodos, - } = useInfinityScroll(); + } = useInfiniteScroll(); const { user } = useAuth(); const [profile, setProfile] = useState(null); @@ -80,88 +81,6 @@ const InfinityTodoList = () => { loadProifle(); }, [user?.id]); - // IntersectionObserver 를 이용한 무한 스크롤 - - // 1. IntersectionObserver 를 저장하는 ref - const observerRef = useRef(null); - // 2. 목록 더보기 할 때 보여줄 로딩 창 - const loadingRef = useRef(null); - // 3. 연속 로딩 방지를 위한 타이머 ref - const debounceTimerRef = useRef(null); - // 4. 데이터 로드 스크롤 바 하단에 위치 문제로 연속 호출 되는 부분 제어 - const [isInCooldown, setIsInCooldown] = useState(false); - const cooldownTimerRef = useRef(null); - - useEffect(() => { - // 화면에서 사라질 때 메모리 정리 : 클린업 함수 - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - if (cooldownTimerRef.current) { - clearTimeout(cooldownTimerRef.current); - } - if (observerRef.current) { - observerRef.current.disconnect(); - } - }; - }, []); - - // 연속 로딩 방지 - useEffect(() => { - if (loadingMore) { - if (cooldownTimerRef.current) clearTimeout(cooldownTimerRef.current); - } else { - // observerRef 를 비활성화 하기 위해서 - setIsInCooldown(true); - cooldownTimerRef.current = setTimeout(() => { - setIsInCooldown(false); - }, 1000); - } - }, [loadingMore]); - - /** - * 목록에 마지막 요소를 등록할 것임. - * 목록의 마지막 요소가 화면에 들어오면 isIntersecting 을 true 로 바꿈 - * 아직 더 불러올 데이터가 있으면 loadMore 을 실행하고 ==> 데이터를 추가함 - * 새로운 목록이 랜더링 되면 새로운 마지막 요소에 다시 옵저버를 붙임 - * 위의 과정을 반복해서 ==> 데이터의 끝까지 반복함 - */ - - // 마지막 todo 항목이 화면에 보이면 자동으로 다음 데이터를 불러들이는 함수 - // 여기서는 useCallback 을 사용합니다. - // - 함수가 리랜더링 될 때마다 새롭게 만들면 성능 이슈가 있음 - // - 함수가 새로 만들어져야 하는 경우는 의존성 배열에 추가하겠다 - // 의존성 배열에는 loadingMore, hasMore, loadMoreTodos 변경될 때 - - const lastTodoElementRef = useCallback( - (node: HTMLElement | null) => { - if (observerRef.current) observerRef.current.disconnect(); - if (loadingMore || !hasMore || !node || isInCooldown) return; - - observerRef.current = new IntersectionObserver( - entries => { - if (entries[0].isIntersecting && hasMore && !loadingMore && !isInCooldown) { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - debounceTimerRef.current = setTimeout(() => { - if (!loadingMore && hasMore && !isInCooldown) { - loadMoreTodos(); - } - }, 500); - } - }, - { - threshold: 0.8, - }, - ); - - observerRef.current.observe(node); - }, - [loadingMore, hasMore, loadMoreTodos, isInCooldown], - ); - // 번호 계산 함수 (최신글이 높은 번호를 가지도록) const getGlobalIndex = (index: number) => { // 무한 스크롤 시에 계산해서 번호 출력 @@ -251,11 +170,17 @@ const InfinityTodoList = () => { {todos.length === 0 ? (

        등록된 할 일이 없습니다.

        ) : ( -
        + // 무한 스크롤 라이브러리 적용 + 데이터를 불러오는 중...
        } + endMessage={
        모든 데이터를 불러왔습니다.
        } + >
          {todos.map((item, index) => ( - // 마지막 요소 태그인지를 연결함. (마지막 배열의 index 인지 비교하면 됨.) -
        • +
        • {/* 번호 표시 */} {getGlobalIndex(index)} {/* 체크 박스 */} @@ -310,39 +235,28 @@ const InfinityTodoList = () => {
        • ))}
        -
        - )} - {/* 무한 목록 로딩용 인디케이터 */} - {loadingMore && ( -
        - 더 많은 할 일을 불러오는 중... -
        - )} - - {/* 더이상 로드할 데이터가 없을 때 */} - {todos.length > 0 && !hasMore && ( -
        모든 데이터를 불러왔습니다.
        + )}
        ); }; -function TodosInfinityPage() { +function TodosInfinitePage() { return (
        - +

        무한 스크롤 Todo 목록

        - +
        - +
        -
        +
        ); } -export default TodosInfinityPage; +export default TodosInfinitePage; diff --git a/src/services/todoServices.ts b/src/services/todoServices.ts index 5883d14..2fcd993 100644 --- a/src/services/todoServices.ts +++ b/src/services/todoServices.ts @@ -126,7 +126,7 @@ export const getTodosPaginated = async ( }; // 무한 스크롤 todo 목록 조회 -export const getTodosInfinity = async ( +export const getTodosInfinite = async ( offset: number = 0, limit: number = 5, ): Promise<{ todos: Todo[]; hasMore: boolean; totalCount: number }> => { @@ -136,7 +136,7 @@ export const getTodosInfinity = async ( .from('todos') .select('*', { count: 'exact', head: true }); if (countError) { - throw new Error(`getTodosInfinity count 오류 : ${countError.message}`); + throw new Error(`getTodosInfinite count 오류 : ${countError.message}`); } // 무한 스크롤 데이터 조회 From a375ca81d0bbb22b75ef3d9b2b787a56d94b1f97 Mon Sep 17 00:00:00 2001 From: suha720 Date: Mon, 15 Sep 2025 14:20:48 +0900 Subject: [PATCH 26/51] [style] tailwind css --- package-lock.json | 76 ++++++++++ package.json | 2 + src/App.tsx | 192 +++++++++++++++---------- src/components/shop/Cart.tsx | 183 ++++++++++++------------ src/components/shop/GoodList.tsx | 60 ++++---- src/components/shop/Wallet.tsx | 29 ++-- src/components/todos/TodoItem.tsx | 39 ++++-- src/components/todos/TodoList.tsx | 2 +- src/components/todos/TodoWrite.tsx | 10 +- src/pages/CartPage.tsx | 1 - src/pages/GoodsPage.tsx | 1 - src/pages/HomePage.tsx | 65 ++++++--- src/pages/SignInPage.tsx | 35 +++-- src/pages/SignUpPage.tsx | 37 +++-- src/pages/TodosInfinityPage.tsx | 218 +++++++++++++++++++---------- 15 files changed, 614 insertions(+), 336 deletions(-) diff --git a/package-lock.json b/package-lock.json index f4133b4..5f1db1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "@fullcalendar/react": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19", "@supabase/supabase-js": "^2.56.1", + "framer-motion": "^12.23.12", + "motion": "^12.23.12", "react": "^18.3.1", "react-dom": "^18.3.1", "react-infinite-scroll-component": "^6.1.0", @@ -3702,6 +3704,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz", + "integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.12", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4870,6 +4899,47 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.12.tgz", + "integrity": "sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.23.12", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz", + "integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6643,6 +6713,12 @@ "json5": "lib/cli.js" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index efc4b57..55de49a 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "@fullcalendar/react": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19", "@supabase/supabase-js": "^2.56.1", + "framer-motion": "^12.23.12", + "motion": "^12.23.12", "react": "^18.3.1", "react-dom": "^18.3.1", "react-infinite-scroll-component": "^6.1.0", diff --git a/src/App.tsx b/src/App.tsx index 67c8c21..70352c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext'; import ProfilePage from './pages/ProfilePage'; import AdminPage from './pages/AdminPage'; import TodosInfinitePage from './pages/TodosInfinityPage'; +import { motion } from 'framer-motion'; const TopBar = () => { const { signOut, user } = useAuth(); @@ -16,90 +17,133 @@ const TopBar = () => { // isAdmin 에는 boolean 임. ( true / false ) const isAdmin = user?.email === 'lynn9702@naver.com'; // 관리자 이메일 입력 return ( -
        + + ); }; function App() { return ( -
        +
        - - } /> - } /> - } /> - } /> - {/* Protected 로 감싸주기 */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - + + + } /> + } /> + } /> + } /> + {/* Protected 로 감싸주기 */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + +
        diff --git a/src/components/shop/Cart.tsx b/src/components/shop/Cart.tsx index 74da7ae..07743e7 100644 --- a/src/components/shop/Cart.tsx +++ b/src/components/shop/Cart.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useShop, useShopSelectors } from '../../features'; +import { motion, AnimatePresence } from 'framer-motion'; const Cart = () => { const { cart, addCart, removeCartOne, clearCart, resetCart, buyAll } = useShop(); @@ -28,97 +29,107 @@ const Cart = () => { }; 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}개 + +
          - {/* 삭제 버튼 */} - -
          -
        • - ); - }) +
          + {good?.name} + + 가격: {(good?.price! * item.qty).toLocaleString()} 원 + +
          + + {/* 수량 컨트롤 */} +
          + {/* - 버튼 */} + + + {/* 수량 입력 */} + handleQtyChange(item.id, e.target.value)} + className="w-14 appearance-none rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-center text-[15px] text-neutral-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 + [&::-webkit-inner-spin-button]:appearance-none + [&::-webkit-outer-spin-button]:appearance-none + [-moz-appearance:textfield]" + /> + + {/* + 버튼 */} + + + {/* 삭제 버튼 */} + +
          + + ); + })} + + )} +
        + + {/* 총 금액 표시 */} + {cart.length > 0 && ( +
        + 총 합계: {total.toLocaleString()} 원 +
        )} -
      - {/* 총 금액 표시 */} - {cart.length > 0 && ( -
      - 총 합계: {total.toLocaleString()} 원 + {/* 하단 버튼 */} +
      + +
      - )} - - {/* 하단 버튼 */} -
      - -
      ); diff --git a/src/components/shop/GoodList.tsx b/src/components/shop/GoodList.tsx index 2c9c063..cc3ccb7 100644 --- a/src/components/shop/GoodList.tsx +++ b/src/components/shop/GoodList.tsx @@ -1,38 +1,50 @@ import React from 'react'; import { useShop } from '../../features/hooks/useShop'; +import { motion, AnimatePresence } from 'framer-motion'; const GoodList = () => { const { goods, addCart } = useShop(); return ( -
      +
      {/* 제목 */} -

      상품 리스트 📦

      +
      +

      상품 리스트

      + + 총 {goods.length}개 + +
      {/* 상품 그리드 */} -
        - {goods.map(item => ( -
      • - {/* 상품 정보 */} -
        - {item.name} - - 가격: {item.price.toLocaleString()} 원 - -
        - - {/* 담기 버튼 */} - -
      • - ))} + {/* 상품 정보 */} +
        + {item.name} + + 가격: {item.price.toLocaleString()} 원 + +
        + + {/* 담기 버튼 */} + + + ))} +
      ); diff --git a/src/components/shop/Wallet.tsx b/src/components/shop/Wallet.tsx index e91e70b..936b7cc 100644 --- a/src/components/shop/Wallet.tsx +++ b/src/components/shop/Wallet.tsx @@ -1,30 +1,29 @@ import React from 'react'; import { useShop } from '../../features'; +import { motion } from 'framer-motion'; const Wallet = () => { const { balance } = useShop(); return ( -
      {/* 상단 */} -
      -

      내 지갑

      - 💳 Wallet +
      +

      내 지갑

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

      사용 가능한 잔액

      -

      {balance.toLocaleString()} 원

      -
      +

      사용 가능한 잔액

      +

      {balance.toLocaleString()} 원

      + ); }; diff --git a/src/components/todos/TodoItem.tsx b/src/components/todos/TodoItem.tsx index 2f2aae6..050577c 100644 --- a/src/components/todos/TodoItem.tsx +++ b/src/components/todos/TodoItem.tsx @@ -7,6 +7,7 @@ import { toggleTodo as toggleTodoService, deleteTodo as deleteTodoService, } from '../../services/todoServices'; +import { motion } from 'framer-motion'; type TodoItemProps = { todo: Todo; @@ -81,61 +82,69 @@ const TodoItem = ({ todo, index }: TodoItemProps) => { }; return ( -
      + {/* 출력 번호 */} - {globalIndex} + + {globalIndex} + + {isEdit ? ( -
      +
      handleChangeTitle(e)} onKeyDown={e => handleKeyDown(e)} - className="flex-grow border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-400" + className="w-full rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-[15px] text-neutral-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200" />
      ) : ( -
      -
      + <> +
      - + {todo.title}
      -
      +
      -
      + )} -
      + ); }; diff --git a/src/components/todos/TodoList.tsx b/src/components/todos/TodoList.tsx index 7786ac6..eb88f1a 100644 --- a/src/components/todos/TodoList.tsx +++ b/src/components/todos/TodoList.tsx @@ -8,7 +8,7 @@ const TodoList = ({}: TodoListProps) => { const { todos } = useTodos(); return ( -
      +

      Todo List

        {todos.length === 0 ? ( diff --git a/src/components/todos/TodoWrite.tsx b/src/components/todos/TodoWrite.tsx index 9aa47d6..53444b0 100644 --- a/src/components/todos/TodoWrite.tsx +++ b/src/components/todos/TodoWrite.tsx @@ -52,20 +52,20 @@ const TodoWrite = ({ handleChangePage }: TodoWriteProps): JSX.Element => { }; return ( -
        -

        할 일 작성

        -
        +
        +

        할 일 작성

        +
        handleChange(e)} onKeyDown={e => handleKeyDown(e)} placeholder="할 일을 입력하세요..." - className="flex-grow border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400" + className="flex-grow rounded-lg border border-neutral-300 bg-white px-3 py-2 text-[15px] text-neutral-900 placeholder-neutral-400 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200" /> diff --git a/src/pages/CartPage.tsx b/src/pages/CartPage.tsx index f94a12f..f6c5b6c 100644 --- a/src/pages/CartPage.tsx +++ b/src/pages/CartPage.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import Cart from '../components/shop/Cart'; function CartPage() { diff --git a/src/pages/GoodsPage.tsx b/src/pages/GoodsPage.tsx index 6cb9c7d..5b27f36 100644 --- a/src/pages/GoodsPage.tsx +++ b/src/pages/GoodsPage.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import GoodList from '../components/shop/GoodList'; function GoodsPage() { diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 2e47d6f..9025ccc 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,34 +1,63 @@ -import React from 'react'; +import { motion } from 'framer-motion'; +import { Link } from 'react-router-dom'; function HomePage() { return ( -
        +
        {/* Hero 영역 */} -
        -

        환영합니다!

        -

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

        +
        + + 환영합니다! + + + 이곳은 메인 홈 화면입니다. 상단 메뉴바엔 나의 할 일을 등록하실 수 있습니다! +
        {/* 소개 카드 */}
        -
        -

        추천 상품

        -

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

        -
        + + +

        추천 상품

        +

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

        +
        + -
        -

        이벤트

        -

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

        -
        + +

        이벤트

        +

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

        +
        -
        -

        회원 혜택

        -

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

        -
        + +

        회원 혜택

        +

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

        +
        {/* 푸터 */} -
        +

        © 2025 DDODO 쇼핑몰. All rights reserved.

        diff --git a/src/pages/SignInPage.tsx b/src/pages/SignInPage.tsx index 694fbab..aeb8760 100644 --- a/src/pages/SignInPage.tsx +++ b/src/pages/SignInPage.tsx @@ -1,8 +1,11 @@ import { useState } from 'react'; import { useAuth } from '../contexts/AuthContext'; +import { useNavigate } from 'react-router-dom'; +import { motion } from 'framer-motion'; function SignInPage() { const { signIn } = useAuth(); + const navigate = useNavigate(); const [email, setEmail] = useState(''); const [pw, setPw] = useState(''); const [msg, setMsg] = useState(''); @@ -15,45 +18,57 @@ function SignInPage() { setMsg(`로그인 오류 : ${error}`); } else { setMsg(`로그인이 성공하였습니다.`); + navigate('/'); // 로그인 성공 시 홈으로 이동 } }; return ( -
        -
        -

        로그인

        -
        +
        + +

        + 로그인 +

        +
        setEmail(e.target.value)} placeholder="이메일" - className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + autoComplete="email" + autoFocus + className="w-full rounded-xl border border-neutral-300 bg-white px-4 py-2 text-[15px] text-neutral-900 placeholder-neutral-400 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200" /> setPw(e.target.value)} placeholder="비밀번호" - className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + autoComplete="current-password" + className="w-full rounded-xl border border-neutral-300 bg-white px-4 py-2 text-[15px] text-neutral-900 placeholder-neutral-400 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200" />

        {msg}

        -
        +
        ); } diff --git a/src/pages/SignUpPage.tsx b/src/pages/SignUpPage.tsx index aafd57b..4b482b6 100644 --- a/src/pages/SignUpPage.tsx +++ b/src/pages/SignUpPage.tsx @@ -1,8 +1,8 @@ +import { motion } from 'framer-motion'; import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import { supabase } from '../lib/supabase'; -import { createProfile } from '../lib/profile'; -import type { ProfileInsert } from '../types/todoType'; function SignUpPage() { const { signUp } = useAuth(); @@ -13,6 +13,7 @@ function SignUpPage() { // 추가 정보 ( 닉네임 ) const [nickName, setNickName] = useState(''); const [msg, setMsg] = useState(''); + const navigate = useNavigate(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // 해당 코드 필수 : 웹브라우저 갱신 막아주기 @@ -56,23 +57,31 @@ function SignUpPage() { setMsg( '회원 가입이 성공했습니다. 이메일을 확인해주세요. 인증 완료 후 프로필이 자동으로 생성됩니다.', ); + // 회원가입 성공 시 로그인 페이지로 이동 + navigate('/signin'); } }; return ( -
        -
        -

        +
        + +

        Todo Service 회원 가입

        -
        +
        setEmail(e.target.value)} placeholder="이메일" - className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + autoComplete="email" + className="w-full rounded-xl border border-neutral-300 bg-white px-4 py-2 text-[15px] text-neutral-900 placeholder-neutral-400 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200" /> {/* */} setPw(e.target.value)} placeholder="비밀번호" - className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + autoComplete="new-password" + className="w-full rounded-xl border border-neutral-300 bg-white px-4 py-2 text-[15px] text-neutral-900 placeholder-neutral-400 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200" /> setNickName(e.target.value)} placeholder="닉네임" - className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + className="w-full rounded-xl border border-neutral-300 bg-white px-4 py-2 text-[15px] text-neutral-900 placeholder-neutral-400 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200" /> {/* form 안에선 button type 지정해주기 */}

        {msg}

        -
        +
        ); } diff --git a/src/pages/TodosInfinityPage.tsx b/src/pages/TodosInfinityPage.tsx index c7b7269..68a43b2 100644 --- a/src/pages/TodosInfinityPage.tsx +++ b/src/pages/TodosInfinityPage.tsx @@ -4,6 +4,7 @@ import type { Profile } from '../types/todoType'; import { getProfile } from '../lib/profile'; import InfiniteScroll from 'react-infinite-scroll-component'; import { InfiniteScrollProvider, useInfiniteScroll } from '../contexts/InfinityScrollContext'; +import { motion, AnimatePresence } from 'framer-motion'; // 용서하세요. 입력창 컴포넌트임다 컴포넌트라 const const InfiniteTodoWrite = () => { @@ -36,20 +37,30 @@ const InfiniteTodoWrite = () => { }; return ( -
        -

        할 일 작성

        -
        + +

        할 일 작성

        +
        handleChange(e)} onKeyDown={e => handleKeyDown(e)} placeholder="할 일을 입력 해주세요." - className="border" + className="w-full rounded-xl border border-neutral-300 bg-white px-3 py-2 text-[15px] text-neutral-900 placeholder-neutral-400 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200" /> - +
        -
        + ); }; @@ -160,80 +171,139 @@ const InfiniteTodoList = () => { }; if (loading) { - return
        데이터 로딩중...
        ; + return ( +
        + 데이터 로딩중... +
        + ); } return ( -
        -

        - TodoList (무한스크롤){profile?.nickname && {profile.nickname} 님의 할 일} -

        +
        +
        +

        + TodoList (무한스크롤) + {profile?.nickname && ( + + {profile.nickname} 님의 할 일 + + )} +

        + {totalCount > 0 && ( + + 총 {totalCount}개 + + )} +
        {todos.length === 0 ? ( -

        등록된 할 일이 없습니다.

        +

        등록된 할 일이 없습니다.

        ) : ( // 무한 스크롤 라이브러리 적용 데이터를 불러오는 중...
        } - endMessage={
        모든 데이터를 불러왔습니다.
        } + loader={ +
        + 데이터를 불러오는 중... +
        + } + endMessage={ +
        + 모든 데이터를 불러왔습니다. +
        + } > -
          - {todos.map((item, index) => ( -
        • - {/* 번호 표시 */} - {getGlobalIndex(index)} - {/* 체크 박스 */} - handleToggle(item.id)} - /> - {/* 제목과 날짜 출력 */} -
          - {editingId === item.id ? ( +
            + + {todos.map((item, index) => ( + + {/* 번호 표시 */} + + {getGlobalIndex(index)} + + {/* 체크 박스 */} +
            setEditingTitle(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') { - handleEditSave(item.id); - } else if (e.key === 'Escape') { - handleEditCancel(); - } - }} + type="checkbox" + checked={item.completed} + className="h-5 w-5 rounded border-neutral-300 accent-blue-600" + onChange={() => handleToggle(item.id)} /> - ) : ( - {item.title} - )} - - 작성일 : {formatDate(item.created_at)} -
            - {/* 버튼들 */} - {editingId === item.id ? ( - <> - - - - ) : ( - <> - - - - )} - - ))} + {/* 제목과 날짜 출력 */} +
            + {editingId === item.id ? ( + setEditingTitle(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + handleEditSave(item.id); + } else if (e.key === 'Escape') { + handleEditCancel(); + } + }} + /> + ) : ( + + {item.title} + + )} + + 작성일 : {formatDate(item.created_at)} + +
            +
          + {/* 버튼들 */} +
          + {editingId === item.id ? ( + <> + + + + ) : ( + <> + + + + )} +
          + + ))} +
        )} @@ -243,17 +313,19 @@ const InfiniteTodoList = () => { function TodosInfinitePage() { return ( -
        +
        -
        -

        무한 스크롤 Todo 목록

        -
        +
        +

        + 무한 스크롤 Todo 목록 +

        +
        -
        +
        ); From 10b0846c36c7aa604f7420a6cb3016258483d512 Mon Sep 17 00:00:00 2001 From: suha720 Date: Tue, 16 Sep 2025 14:30:20 +0900 Subject: [PATCH 27/51] =?UTF-8?q?[docs]=20router=20=EA=B5=AC=EC=84=B1?= =?UTF-8?q?=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 --- .eslintrc.json | 4 +- .prettierrc | 2 +- README.md | 1958 ++++++------------- index.html | 4 +- package-lock.json | 1086 +--------- package.json | 10 +- src/App.tsx | 242 ++- src/components/Counter.tsx | 44 +- src/components/Loading.tsx | 48 + src/components/NameEditor.tsx | 33 +- src/components/Pagination.tsx | 34 +- src/components/Protected.tsx | 44 + src/components/User.tsx | 7 +- src/components/shop/Cart.tsx | 175 +- src/components/shop/GoodList.tsx | 69 +- src/components/shop/ShopContext.tsx | 0 src/components/shop/Wallet.tsx | 33 +- src/components/todos/TodoItem.tsx | 178 +- src/components/todos/TodoList.tsx | 26 +- src/components/todos/TodoWrite.tsx | 62 +- src/components/todos/TodoWriteBox.tsx | 29 + src/contexts/AuthContext.tsx | 106 +- src/contexts/InfiniteScrollContext.tsx | 304 +++ src/contexts/TodoContext.tsx | 114 +- src/features/shop/ShopContext.tsx | 18 +- src/features/shop/hooks/useShop.ts | 10 + src/features/shop/hooks/useShopSelectors.ts | 12 + src/features/shop/index.ts | 9 + src/features/shop/reducer.ts | 40 +- src/features/shop/state.ts | 9 +- src/features/shop/types.ts | 27 +- src/features/shop/utils.ts | 15 +- src/index.css | 700 ++++++- src/lib/profile.ts | 68 +- src/lib/supabase.ts | 11 +- src/pages/AdminPage.tsx | 93 +- src/pages/AuthCallback.tsx | 76 + src/pages/Calendar.tsx | 70 +- src/pages/CartPage.tsx | 12 +- src/pages/GoodsPage.tsx | 14 +- src/pages/HomePage.tsx | 195 +- src/pages/NotFound.tsx | 24 +- src/pages/ProfilePage.tsx | 526 +++-- src/pages/SignInPage.tsx | 79 +- src/pages/SignUpPage.tsx | 119 +- src/pages/TodoDetailPage.tsx | 238 +++ src/pages/TodoEditPage.tsx | 186 ++ src/pages/TodoListPage.tsx | 147 ++ src/pages/TodoWritePage.tsx | 113 ++ src/pages/TodosInfinitePage.tsx | 421 ++++ src/pages/TodosPage.tsx | 33 +- src/pages/WalletPage.tsx | 15 +- src/services/todoService.ts | 179 ++ src/types/TodoTypes.ts | 274 +++ tsconfig.app.json | 1 - types_db.ts | 6 +- 56 files changed, 4634 insertions(+), 3718 deletions(-) create mode 100644 src/components/Loading.tsx create mode 100644 src/components/Protected.tsx create mode 100644 src/components/shop/ShopContext.tsx create mode 100644 src/components/todos/TodoWriteBox.tsx create mode 100644 src/contexts/InfiniteScrollContext.tsx create mode 100644 src/features/shop/hooks/useShop.ts create mode 100644 src/features/shop/hooks/useShopSelectors.ts create mode 100644 src/features/shop/index.ts create mode 100644 src/pages/AuthCallback.tsx create mode 100644 src/pages/TodoDetailPage.tsx create mode 100644 src/pages/TodoEditPage.tsx create mode 100644 src/pages/TodoListPage.tsx create mode 100644 src/pages/TodoWritePage.tsx create mode 100644 src/pages/TodosInfinitePage.tsx create mode 100644 src/services/todoService.ts create mode 100644 src/types/TodoTypes.ts diff --git a/.eslintrc.json b/.eslintrc.json index a42c0ea..ea1493c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -18,7 +18,7 @@ "rules": { "react/react-in-jsx-scope": "off", "@typescript-eslint/no-unused-vars": "off", - "no-unused-vars": "off", - "prettier/prettier": "warn" + "prettier/prettier": "warn", + "no-unused-vars": "off" } } diff --git a/.prettierrc b/.prettierrc index 2fd1103..91db5df 100644 --- a/.prettierrc +++ b/.prettierrc @@ -5,4 +5,4 @@ "printWidth": 100, "tabWidth": 2, "arrowParens": "avoid" -} \ No newline at end of file +} diff --git a/README.md b/README.md index a51a13d..274724d 100644 --- a/README.md +++ b/README.md @@ -1,1120 +1,648 @@ -# Infinity Scroll Loof List - -- 스크롤 시 추가 목록 구현 ( UI가 SNS 서비스에 좋음 ) -- 무한하게 진행되는 ( 스크롤 내리면 계속 보여지는 ) 스크롤 바 - -## 1. /src/services/todoService.ts - -- 무한 스크롤 todos 목록 조회 기능 추가 - -- todoService.ts - -```ts -// 무한 스크롤 todo 목록 조회 -export const getTodosInfinity = async ( - offset: number = 0, - limit: number = 5, -): Promise<{ todos: Todo[]; hasMore: boolean; totalCount: number }> => { - try { - // 전체 todos 의 Row 개수 - const { count, error: countError } = await supabase - .from('todos') - .select('*', { count: 'exact', head: true }); - if (countError) { - throw new Error(`getTodosInfinity count 오류 : ${countError.message}`); - } - - // 무한 스크롤 데이터 조회 - const { data, error: limitError } = await supabase - .from('todos') - .select('*') - .order('created_at', { ascending: false }) - .range(offset, offset + limit - 1); - - if (limitError) { - throw new Error(`getTodosInfinite limit 오류 : ${limitError.message}`); - } - // 전체 개수 - const totalCount = count || 0; - - // 앞으로 더 가져올 데이터가 있는지? - const hasMore = offset + limit < totalCount; - - // 최종 값을 리턴함 - return { - todos: data || [], - hasMore, - totalCount, - }; - } catch (error) { - console.log(`getTodosInfinite 오류 : ${error}`); - throw new Error(`getTodosInfinite 오류 : ${error}`); - } -}; -``` - -- todoService.ts 전체코드 - -```ts -import { supabase } from '../lib/supabase'; -import type { Todo, TodoInsert, TodoUpdate } from '../types/todoType'; +# 스타일 정리 -// 이것이 CRUD!! +## 1. css 기본 코드 -// 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 생성 -// 로그인을 하고 나면 실제로 user_id 가 이미 파악이 됨 -// TodoInsert 에서 user_id : 값을 생략하는 타입을 생성 -// 타입스크립트에서 Omit을 이용하면, 특정 키를 제거 할 수 있음. -export const createTodo = async (newTodo: Omit): Promise => { - try { - // 현재 로그인 한 사용자 정보 가져오기 - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - throw new Error('로그인이 필요합니다.'); - } - - const { data, error } = await supabase - .from('todos') - .insert([{ ...newTodo, completed: false, user_id: user.id }]) - .select() - .single(); - if (error) { - throw new Error(`createTodo 오류 : ${error.message}`); - } - return data; - } catch (error) { - console.log(error); - return null; - } -}; -// Todo 수정 -// 로그인을 하고 나면 실제로 user_id 가 이미 파악이 됨 -// TodoInsert 에서 user_id : 값을 생략하는 타입을 생성 -// 타입스크립트에서 Omit을 이용하면, 특정 키를 제거 할 수 있음. -export const updateTodo = async ( - id: number, - editTitle: Omit, -): 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); - } -}; +- /src/index.css 업데이트 -// Complited 토글 = 어차피 toggle도 업데이트기 때문에 굳이 만들지 않아도 되지만 수업상 만듦 -export const toggleTodo = async (id: number, completed: boolean): Promise => { - return updateTodo(id, { completed }); -}; +## 2. App.tsx css 정리 -// 페이지 단위로 조각내서 목록 출력하기 -// getTodosPaginated (페이지 번호, 10개) -// getTodosPaginated (1, 10개) -// getTodosPaginated (2, 10개) -export const getTodosPaginated = async ( - page: number = 1, - limit: number = 10, -): Promise<{ todos: Todo[]; totalCount: number; totalPages: number; currentPage: number }> => { - // 시작 지점 ( 만약 page = 2 라면, limit 은 10 ) - // (2-1)*10 = 10 - const from = (page - 1) * limit; - // 제한 지점 (종료) - // 10 + 10 - 1 = 19 - const to = from + limit - 1; - - // 전체 데이터 개수 (행row의 개수) - const { count } = await supabase.from('todos').select('*', { count: 'exact', head: true }); - - // from 부터 to 까지의 상세 데이터 가져오기 - const { data } = await supabase - .from('todos') - .select('*') - .order('created_at', { ascending: false }) - .range(from, to); - - // 편하게 활용하기 - const totalCount = count || 0; - // 몇 페이지인지 계산 (소수점은 올림) - const totalPages = Math.ceil(totalCount / limit); - return { - todos: data || [], - totalCount, - totalPages, - currentPage: page, - }; -}; +## 3. /src/pages/HomePage.tsx 정리 -// 무한 스크롤 todo 목록 조회 -export const getTodosInfinity = async ( - offset: number = 0, - limit: number = 5, -): Promise<{ todos: Todo[]; hasMore: boolean; totalCount: number }> => { - try { - // 전체 todos 의 Row 개수 - const { count, error: countError } = await supabase - .from('todos') - .select('*', { count: 'exact', head: true }); - if (countError) { - throw new Error(`getTodosInfinity count 오류 : ${countError.message}`); - } +## 4. /src/pages/SignUpPage.tsx 정리 - // 무한 스크롤 데이터 조회 - const { data, error: limitError } = await supabase - .from('todos') - .select('*') - .order('created_at', { ascending: false }) - .range(offset, offset + limit - 1); +## 5. /src/pages/SignInPage.tsx 정리 - if (limitError) { - throw new Error(`getTodosInfinite limit 오류 : ${limitError.message}`); - } - // 전체 개수 - const totalCount = count || 0; +## 6. /src/pages/TodosPage.tsx 정리 - // 앞으로 더 가져올 데이터가 있는지? - const hasMore = offset + limit < totalCount; +## 7. /src/pages/TodosInfinitePage.tsx 정리 - // 최종 값을 리턴함 - return { - todos: data || [], - hasMore, - totalCount, - }; - } catch (error) { - console.log(`getTodosInfinite 오류 : ${error}`); - throw new Error(`getTodosInfinite 오류 : ${error}`); - } -}; -``` +## 8. /src/pages/ProfilePage.tsx 정리 -## 2. 상태 관리 ( Context State ) +# 라우터 정리(할일을 별도 페이지로) -- 별도로 구성해서 진행해봄 -- /src/contexts/InfinityScrollContext.tsx +## 1. 할일 목록 페이지 -- 첫번째 : 초기값 세팅 +- /src/pages/TodoListPage.tsx ```tsx -import type { Todo } from '../types/todoType'; - -// 1. 초기값 -type InfinityScrollState = { - todos: Todo[]; - hasMore: boolean; - totalCount: number; - loading: boolean; - loadingMore: boolean; -}; +import { useEffect, useState } from 'react'; +import Pagination from '../components/Pagination'; +import TodoList from '../components/todos/TodoList'; +import TodoWriteBox from '../components/todos/TodoWriteBox'; +import { useAuth } from '../contexts/AuthContext'; +import { TodoProvider, useTodos } from '../contexts/TodoContext'; +import { getProfile } from '../lib/profile'; +import type { Profile, Todo } from '../types/TodoTypes'; +import { Link } from 'react-router-dom'; +// 컴포넌트 추후 추출 +interface TodoItemProps { + todo: Todo; + index: number; +} -const initialState: InfinityScrollState = { - todos: [], - hasMore: false, - totalCount: 0, - loading: false, - loadingMore: false, -}; -``` +const TodoItemBox = ({ todo, index }: TodoItemProps) => { + const { toggleTodo, deleteTodo, editTodo, currentPage, itemsPerPage, totalCount } = useTodos(); -- 2번째 : Action types 정의 + // 순서번호 매기기 + const globalIndex = totalCount - ((currentPage - 1) * itemsPerPage + index); -```tsx -// 2. Action 타입 정의 -enum InfinityScrollActionType { - SET_TODOS = 'SET_TODOS', - SET_LOADING = 'SET_LOADING', - SET_LOADING_MORE = 'SET_LOADING_MORE', - APPEND_TODOS = 'APPEND_TODOS', - ADD_TODO = 'ADD_TODO', - TOGGLE_TODO = 'TOGGLE_TODO', - DELETE_TODO = 'DELETE_TODO', - EDIT_TODO = 'EDIT_TODO', - RESET = 'RESET', -} + // 작성 날짜 포맷팅 + const formatDate = (dateString: string | null): string => { + if (!dateString) return '날짜 없음'; + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }; -type SetLoadingAction = { type: InfinityScrollActionType.SET_LOADING; payload: boolean }; -type SetLoadingMoreAction = { type: InfinityScrollActionType.SET_LOADING_MORE; payload: boolean }; -type SetTodosAction = { - type: InfinityScrollActionType.SET_TODOS; - payload: { todos: Todo[]; hasMore: boolean; totalCount: number }; -}; -type AppendTodosAction = { - type: InfinityScrollActionType.APPEND_TODOS; - payload: { todos: Todo[]; hasMore: boolean }; -}; -type AddAction = { - type: InfinityScrollActionType.ADD_TODO; - payload: { todo: Todo }; -}; -type ToggleAction = { - type: InfinityScrollActionType.TOGGLE_TODO; - payload: { id: number }; -}; -type DeleteAction = { - type: InfinityScrollActionType.DELETE_TODO; - payload: { id: number }; -}; -type EditAction = { - type: InfinityScrollActionType.EDIT_TODO; - payload: { id: number; title: string }; -}; -type ResetAction = { - type: InfinityScrollActionType.RESET; + return ( +
      • + {/* 출력 번호 */} + {globalIndex} +
        + + {todo.title} + + 작성일 : {formatDate(todo.created_at)} +
        +
      • + ); }; +const TodoListBox = () => { + const { user } = useAuth(); + // 전체 할 일 목록 가져오기 + const { todos } = useTodos(); -type InfinityScrollAction = - | SetLoadingAction - | SetLoadingMoreAction - | SetTodosAction - | AppendTodosAction - | AddAction - | ToggleAction - | DeleteAction - | EditAction - | ResetAction; -``` - -- 3번째 : reducer 함수 만들기 + return ( +
          + {todos.map((item, index) => ( + + ))} +
        + ); +}; -```tsx -// 3. Reducer 함수 -function reducer(state: InfinityScrollState, action: InfinityScrollAction): InfinityScrollState { - switch (action.type) { - case InfinityScrollActionType.SET_LOADING: - return { ...state, loading: action.payload }; - case InfinityScrollActionType.SET_LOADING_MORE: - return { ...state, loadingMore: action.payload }; - case InfinityScrollActionType.SET_TODOS: - return { - ...state, - todos: action.payload.todos, - hasMore: action.payload.hasMore, - totalCount: action.payload.totalCount, - loading: false, - loadingMore: false, - }; - case InfinityScrollActionType.APPEND_TODOS: - // 추가 - return { - ...state, - todos: [...action.payload.todos, ...state.todos], - hasMore: action.payload.hasMore, - loadingMore: false, - }; - case InfinityScrollActionType.ADD_TODO: - return { - ...state, - todos: [action.payload.todo, ...state.todos], - totalCount: state.totalCount + 1, - }; - case InfinityScrollActionType.TOGGLE_TODO: - return { - ...state, - todos: state.todos.map(item => - item.id === action.payload.id ? { ...item, completed: !item.completed } : item, - ), - }; - case InfinityScrollActionType.DELETE_TODO: - return { - ...state, - todos: state.todos.filter(item => item.id !== action.payload.id), - }; - case InfinityScrollActionType.EDIT_TODO: - return { - ...state, - todos: state.todos.map(item => - item.id === action.payload.id ? { ...item, title: action.payload.title } : item, - ), - }; - case InfinityScrollActionType.RESET: - return initialState; - default: - return state; - } +interface TodosContentProps { + profile: Profile | null; + currentPage: number; + itemsPerPage: number; + handleChangePage: (page: number) => void; } -``` - -- 4번째 : Context 생성 - -```tsx -// 4. Context 생성 -type InfinityScrollContextValue = { - todos: Todo[]; - hasMore: boolean; - totalCount: number; - loading: boolean; - loadingMore: boolean; - loadingInitialTodos: () => Promise; - loadMoreTodos: () => Promise; - addTodo: (todo: Todo) => void; - toggleTodo: (id: number) => void; - deleteTodo: (id: number) => void; - editTodo: (id: number, title: string) => void; - reset: () => void; +const TodosContent = ({ + profile, + currentPage, + itemsPerPage, + handleChangePage, +}: TodosContentProps): JSX.Element => { + const { totalCount, totalPages } = useTodos(); + return ( +
        +
        + {/* 새글 등록시 1페이지로 이동후 목록새로고침 */} + +
        +
        + +
        +
        + +
        +
        + ); }; -const InfiniteScrollContext = createContext(null); -``` -- 5번째 : Provider 만들기 +function TodoListPage() { + const { user } = useAuth(); -```tsx -// 5. Provider 생성 -// interface InfinityScrollProviderProps { -// children?: React.ReactNode; -// itemsPerPage: number; -// } -interface InfinityScrollProviderProps extends PropsWithChildren { - itemsPerPage?: number; -} -export const InfinityScrollProvider: React.FC = ({ - children, - itemsPerPage = 5, -}) => { - // ts 자리 - // useReducer 를 활용 - const [state, dispatch] = useReducer(reducer, initialState); - - // 초기 데이터 로드 - const loadingInitialTodos = async (): Promise => { - try { - // 초기 로딩 활성화 시켜줌 - dispatch({ type: InfinityScrollActionType.SET_LOADING, payload: true }); - const result = await getTodosInfinity(0, itemsPerPage); - console.log( - '초기 로드 된 데이터', - result.todos.map(item => ({ - id: item.id, - title: item.title, - created_at: item.created_at, - user_id: item.user_id, - })), - ); - - dispatch({ - type: InfinityScrollActionType.SET_TODOS, - payload: { todos: result.todos, hasMore: result.hasMore, totalCount: result.totalCount }, - }); - } catch (error) { - console.log(`초기 데이터 로드 실패 : ${error}`); - dispatch({ type: InfinityScrollActionType.SET_LOADING, payload: false }); - } + // 페이지네이션 관련 + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(10); + // 페이지 변경 핸들러 + const handleChangePage = (page: number) => { + setCurrentPage(page); }; - // 데이터 더보기 기능 - const loadMoreTodos = async (): Promise => { + // 프로필 가져오기 + const [profile, setProfile] = useState(null); + const loadProfile = async () => { try { - dispatch({ type: InfinityScrollActionType.SET_LOADING_MORE, payload: true }); - const result = await getTodosInfinity(state.todos.length, itemsPerPage); - console.log( - '추가 로드 된 데이터', - result.todos.map(item => ({ - id: item.id, - title: item.title, - created_at: item.created_at, - user_id: item.user_id, - })), - ); - - dispatch({ - type: InfinityScrollActionType.APPEND_TODOS, - payload: { todos: result.todos, hasMore: result.hasMore }, - }); + if (user?.id) { + const userProfile = await getProfile(user.id); + if (!userProfile) { + alert('탈퇴한 회원입니다. 관리자님에게 요청하세요.'); + } + setProfile(userProfile); + } } catch (error) { - console.log(`추가 데이터 로드 실패 : ${error}`); - dispatch({ type: InfinityScrollActionType.SET_LOADING_MORE, payload: false }); + console.log('프로필 가져오기 Error : ', error); } }; - // Todo 추가 - const addTodo = (todo: Todo): void => { - dispatch({ type: InfinityScrollActionType.ADD_TODO, payload: { todo } }); - }; - // Todo 토글 - const toggleTodo = (id: number): void => { - dispatch({ type: InfinityScrollActionType.TOGGLE_TODO, payload: { id } }); - }; - // Todo 삭제 - const deleteTodo = (id: number): void => { - dispatch({ type: InfinityScrollActionType.DELETE_TODO, payload: { id } }); - }; - // Todo 수정 - const editTodo = (id: number, title: string): void => { - dispatch({ type: InfinityScrollActionType.EDIT_TODO, payload: { id, title } }); - }; - // Context 상태 초기화 - const reset = (): void => { - dispatch({ type: InfinityScrollActionType.RESET }); - }; - - // 최초 실행시 데이터 로드 useEffect(() => { - loadingInitialTodos(); + loadProfile(); }, []); - const value: InfinityScrollContextValue = { - todos: state.todos, - hasMore: state.hasMore, - totalCount: state.totalCount, - loading: state.loading, - loadingMore: state.loadingMore, - loadingInitialTodos, - loadMoreTodos, - addTodo, - toggleTodo, - deleteTodo, - editTodo, - reset, - }; - - // tsx 자리 - return {children}; -}; -``` - -- 6번째 : Custom Hook + return ( +
        +
        +

        🍈 할 일 관리

        + {profile?.nickname &&

        {profile.nickname}님의 Todo 관리

        } +
        -```tsx -// 6. 커스텀 훅 -export function useInfinityScrollTodos(): InfinityScrollContextValue { - const ctx = useContext(InfinityScrollContext); - if (!ctx) { - throw new Error('InfinityScrollContext가 없습니다.'); - } - return ctx; + + + +
        + ); } -``` -## 3. 전체 코드 +export default TodoListPage; +``` -- 전체 InfinityScrollContext.tsx +- /src/components/todos/TodoWriteBox.tsx 생성 ```tsx -import { - createContext, - useContext, - useEffect, - useReducer, - useState, - type PropsWithChildren, -} from 'react'; -import type { Todo } from '../types/todoType'; -import { getTodosInfinity } from '../services/todoServices'; - -// 1. 초기값 -type InfinityScrollState = { - todos: Todo[]; - hasMore: boolean; - totalCount: number; - loading: boolean; - loadingMore: boolean; -}; - -const initialState: InfinityScrollState = { - todos: [], - hasMore: false, - totalCount: 0, - loading: false, - loadingMore: false, -}; +import { Link } from 'react-router-dom'; +import type { Profile } from '../../types/TodoTypes'; -// 2. Action 타입 정의 -enum InfinityScrollActionType { - SET_TODOS = 'SET_TODOS', - SET_LOADING = 'SET_LOADING', - SET_LOADING_MORE = 'SET_LOADING_MORE', - APPEND_TODOS = 'APPEND_TODOS', - ADD_TODO = 'ADD_TODO', - TOGGLE_TODO = 'TOGGLE_TODO', - DELETE_TODO = 'DELETE_TODO', - EDIT_TODO = 'EDIT_TODO', - RESET = 'RESET', +interface TodoWriteBoxProps { + profile: Profile | null; } - -type SetLoadingAction = { type: InfinityScrollActionType.SET_LOADING; payload: boolean }; -type SetLoadingMoreAction = { type: InfinityScrollActionType.SET_LOADING_MORE; payload: boolean }; -type SetTodosAction = { - type: InfinityScrollActionType.SET_TODOS; - payload: { todos: Todo[]; hasMore: boolean; totalCount: number }; -}; -type AppendTodosAction = { - type: InfinityScrollActionType.APPEND_TODOS; - payload: { todos: Todo[]; hasMore: boolean }; -}; -type AddAction = { - type: InfinityScrollActionType.ADD_TODO; - payload: { todo: Todo }; -}; -type ToggleAction = { - type: InfinityScrollActionType.TOGGLE_TODO; - payload: { id: number }; -}; -type DeleteAction = { - type: InfinityScrollActionType.DELETE_TODO; - payload: { id: number }; -}; -type EditAction = { - type: InfinityScrollActionType.EDIT_TODO; - payload: { id: number; title: string }; -}; -type ResetAction = { - type: InfinityScrollActionType.RESET; -}; - -type InfinityScrollAction = - | SetLoadingAction - | SetLoadingMoreAction - | SetTodosAction - | AppendTodosAction - | AddAction - | ToggleAction - | DeleteAction - | EditAction - | ResetAction; - -// 3. Reducer 함수 - -function reducer(state: InfinityScrollState, action: InfinityScrollAction): InfinityScrollState { - switch (action.type) { - case InfinityScrollActionType.SET_LOADING: - return { ...state, loading: action.payload }; - case InfinityScrollActionType.SET_LOADING_MORE: - return { ...state, loadingMore: action.payload }; - case InfinityScrollActionType.SET_TODOS: - return { - ...state, - todos: action.payload.todos, - hasMore: action.payload.hasMore, - totalCount: action.payload.totalCount, - loading: false, - loadingMore: false, - }; - case InfinityScrollActionType.APPEND_TODOS: - // 추가 - return { - ...state, - todos: [...action.payload.todos, ...state.todos], - hasMore: action.payload.hasMore, - loadingMore: false, - }; - case InfinityScrollActionType.ADD_TODO: - return { - ...state, - todos: [action.payload.todo, ...state.todos], - totalCount: state.totalCount + 1, - }; - case InfinityScrollActionType.TOGGLE_TODO: - return { - ...state, - todos: state.todos.map(item => - item.id === action.payload.id ? { ...item, completed: !item.completed } : item, - ), - }; - case InfinityScrollActionType.DELETE_TODO: - return { - ...state, - todos: state.todos.filter(item => item.id !== action.payload.id), - }; - case InfinityScrollActionType.EDIT_TODO: - return { - ...state, - todos: state.todos.map(item => - item.id === action.payload.id ? { ...item, title: action.payload.title } : item, - ), - }; - case InfinityScrollActionType.RESET: - return initialState; - default: - return state; - } -} - -// 4. Context 생성 -type InfinityScrollContextValue = { - todos: Todo[]; - hasMore: boolean; - totalCount: number; - loading: boolean; - loadingMore: boolean; - loadingInitialTodos: () => Promise; - loadMoreTodos: () => Promise; - addTodo: (todo: Todo) => void; - toggleTodo: (id: number) => void; - deleteTodo: (id: number) => void; - editTodo: (id: number, title: string) => void; - reset: () => void; -}; -const InfinityScrollContext = createContext(null); - -// 5. Provider 생성 -// interface InfinityScrollProviderProps { -// children?: React.ReactNode; -// itemsPerPage: number; -// } -interface InfinityScrollProviderProps extends PropsWithChildren { - itemsPerPage?: number; -} -export const InfinityScrollProvider: React.FC = ({ - children, - itemsPerPage = 5, -}) => { - // ts 자리 - // useReducer 를 활용 - const [state, dispatch] = useReducer(reducer, initialState); - - // 초기 데이터 로드 - const loadingInitialTodos = async (): Promise => { - try { - // 초기 로딩 활성화 시켜줌 - dispatch({ type: InfinityScrollActionType.SET_LOADING, payload: true }); - const result = await getTodosInfinity(0, itemsPerPage); - console.log( - '초기 로드 된 데이터', - result.todos.map(item => ({ - id: item.id, - title: item.title, - created_at: item.created_at, - user_id: item.user_id, - })), - ); - - dispatch({ - type: InfinityScrollActionType.SET_TODOS, - payload: { todos: result.todos, hasMore: result.hasMore, totalCount: result.totalCount }, - }); - } catch (error) { - console.log(`초기 데이터 로드 실패 : ${error}`); - dispatch({ type: InfinityScrollActionType.SET_LOADING, payload: false }); - } - }; - - // 데이터 더보기 기능 - const loadMoreTodos = async (): Promise => { - try { - dispatch({ type: InfinityScrollActionType.SET_LOADING_MORE, payload: true }); - const result = await getTodosInfinity(state.todos.length, itemsPerPage); - console.log( - '추가 로드 된 데이터', - result.todos.map(item => ({ - id: item.id, - title: item.title, - created_at: item.created_at, - user_id: item.user_id, - })), - ); - - dispatch({ - type: InfinityScrollActionType.APPEND_TODOS, - payload: { todos: result.todos, hasMore: result.hasMore }, - }); - } catch (error) { - console.log(`추가 데이터 로드 실패 : ${error}`); - dispatch({ type: InfinityScrollActionType.SET_LOADING_MORE, payload: false }); - } - }; - - // Todo 추가 - const addTodo = (todo: Todo): void => { - dispatch({ type: InfinityScrollActionType.ADD_TODO, payload: { todo } }); - }; - // Todo 토글 - const toggleTodo = (id: number): void => { - dispatch({ type: InfinityScrollActionType.TOGGLE_TODO, payload: { id } }); - }; - // Todo 삭제 - const deleteTodo = (id: number): void => { - dispatch({ type: InfinityScrollActionType.DELETE_TODO, payload: { id } }); - }; - // Todo 수정 - const editTodo = (id: number, title: string): void => { - dispatch({ type: InfinityScrollActionType.EDIT_TODO, payload: { id, title } }); - }; - // Context 상태 초기화 - const reset = (): void => { - dispatch({ type: InfinityScrollActionType.RESET }); - }; - - // 최초 실행시 데이터 로드 - useEffect(() => { - loadingInitialTodos(); - }, []); - - const value: InfinityScrollContextValue = { - todos: state.todos, - hasMore: state.hasMore, - totalCount: state.totalCount, - loading: state.loading, - loadingMore: state.loadingMore, - loadingInitialTodos, - loadMoreTodos, - addTodo, - toggleTodo, - deleteTodo, - editTodo, - reset, - }; - - // tsx 자리 - return {children}; +const TodoWriteBox = ({ profile }: TodoWriteBoxProps) => { + return ( +
        +
        +

        + 🎈 새 할일 작성 + {profile?.nickname && ( + + {profile.nickname} + + )} +

        + + 작성하기 + +
        +
        + ); }; -// 6. 커스텀 훅 -export function useInfinityScrollTodos(): InfinityScrollContextValue { - const ctx = useContext(InfinityScrollContext); - if (!ctx) { - throw new Error('InfinityScrollContext가 없습니다.'); - } - return ctx; -} +export default TodoWriteBox; ``` -## 4. 활용 +## 2. 할일 내용 및 제목 작성 페이지 -- /src/pages/TodosInfinityPage.tsx 생성 +- /src/pages/TodoWritePage.tsx ```tsx import { useEffect, useState } from 'react'; import { useAuth } from '../contexts/AuthContext'; -import { InfinityScrollProvider, useInfinityScroll } from '../contexts/InfinityScrollContext'; -import type { Profile } from '../types/todoType'; +import type { Profile, TodoInsert } from '../types/TodoTypes'; import { getProfile } from '../lib/profile'; +import { useNavigate } from 'react-router-dom'; +import { createTodo } from '../services/todoService'; -// 용서하세요. 입력창 컴포넌트임다 컴포넌트라 const -const InfinityTodoWrite = () => { - const { addTodo, loadingInitialTodos } = useInfinityScroll(); - +function TodoWritePage() { + const { user } = useAuth(); + const navigate = useNavigate(); + // 사용자 입력 내용 const [title, setTitle] = useState(''); - const handleChange = async (e: React.ChangeEvent) => { + const [content, setContent] = useState(''); + const [saving, setSaving] = useState(false); + + const handleTitleChange = (e: React.ChangeEvent) => { setTitle(e.target.value); }; - const handleKeyDown = async (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSave(); + const handleContentChange = (e: React.ChangeEvent) => { + setContent(e.target.value); + }; + const handleCancel = () => { + // 사용자가 실수로 취소를 할 수 있으므로 이에 대비 + if (title.trim() || content.trim()) { + if (window.confirm(`작성 중인 내용이 있습니다. 정말 취소하시겠습니까?`)) { + // 목록으로 + navigate('/todos'); + } else { + // 목록으로 + navigate('/todos'); + } } }; - const handleSave = async (): Promise => { + const handleSave = async () => { + // 제목은 필수 입력 if (!title.trim()) { - alert('제목을 입력하세요'); + alert('제목은 필수 입니다.'); return; } try { - // 새 할 일 추가 - await addTodo(title); - // 다시 데이터를 로딩함 - await loadingInitialTodos(); - setTitle(''); + setSaving(true); + const newTodo: TodoInsert = { title, user_id: user!.id, content }; + const result = await createTodo(newTodo); + if (result) { + alert(`할 일이 성공적으로 등록되었습니다.`); + navigate(`/todos`); + } else { + alert(`오류가 발생했습니다. 다시 시도해 주세요.`); + } } catch (error) { - console.log('등록에 오류가 발생 : ', error); - alert(`등록에 오류가 발생 : ${error}`); + console.log('데이터 추가에 실패하였습니다.', error); + alert(`데이터 추가에 실패하였습니다. ${error}`); + } finally { + setSaving(false); } }; + // 사용자 정보 + const [profile, setProfile] = useState(null); + useEffect(() => { + const loadProfile = async () => { + if (user?.id) { + const userProfile = await getProfile(user.id); + setProfile(userProfile); + } + }; + loadProfile(); + }, [user?.id]); + return (
        -

        할 일 작성

        -
        - handleChange(e)} - onKeyDown={e => handleKeyDown(e)} - placeholder="할 일을 입력 해주세요." - className="border" - /> - +
        +

        🧨 새 할 일 작성

        + {profile?.nickname &&

        {profile.nickname}님의 새로운 할 일

        } +
        + {/* 입력창 */} +
        +
        + + handleTitleChange(e)} + placeholder="할 일을 입력해주세요." + disabled={saving} + /> +
        +
        + + +
        +
        + + +
        ); -}; +} + +export default TodoWritePage; +``` + +## 3. 할일 상세 페이지 + +- /src/pages/TodoDetailPage.tsx + +```tsx +import React, { useEffect, useState } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import { useNavigate, useParams } from 'react-router-dom'; +import type { Profile, Todo } from '../types/TodoType'; +import { getProfile } from '../lib/profile'; +import { deleteTodo, getTodoById, getTodos } from '../services/todoService'; +import Loading from '../components/Loading'; -// 용서하세요..ㅋㅋ 목록 컴포넌트 -const InfinityTodoList = () => { - const { loading, todos, totalCount, editTodo, toggleTodo, deleteTodo, loadingInitialTodos } = - useInfinityScroll(); +function TodoDetailPage() { + const navigate = useNavigate(); const { user } = useAuth(); + // 사용자 정보 const [profile, setProfile] = useState(null); - - // 사용자 프로필 가져오기 useEffect(() => { - const loadProifle = async () => { + const loadProfile = async () => { if (user?.id) { const userProfile = await getProfile(user.id); setProfile(userProfile); } }; - loadProifle(); + loadProfile(); }, [user?.id]); - // 번호 계산 함수 (최신글이 높은 번호를 가지도록) - const getGlobalIndex = (index: number) => { - // 무한 스크롤 시에 계산해서 번호 출력 - const globalIndex = totalCount - index; - // console.log( - // `번호 계산 - index : ${index}, totalCount : ${totalCount}, globalIndex: ${globalIndex}`, - // ); - return globalIndex; - }; - - // 날짜 포맷팅 함수 ( 자주 쓰여서 유틸 폴더 만들어서 관리해도 좋음 ) - const formatDate = (dateString: string | null): string => { - if (!dateString) return '날짜 없음'; - const date = new Date(dateString); - return date.toLocaleDateString('ko-KR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }); - }; + // param 값을 읽기 + const { id } = useParams<{ id: string }>(); - // 수정 상태 관리 - const [editingId, setEditingId] = useState(null); - const [editingTitle, setEditingTitle] = useState(''); + // id 를 이용해서 Todo 내용 가져오기 + const [todo, setTodo] = useState(null); + // 상세 페이지오면 todo 내용을 호출해야 하므로 true 셋팅 + const [loading, setLoading] = useState(true); - // 수정 시작 - const handleEditStart = (todo: any) => { - setEditingId(todo.id); - setEditingTitle(todo.title); - }; + // 현재 삭제 중인지 처리 + const [actionLoading, setActionLoading] = useState<{ + delete: boolean; + }>({ delete: false }); - // 수정 취소 - const handleEditCancel = () => { - setEditingId(null); - setEditingTitle(''); - }; - - // 수정 저장 - const handleEditSave = async (id: number) => { - if (!editingTitle.trim()) { - alert('제목을 입력하세요.'); - return; - } - try { - editTodo(id, editingTitle); - setEditingId(null); - setEditingTitle(''); - } catch (error) { - console.log(error); - alert('수정에 실패했습니다.'); - } - }; - - const handleToggle = async (id: number) => { - try { - // Context 의 state 를 업데이트함 - await toggleTodo(id); - } catch (error) { - console.log('토글 실패 :', error); - alert('상태 변경에 실패하였습니다.'); - } - }; - - const handleDelete = async (id: number) => { - if (window.confirm('정말 삭제하시겠습니까?')) { + useEffect(() => { + const loadTodo = async () => { + if (!id) { + navigate('/todos'); + return; + } try { - // id 를 삭제 - await deleteTodo(id); - // 삭제 이후 번호를 갱신해서 정리해줌 - await loadingInitialTodos(); + setLoading(true); + const todoData = await getTodoById(parseInt(id)); + + if (!todoData) { + alert('해당 할 일을 찾을 수 없습니다.'); + navigate('/todos'); + return; + } + + // 본인의 Todo 인지 확인 + if (todoData.user_id !== user?.id) { + alert('조회 권한이 없습니다.'); + navigate('/todos'); + return; + } + + setTodo(todoData); } catch (error) { - console.log('삭제에 실패하였습니다.'); - alert('삭제에 실패하였습니다.'); + console.log('Todo 로드 실패 : ', error); + alert('할 일을 불러오는데 실패했습니다.'); + navigate('/todos'); + } finally { + setLoading(false); } + }; + loadTodo(); + }, [id, user?.id, navigate]); + + const handleDelete = async () => { + if (!todo) return; + if (!window.confirm('정말 삭제하시겠습니까?')) return; + try { + setActionLoading({ ...actionLoading, delete: true }); + await deleteTodo(todo.id); + alert('할일이 삭제되었습니다.'); + navigate('/todos'); + } catch (error) { + } finally { + setActionLoading({ ...actionLoading, delete: false }); } }; if (loading) { - return
        데이터 로딩중...
        ; + return ; + } + + if (!todo) { + return ( +
        +

        할 일을 찾을 수 없습니다.

        + +
        + ); } - return ( -
        -

        - TodoList (무한스크롤){profile?.nickname && {profile.nickname} 님의 할 일} - {todos.length === 0 ? ( -

        등록된 할 일이 없습니다.

        - ) : ( -
        -
          - {todos.map((item, index) => ( -
        • - {/* 번호 표시 */} - {getGlobalIndex(index)} - {/* 체크 박스 */} - handleToggle(item.id)} - /> - {/* 제목과 날짜 출력 */} -
          - {editingId === item.id ? ( - setEditingTitle(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') { - handleEditSave(item.id); - } else if (e.key === 'Escape') { - handleEditCancel(); - } - }} - /> - ) : ( - {item.title} - )} - - 작성일 : {formatDate(item.created_at)} -
          - {/* 버튼들 */} - {editingId === item.id ? ( - <> - - - - ) : ( - <> - - - - )} -
        • - ))} -
        -
        - )} -

        -
        - ); -}; -function TodosInfinityPage() { return (
        - -
        -

        무한 스크롤 Todo 목록

        -
        - +
        +

        할 일 상세보기

        + {profile?.nickname &&

        {profile.nickname}님의 할 일

        } +
        + {/* 실제내용 */} +
        +
        +
        +

        + {todo.title} +

        +
        +
        + + {todo.completed ? '✅ 완료' : '⏳ 진행 중'} + +
        +
        +
        + + +
        + {/* 상세내용 */} + {todo.content && ( +
        +

        상세 내용

        +

        + {todo.content} +

        -
        - + )} + {/* 추가정보 출력 */} +
        +

        할일 정보

        +
        +
        + 작성일 : +
        + {todo.created_at ? new Date(todo.created_at).toLocaleString('ko-KR') : '정보 없음'} +
        +
        +
        + 수정일 : +
        + {todo.updated_at ? new Date(todo.updated_at).toLocaleString('ko-KR') : '정보 없음'} +
        +
        +
        + 작성자 : +
        + {profile?.nickname || user?.email} +
        +
        - + +
        + +
        +
        ); } -export default TodosInfinityPage; +export default TodoDetailPage; ``` -## 5. Router 추가 +## 4. 할일 내용 및 제목 수정 페이지 + +- /src/pages/TodoEditPage.tsx -- App.tsx +## 5. 라우터 구성 + +- App.tsx 업데이트 +- `edit 과 detail 은 id 를 param` 으로 전달함. ```tsx import { Link, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; -import AuthCallbackPage from './pages/AuthCallbackPage'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; import HomePage from './pages/HomePage'; -import SignInPage from './pages/SignInPage'; import SignUpPage from './pages/SignUpPage'; +import SignInPage from './pages/SignInPage'; import TodosPage from './pages/TodosPage'; -import Protected from './contexts/Protected'; -import { AuthProvider, useAuth } from './contexts/AuthContext'; +import Protected from './components/Protected'; import ProfilePage from './pages/ProfilePage'; +import AuthCallback from './pages/AuthCallback'; import AdminPage from './pages/AdminPage'; -import TodosInfinityPage from './pages/TodosInfinityPage'; +import TodosInfinitePage from './pages/TodosInfinitePage'; +import TodoListPage from './pages/TodoListPage'; +import TodoWritePage from './pages/TodoWritePage'; +import TodoEditPage from './pages/TodoEditPage'; +import TodoDetailPage from './pages/TodoDetailPage'; const TopBar = () => { const { signOut, user } = useAuth(); // 관리자인 경우 메뉴 추가로 출력하기 - // isAdmin 에는 boolean 임. ( true / false ) - const isAdmin = user?.email === 'lynn9702@naver.com'; // 관리자 이메일 입력 + // isAdmin 에는 true/fasle + const isAdmin = user?.email === 'dev.seastj@gmail.com'; + return ( -

        +
        - {todo.content} -

        -
        - )} - {/* 추가정보 출력 */} -
        -

        할일 정보

        -
        -
        - 작성일 : -
        - {todo.created_at ? new Date(todo.created_at).toLocaleString('ko-KR') : '정보 없음'} +
        + 작성일 : +
        + {todo.created_at + ? new Date(todo.created_at).toLocaleString(`ko-KR`) + : '정보 없음'} +
        -
        -
        - 수정일 : -
        - {todo.updated_at ? new Date(todo.updated_at).toLocaleString('ko-KR') : '정보 없음'} +
        + 수정일 : +
        + {todo.updated_at + ? new Date(todo.updated_at).toLocaleString(`ko-KR`) + : '정보 없음'} +
        -
        -
        - 작성자 : -
        - {profile?.nickname || user?.email} +
        + 작성자 : +
        + {profile?.nickname || user?.email} +
        -
        @@ -570,7 +656,7 @@ function TodoDetailPage() { export default TodoDetailPage; ``` -## 4. 할일 내용 및 제목 수정 페이지 +## 5. Editor 내용 수정 활용 - /src/pages/TodoEditPage.tsx @@ -583,6 +669,7 @@ import { getProfile } from '../lib/profile'; import { getTodoById, updateTodo } from '../services/todoService'; import Loading from '../components/Loading'; import { toggleTodo } from '../services/todoServices'; +import RichTextEditor from '../components/RichTextEditor'; function TodoEditPage() { const navigate = useNavigate(); @@ -672,8 +759,12 @@ function TodoEditPage() { setTitle(e.target.value); }; - const handleContentChange = (e: React.ChangeEvent) => { - setContent(e.target.value); + // const handleContentChange = (e: React.ChangeEvent) => { + // setContent(e.target.value); + // }; + + const handleContentChange = (value: string) => { + setContent(value); }; const handleSave = async () => { @@ -767,13 +858,19 @@ function TodoEditPage() {
        - + /> */} +
        @@ -66,18 +35,7 @@ const InfiniteTodoWrite = () => { // 용서하세요. 목록 컴포넌트 const InfiniteTodoList = () => { - const { - loading, - loadingMore, - hasMore, - loadMoreTodos, - todos, - totalCount, - editTodo, - toggleTodo, - deleteTodo, - loadingInitialTodos, - } = useInfiniteScroll(); + const { loading, hasMore, loadMoreTodos, todos, totalCount } = useInfiniteScroll(); const { user } = useAuth(); const [profile, setProfile] = useState(null); @@ -115,104 +73,6 @@ const InfiniteTodoList = () => { }); }; - // 수정 상태 관리 - const [editingId, setEditingId] = useState(null); - const [editingTitle, setEditingTitle] = useState(''); - - // 개별 액션 로딩 상태 관리 - const [actionLoading, setActionLoading] = useState<{ - [key: number]: { - edit: boolean; - toggle: boolean; - delete: boolean; - }; - }>({}); - - // 수정 시작 - const handleEditStart = (todo: any) => { - setEditingId(todo.id); - setEditingTitle(todo.title); - }; - - const handleEditCancel = () => { - setEditingId(null); - setEditingTitle(''); - }; - - const handleEditSave = async (id: number) => { - if (!editingTitle.trim()) { - alert('제목을 입력하세요.'); - return; - } - - try { - // 수정 진행 중 - setActionLoading(prev => ({ - ...prev, - [id]: { ...prev[id], edit: true }, - })); - - await editTodo(id, editingTitle); - setEditingId(null); - setEditingTitle(''); - } catch (error) { - console.log('수정 실패:', error); - alert('수정에 실패했습니다.'); - } finally { - // 수정 완료 - setActionLoading(prev => ({ - ...prev, - [id]: { ...prev[id], edit: false }, - })); - } - }; - - const handleToggle = async (id: number) => { - try { - // 토글 진행 중 - setActionLoading(prev => ({ - ...prev, - [id]: { ...prev[id], toggle: true }, - })); - - await toggleTodo(id); - } catch (error) { - console.log('토글 실패:', error); - alert('상태 변경에 실패하였습니다.'); - } finally { - // 토글 완료 - setActionLoading(prev => ({ - ...prev, - [id]: { ...prev[id], toggle: false }, - })); - } - }; - - const handleDelete = async (id: number) => { - if (window.confirm('정말 삭제하시겠습니까?')) { - try { - // 삭제 진행 중 - setActionLoading(prev => ({ - ...prev, - [id]: { ...prev[id], delete: true }, - })); - - await deleteTodo(id); - // 삭제 이후에 번호를 갱신해서 정리해줌 - await loadingInitialTodos(); - } catch (error) { - console.log('삭제 실패:', error); - alert('삭제에 실패하였습니다.'); - } finally { - // 삭제 완료 - setActionLoading(prev => ({ - ...prev, - [id]: { ...prev[id], delete: false }, - })); - } - } - }; - if (loading) { return
        데이터 로딩중 ...
        ; } @@ -277,113 +137,22 @@ const InfiniteTodoList = () => { >
          {todos.map((item, index) => { - const itemLoading = actionLoading[item.id] || { - edit: false, - toggle: false, - delete: false, - }; - return (
        • {/* 번호표시 */} {getGlobalIndex(index)}. - - {editingId === item.id ? ( - <> - {/* 수정 모드 */} -
          - setEditingTitle(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') { - handleEditSave(item.id); - } else if (e.key === 'Escape') { - handleEditCancel(); - } - }} - className="form-input" - style={{ - fontSize: '14px', - padding: 'var(--space-2)', - width: '100%', - marginBottom: '4px', - }} - disabled={itemLoading.edit} - autoFocus - /> - 작성일: {formatDate(item.created_at)} -
          - -
          - - -
          - - ) : ( - <> - {/* 일반 모드 */} - handleToggle(item.id)} - disabled={itemLoading.toggle} - style={{ - transform: 'scale(1.2)', - cursor: itemLoading.toggle ? 'not-allowed' : 'pointer', - opacity: itemLoading.toggle ? 0.6 : 1, - }} - /> - -
          - - {item.title} - - 작성일: {formatDate(item.created_at)} -
          - -
          - - -
          - - )} +
          + + {item.title} + + 작성일: {formatDate(item.created_at)} +
        • ); })} diff --git a/src/pages/TodosInfinityPage.tsx b/src/pages/TodosInfinityPage.tsx deleted file mode 100644 index e7dbf68..0000000 --- a/src/pages/TodosInfinityPage.tsx +++ /dev/null @@ -1,334 +0,0 @@ -// import { useCallback, useEffect, useRef, useState } from 'react'; -// import { useAuth } from '../contexts/AuthContext'; -// import type { Profile } from '../types/todoType'; -// import { getProfile } from '../lib/profile'; -// import InfiniteScroll from 'react-infinite-scroll-component'; -// import { InfiniteScrollProvider, useInfiniteScroll } from '../contexts/InfinityScrollContext'; -// import { motion, AnimatePresence } from 'framer-motion'; - -// // 용서하세요. 입력창 컴포넌트임다 컴포넌트라 const -// const InfiniteTodoWrite = () => { -// const { addTodo, loadingInitialTodos } = useInfiniteScroll(); - -// 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 = async (): Promise => { -// if (!title.trim()) { -// alert('제목을 입력하세요'); -// return; -// } -// try { -// // 새 할 일 추가 -// await addTodo(title); -// // 다시 데이터를 로딩함 -// await loadingInitialTodos(); -// setTitle(''); -// } catch (error) { -// console.log('등록에 오류가 발생 : ', error); -// alert(`등록에 오류가 발생 : ${error}`); -// } -// }; - -// return ( -// -//

          할 일 작성

          -//
          -// handleChange(e)} -// onKeyDown={e => handleKeyDown(e)} -// placeholder="할 일을 입력 해주세요." -// className="w-full rounded-xl border border-neutral-300 bg-white px-3 py-2 text-[15px] text-neutral-900 placeholder-neutral-400 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200" -// /> -// -//
          -//
          -// ); -// }; - -// // 용서하세요..ㅋㅋ 목록 컴포넌트 -// const InfiniteTodoList = () => { -// const { -// loading, -// loadingMore, -// loadMoreTodos, -// hasMore, -// todos, -// totalCount, -// editTodo, -// toggleTodo, -// deleteTodo, -// loadingInitialTodos, -// } = useInfiniteScroll(); -// const { user } = useAuth(); -// const [profile, setProfile] = useState(null); - -// // 사용자 프로필 가져오기 -// useEffect(() => { -// const loadProifle = async () => { -// if (user?.id) { -// const userProfile = await getProfile(user.id); -// setProfile(userProfile); -// } -// }; -// loadProifle(); -// }, [user?.id]); - -// // 번호 계산 함수 (최신글이 높은 번호를 가지도록) -// const getGlobalIndex = (index: number) => { -// // 무한 스크롤 시에 계산해서 번호 출력 -// const globalIndex = totalCount - index; -// return globalIndex; -// }; - -// // 날짜 포맷팅 함수 ( 자주 쓰여서 유틸 폴더 만들어서 관리해도 좋음 ) -// const formatDate = (dateString: string | null): string => { -// if (!dateString) return '날짜 없음'; -// const date = new Date(dateString); -// return date.toLocaleDateString('ko-KR', { -// year: 'numeric', -// month: '2-digit', -// day: '2-digit', -// hour: '2-digit', -// minute: '2-digit', -// }); -// }; - -// // 수정 상태 관리 -// const [editingId, setEditingId] = useState(null); -// const [editingTitle, setEditingTitle] = useState(''); - -// // 수정 시작 -// const handleEditStart = (todo: any) => { -// setEditingId(todo.id); -// setEditingTitle(todo.title); -// }; - -// // 수정 취소 -// const handleEditCancel = () => { -// setEditingId(null); -// setEditingTitle(''); -// }; - -// // 수정 저장 -// const handleEditSave = async (id: number) => { -// if (!editingTitle.trim()) { -// alert('제목을 입력하세요.'); -// return; -// } -// try { -// editTodo(id, editingTitle); -// setEditingId(null); -// setEditingTitle(''); -// } catch (error) { -// console.log(error); -// alert('수정에 실패했습니다.'); -// } -// }; - -// // 토글 -// const handleToggle = async (id: number) => { -// try { -// // Context 의 state 를 업데이트함 -// await toggleTodo(id); -// } catch (error) { -// console.log('토글 실패 :', error); -// alert('상태 변경에 실패하였습니다.'); -// } -// }; - -// // 삭제 -// const handleDelete = async (id: number) => { -// if (window.confirm('정말 삭제하시겠습니까?')) { -// try { -// // id 를 삭제 -// await deleteTodo(id); -// // 삭제 이후 번호를 갱신해서 정리해줌 -// await loadingInitialTodos(); -// } catch (error) { -// console.log('삭제에 실패하였습니다.'); -// alert('삭제에 실패하였습니다.'); -// } -// } -// }; - -// if (loading) { -// return ( -//
          -// 데이터 로딩중... -//
          -// ); -// } -// return ( -//
          -//
          -//

          -// TodoList (무한스크롤) -// {profile?.nickname && ( -// -// {profile.nickname} 님의 할 일 -// -// )} -//

          -// {totalCount > 0 && ( -// -// 총 {totalCount}개 -// -// )} -//
          -// {todos.length === 0 ? ( -//

          등록된 할 일이 없습니다.

          -// ) : ( -// // 무한 스크롤 라이브러리 적용 -// -// 데이터를 불러오는 중... -//
          -// } -// endMessage={ -//
          -// 모든 데이터를 불러왔습니다. -//
          -// } -// > -//
            -// -// {todos.map((item, index) => ( -// -// {/* 번호 표시 */} -// -// {getGlobalIndex(index)} -// -// {/* 체크 박스 */} -//
            -// handleToggle(item.id)} -// /> -// {/* 제목과 날짜 출력 */} -//
            -// {editingId === item.id ? ( -// setEditingTitle(e.target.value)} -// onKeyDown={e => { -// if (e.key === 'Enter') { -// handleEditSave(item.id); -// } else if (e.key === 'Escape') { -// handleEditCancel(); -// } -// }} -// /> -// ) : ( -// -// {item.title} -// -// )} -// -// 작성일 : {formatDate(item.created_at)} -// -//
            -//
            -// {/* 버튼들 */} -//
            -// {editingId === item.id ? ( -// <> -// -// -// -// ) : ( -// <> -// -// -// -// )} -//
            -//
            -// ))} -//
            -//
          -// -// )} -//
        -// ); -// }; - -// function TodosInfinitePage() { -// return ( -//
        -// -//
        -//

        -// 무한 스크롤 Todo 목록 -//

        -//
        -// -//
        -//
        -// -//
        -//
        -//
        -//
        -// ); -// } - -// export default TodosInfinitePage; From 39c499f08fdf8f97ed1cfbc859b2d72d54193e61 Mon Sep 17 00:00:00 2001 From: suha720 Date: Thu, 18 Sep 2025 13:01:31 +0900 Subject: [PATCH 30/51] =?UTF-8?q?[docs]=20=EC=97=90=EB=94=94=ED=84=B0=20?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=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 | 1628 +---------------------------- package-lock.json | 111 +- package.json | 1 + src/components/RichTextEditor.tsx | 189 +++- src/pages/TodoWritePage.tsx | 132 ++- 5 files changed, 412 insertions(+), 1649 deletions(-) diff --git a/README.md b/README.md index 70a4a35..2d44c1b 100644 --- a/README.md +++ b/README.md @@ -1,1616 +1,36 @@ -# Supabase 에 Editor 적용 +# Editor 와 Supabase Storage 연동 -- 상세 내용 작성 부분을 React Quill 을 활용함 -- 상세 내용은 `til_npm` 의 `15-react-quill` 참조 -- 내용이 길어지므로 DB 에서는 calumn 을 `text` 타입 권장 -- HTML 을 직접 출력하는 것은 위험함 (`https://www.npmjs.com/package/dompurify`) +- 사용자가 내용 작성 중 이미지를 배치한다면 ? + - 1. 그냥 text 로 처리한다. + - 2. 이미지를 배치하면 storage 업로드 후 url 을 받아서 보여준다 + - 3. 이미지를 배치하면 미리보기 URL 을 생성 한 후 보여주고, + img src='임시주소', img 파일은 별도로 보관함. + 사용자가 저장 버튼을 누르면 그 때 storage 에 등록자 폴더 생성 후 저장한다. + 저장에 성공하면 getURL 로 주소를 알아냄. + content 의 내용 중 img src='주소' 교체하고, DB 에 저장한다. -## 1. 적용 단계 +## 1. Supabase Storage 설정 -- TypeScript 에서도 잘 진행됨 (타입 정의 불필요) +### 1.1 `todos-images` 생성 -```bash -npm i react-quill -npm quill -npm i dompurify -``` - -## 2. Editor 는 Component 생성 후 활용 - -- /src/components/RichTextEditor.tsx 생성 - -```tsx -import React from 'react'; -import ReactQuill, { type Value } from 'react-quill'; -import 'react-quill/dist/quill.snow.css'; - -// 컴포넌트가 외부에서 전달받을 데이터 형태 -interface RichTextEditorProps { - value: string; // 에디터에 초기로 보여줄 내용 - onChange: (value: string) => void; // 내용이 변결될때 실행할 함수 - placeholder?: string; // 안내 텍스트 (선택사항) - disabled?: boolean; // 에디터를 비활성화할지 여부 (선택사항) -} - -const RichTextEditor = ({ - value, - onChange, - placeholder = '내용을 입력하세요.', - disabled = false, -}: RichTextEditorProps) => { - // 툴바 설정 - 에디터 상단에 표시될 버튼들을 정의 - const modules = { - toolbar: [ - // 헤더 옵션: H1, H2, H3, 일반 텍스트 - [{ header: [1, 2, 3, false] }], - // 텍스트 서식 옵션 - ['bold', 'italic', 'underline', 'strike'], - // 색상 옵션: 텍스트 색상, 배경 색상 - [{ color: [] }, { background: [] }], - // 텍스트 정렬 옵션: 왼쪽, 가운데, 오른쪽, 양쪽 정렬 - [{ align: [] }], - // 목록 옵션: 순서 있는 목록, 순서 없는 목록 - [{ list: 'ordered' }, { list: 'bullet' }], - // 들여쓰기 옵션: 왼쪽으로 들여쓰기, 오른쪽으로 들여쓰기 - [{ indent: '-1' }, { indent: '+1' }], - // 링크와 이미지 삽입 옵션 - ['link', 'image'], - // 서식 제거 옵션: 선택한 텍스트의 모든 서식을 제거 - ['clean'], - ], - }; - - // 에디터에서 허용할 HTML 태그들을 정의 - // 이 배열에 포함된 태그만 에디터에서 사용할 수 있음 - const formats = [ - 'header', // 헤더 태그 (h1, h2, h3) - 'bold', // 굵은 글씨 (strong, b) - 'italic', // 기울임 글씨 (em, i) - 'underline', // 밑줄 (u) - 'strike', // 취소선 (s, del) - 'color', // 텍스트 색상 (span with color) - 'background', // 배경 색상 (span with background-color) - 'align', // 텍스트 정렬 (text-align) - 'list', // 목록 (ul, ol) - 'bullet', // 순서 없는 목록 (ul) - 'indent', // 들여쓰기 (margin-left) - 'link', // 링크 (a) - 'image', // 이미지 (img) - ]; - - return ( -
        - -
        - ); -}; - -export default RichTextEditor; -``` - -## 3. Editor 내용 활용 - -- /src/pages/TodoWritePage.tsx - -```tsx -import { useEffect, useState } from 'react'; -import { useAuth } from '../contexts/AuthContext'; -import type { Profile, TodoInsert } from '../types/TodoTypes'; -import { getProfile } from '../lib/profile'; -import { useNavigate } from 'react-router-dom'; -import { createTodo } from '../services/todoService'; -import RichTextEditor from '../components/RichTextEditor'; - -function TodoWritePage() { - const { user } = useAuth(); - const navigate = useNavigate(); - // 사용자 입력 내용 - const [title, setTitle] = useState(''); - const [content, setContent] = useState(''); - const [saving, setSaving] = useState(false); - - const handleTitleChange = (e: React.ChangeEvent) => { - setTitle(e.target.value); - }; - // const handleContentChange = (e: React.ChangeEvent) => { - // setContent(e.target.value); - // }; - const handleContentChange = (value: string) => { - setContent(value); - }; - const handleCancel = () => { - // 사용자가 실수로 취소를 할 수 있으므로 이에 대비 - if (title.trim() || content.trim()) { - if (window.confirm(`작성 중인 내용이 있습니다. 정말 취소하시겠습니까?`)) { - // 목록으로 - navigate('/todos'); - } else { - // 목록으로 - navigate('/todos'); - } - } - }; - const handleSave = async () => { - // 제목은 필수 입력 - if (!title.trim()) { - alert('제목은 필수 입니다.'); - return; - } - try { - setSaving(true); - const newTodo: TodoInsert = { title, user_id: user!.id, content }; - const result = await createTodo(newTodo); - if (result) { - alert(`할 일이 성공적으로 등록되었습니다.`); - navigate(`/todos`); - } else { - alert(`오류가 발생했습니다. 다시 시도해 주세요.`); - } - } catch (error) { - console.log('데이터 추가에 실패하였습니다.', error); - alert(`데이터 추가에 실패하였습니다. ${error}`); - } finally { - setSaving(false); - } - }; - - // 사용자 정보 - const [profile, setProfile] = useState(null); - useEffect(() => { - const loadProfile = async () => { - if (user?.id) { - const userProfile = await getProfile(user.id); - setProfile(userProfile); - } - }; - loadProfile(); - }, [user?.id]); - - return ( -
        -
        -

        🧨 새 할 일 작성

        - {profile?.nickname &&

        {profile.nickname}님의 새로운 할 일

        } -
        - {/* 입력창 */} -
        -
        - - handleTitleChange(e)} - placeholder="할 일을 입력해주세요." - disabled={saving} - /> -
        -
        - - {/*