diff --git a/README.md b/README.md index e215bc4c..2f5ef8dd 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,70 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Coworkers -## Getting Started +## **๐Ÿ’ก**ย ํ”„๋กœ์ ํŠธ ๊ฐœ์š” -First, run the development server: -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` +> โ–ซ๏ธย **Coworkers** ๋Š” ๊ฐ€์กฑ, ํšŒ์‚ฌ ๋“ฑ ๋‹ค์–‘ํ•œ ์ปค๋ฎค๋‹ˆํ‹ฐ์—์„œ ์ผ์ •์„ ๊ด€๋ฆฌํ•˜๊ณ  ๊ณต์œ ํ•  ์ˆ˜ ์žˆ๋Š” ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ž…๋‹ˆ๋‹ค. +โ–ซ๏ธย ๋ฉค๋ฒ„ ์ดˆ๋Œ€, ํ•  ์ผ ๋ชฉ๋ก CRUD, ๋Œ“๊ธ€ ์ž‘์„ฑ ๋“ฑ ์œ ๊ธฐ์ ์ธ ์ปค๋ฎค๋‹ˆํ‹ฐ ๊ธฐ๋Šฅ์„ ์ œ๊ณต ํ•ฉ๋‹ˆ๋‹ค. +> -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +**๐Ÿ”น ์œ ์ € ๊ธฐ๋Šฅ** -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +- ์ดˆ๋Œ€๋œ ๋ฉค๋ฒ„๋“ค๋งŒ ์ ‘๊ทผ ํ•  ์ˆ˜ ์žˆ์–ด, ์ข…์†๋œ ๊ณต๊ฐ„์—์„œ ๊ณต์œ ์™€ ์˜๊ฒฌ์„ ๋‚˜๋ˆŒ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +๐Ÿ”น **๊ทธ๋ฃน, ํ• ์ผ๋ชฉ๋ก, ํ• ์ผ ์ƒ์„ฑ** -## Learn More +- ํ• ์ผ์˜ ๋ฐ˜๋ณต ์„ค์ •, ํ• ์ผ ๋ชฉ๋ก ์ƒ์„ฑ ์ˆ˜์ •๋“ฑ ๋งˆ๊ฐ์ผ, ๋‹ด๋‹น์ž ๋“ฑ ์—…๋ฌด์— ํ•„์š”ํ•œ ๋‚ด์šฉ์„ ์ถ”๊ฐ€ํ• ์ˆ˜ ์žˆ์–ด, ์ „๋ฐ˜์ ์ธ ๋‚ด์šฉ์„ ์‰ฝ๊ฒŒ ๊ณต์œ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +- ๋Œ“๊ธ€ ๋‚จ๊ธธ ์ˆ˜ ์žˆ์–ด ์‹ค์‹œ๊ฐ„์œผ๋กœ ํ˜„ํ™ฉ์ด๋‚˜ ์˜๊ฒฌ์„ ๊ณต์œ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +- drag & drop์œผ๋กœ ์‰ฝ๊ฒŒ ํ• ์ผ์˜ ์šฐ์„ ์ˆœ์œ„๋ฅผ ๋ถ€์—ฌํ•˜์—ฌ ์‚ฌ์šฉ์ž์˜ ํŽธ์˜์„ฑ์„ ๋†’์˜€์Šต๋‹ˆ๋‹ค. -To learn more about Next.js, take a look at the following resources: + +
-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## **๐Ÿ› ** ๊ฐœ๋ฐœํ™˜๊ฒฝ -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +- **Front-End : Next.js(App Router) , Axios , tailwind, dnd-kit,โ€ฆ** +- **Back-end : Swagger** +- **๋ฐฐํฌ ํ™˜๊ฒฝ : Vercel , AWS** +- **๋””์ž์ธ : Figma** +- **๋ฒ„์ „ ๋ฐ ์ด์Šˆ ๊ด€๋ฆฌ: Github , Git ,Github action** +- **ํ˜‘์—… ํˆด: Discord, Notion** -## Deploy on Vercel +
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## ๐Ÿ“‚ย ํด๋” ๊ตฌ์กฐ + +แ„‰แ…ณแ„แ…ณแ„…แ…ตแ†ซแ„‰แ…ฃแ†บ 2025-05-28 แ„‹แ…ฉแ„’แ…ฎ 4 51 02 + + +
+ +# **๐Ÿ‘ฅ**ย ํ”„๋กœ์ ํŠธ ํŒ€ ๊ตฌ์„ฑ ๋ฐ ์—ญํ•  + + + +--- + +## ๐Ÿง‘โ€๐Ÿ’ป๐Ÿ‘ฉโ€๐Ÿ’ป๐Ÿ‘ฉโ€๐Ÿ’ป๐Ÿ‘ฉโ€๐Ÿ’ปย ํŒ€์› ์†Œ๊ฐœ์™€ ์—ญํ•  + +| ์ด๋ฆ„ | ์ฃผ์š” ์—ญํ•  | GitHub | +| --- | --- | --- | +|๊ฐ•์„์ค€ | modal ,interceptor, ๋Œ“๊ธ€, ๋žœ๋”ฉํŽ˜์ด์ง€, ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€, ๊ทธ๋ฃน ํŽ˜์ด์ง€, ์ž์œ ๊ฒŒ์‹œํŒ ์ƒ์„ฑ,์ˆ˜์ •, AWS ๋ฐฐํฌ, docker ์„ค์ •, ... | https://github.com/KSJ27 +|๊น€ํฌ์ง„ | button , toast, input, ํˆฌ๋‘๋ฆฌ์ŠคํŠธ ์•„์ดํ…œ, ํŒ€์ƒ์„ฑํŽ˜์ด์ง€, ํŒ€ ์ˆ˜์ •ํŽ˜์ด์ง€, ํ• ์ผ ์ƒ์„ฑ,์ˆ˜์ •, ์ž์œ ๊ฒŒ์‹œํŒ ์ƒ์„ธ ํŽ˜์ด์ง€, ... | https://github.com/heewls +|์œ ์„ ํ–ฅ | dropdown , svgr, OAuth, ๋น„๋ฒˆ ์žฌ์„ค์ • ํŽ˜์ด์ง€, ๋ฆฌ์ŠคํŠธ ํŽ˜์ด์ง€, ํžˆ์Šคํ† ๋ฆฌํŽ˜์ด์ง€, ํ• ์ผ ์ƒ์„ธ, Dnd ,... | https://github.com/grimza99 +|ํ™ฉํ˜œ์ง„ | header , global css, ํšŒ์›๊ฐ€์ž…, ํŒ€์ฐธ์—ฌํ•˜๊ธฐ ํŽ˜์ด์ง€, ๊ณ„์ • ๊ด€๋ฆฌ ํŽ˜์ด์ง€, ์ž์œ ๊ฒŒ์‹œํŒ ํŽ˜์ด์ง€, ... | https://github.com/hhjin1 + + +
+ +# ํ”„๋กœ์ ํŠธ ์ˆ˜ํ–‰ ์ ˆ์ฐจ ๋ฐ ์ปจ๋ฒค์…˜ + +### ํ”„๋กœ์ ํŠธ ๊ธฐ๊ฐ„ : 25.04.21 ~ 25.05.25 +### ๋ฆฌํŒฉํ† ๋ง ๊ธฐ๊ฐ„ : 25.05.28 ~ 25.06.08 +### โฐ ์ฝ”์–ด ํƒ€์ž„: ์›”-๊ธˆ, ์˜คํ›„ 2์‹œ~5์‹œ + +### ๐Ÿ›  ๋ฐ์ผ๋ฆฌ ์Šคํฌ๋Ÿผ: ์›”- ํ† , ์˜คํ›„ 2์‹œ + +### ๐Ÿ“Œ **์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ, 1~2์‹œ๊ฐ„ ๋‚ด๋กœ ํ•ด๊ฒฐ์ด ์•ˆ๋˜๋ฉด ๊ณต์œ ํ•˜๊ธฐ!** + +### ๐Ÿ“– ๋ฐ์ผ๋ฆฌ ์Šคํฌ๋Ÿผ์‹œ, ํŒ€ ๊ทœ์น™์— ๋”ฐ๋ฅธ ํ•ด๋‹น ํŒ€์›์ด ๋…ธ์…˜์— ์ง„ํ–‰ ๋‚ด์šฉ๊ณผ ์ง„ํ–‰ ์˜ˆ์ •์„ ๊ธฐ๋ก -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/package-lock.json b/package-lock.json index 17295683..6ddd3290 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", + "@hookform/resolvers": "^5.0.1", + "@tanstack/react-query": "^5.79.0", "axios": "^1.9.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -19,8 +21,10 @@ "react-calendar": "^5.1.0", "react-dom": "^19.0.0", "react-error-boundary": "^6.0.0", + "react-hook-form": "^7.56.4", "react-intersection-observer": "^9.16.0", - "react-toastify": "^11.0.5" + "react-toastify": "^11.0.5", + "zod": "^3.25.41" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -2048,6 +2052,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.0.1.tgz", + "integrity": "sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2774,6 +2790,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -3328,6 +3350,32 @@ "tailwindcss": "4.1.4" } }, + "node_modules/@tanstack/query-core": { + "version": "5.79.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.79.0.tgz", + "integrity": "sha512-s+epTqqLM0/TbJzMAK7OEhZIzh63P9sWz5HEFc5XHL4FvKQXQkcjI8F3nee+H/xVVn7mrP610nVXwOytTSYd0w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.79.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.79.0.tgz", + "integrity": "sha512-DjC4JIYZnYzxaTzbg3osOU63VNLP67dOrWet2cZvXgmgwAXNxfS52AMq86M5++ILuzW+BqTUEVMTjhrZ7/XBuA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.79.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -8181,6 +8229,22 @@ "react": ">=16.13.1" } }, + "node_modules/react-hook-form": { + "version": "7.56.4", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.4.tgz", + "integrity": "sha512-Rob7Ftz2vyZ/ZGsQZPaRdIefkgOSrQSPXfqBdvOPwJfoGnjwRJUs7EM7Kc1mcoDv3NOtqBzPGbcMB8CGn9CKgw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-intersection-observer": { "version": "9.16.0", "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz", @@ -9718,6 +9782,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.41", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.41.tgz", + "integrity": "sha512-8+sDJTGtCYIDBhdqDygp0ffj8kzziRKqAJPhpYObbElJ+3TRe/mnlnwH+/OMa3kKhueS4Drm5UMW00/u1p07zA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index fe185f42..553e1fae 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", + "@hookform/resolvers": "^5.0.1", + "@tanstack/react-query": "^5.79.0", "axios": "^1.9.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -20,8 +22,10 @@ "react-calendar": "^5.1.0", "react-dom": "^19.0.0", "react-error-boundary": "^6.0.0", + "react-hook-form": "^7.56.4", "react-intersection-observer": "^9.16.0", - "react-toastify": "^11.0.5" + "react-toastify": "^11.0.5", + "zod": "^3.25.41" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/public/icons/expand-icon.svg b/public/icons/expand-icon.svg new file mode 100644 index 00000000..75b29dd9 --- /dev/null +++ b/public/icons/expand-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Members/MemberDeleteModal.tsx b/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Members/MemberDeleteModal.tsx index d4d749d5..6a072034 100644 --- a/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Members/MemberDeleteModal.tsx +++ b/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Members/MemberDeleteModal.tsx @@ -8,9 +8,8 @@ import { ModalFooter, ModalHeading, ModalOverlay, - ModalPortal, } from '@/components/common/modal'; -import useModalContext from '@/components/common/modal/core/useModalContext'; +import { useModal, ModalPortal } from '@/contexts/ModalContext'; import BouncingDots from '@/components/common/loading/BouncingDots'; import { Member } from '@/types/user'; @@ -28,7 +27,7 @@ export default function MemberDeleteModal({ deleteMember, }: MemberDeleteModalProps) { const { userName } = member; - const { closeModal } = useModalContext(); + const { closeModal } = useModal(); const handleClickDeleteButton = async () => { deleteMember(); diff --git a/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Members/MemberDetailModal.tsx b/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Members/MemberDetailModal.tsx index 8b602c7e..2766889e 100644 --- a/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Members/MemberDetailModal.tsx +++ b/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Members/MemberDetailModal.tsx @@ -8,9 +8,8 @@ import { ModalFooter, ModalHeading, ModalOverlay, - ModalPortal, } from '@/components/common/modal'; -import useModalContext from '@/components/common/modal/core/useModalContext'; +import { useModal, ModalPortal } from '@/contexts/ModalContext'; import { Member } from '@/types/user'; type MemberDetailModalProps = { @@ -20,7 +19,7 @@ type MemberDetailModalProps = { export default function MemberDetailModal({ modalId, member }: MemberDetailModalProps) { const { userName, userImage, userEmail } = member; - const { closeModal } = useModalContext(); + const { closeModal } = useModal(); const copyEmailToClipboard = () => { navigator.clipboard.writeText(userEmail); }; diff --git a/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Members/MemberInvitationModal.tsx b/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Members/MemberInvitationModal.tsx index 68b660b7..6fbd6444 100644 --- a/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Members/MemberInvitationModal.tsx +++ b/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Members/MemberInvitationModal.tsx @@ -10,9 +10,8 @@ import { ModalFooter, ModalHeading, ModalOverlay, - ModalPortal, } from '@/components/common/modal'; -import useModalContext from '@/components/common/modal/core/useModalContext'; +import { useModal, ModalPortal } from '@/contexts/ModalContext'; import { Toast } from '@/components/common/Toastify'; import BouncingDots from '@/components/common/loading/BouncingDots'; import { getInvitationToken } from '@/api/group'; @@ -32,7 +31,7 @@ export default function MemberInvitationModal({ isLoading, addMember, }: MemberInvitationModalProps) { - const { closeModal } = useModalContext(); + const { closeModal } = useModal(); const [isTokenMethod, setIsTokenMethod] = useState(true); const [email, setEmail] = useState(''); diff --git a/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistCreateModal.tsx b/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistCreateModal.tsx deleted file mode 100644 index 07ad5f1b..00000000 --- a/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistCreateModal.tsx +++ /dev/null @@ -1,75 +0,0 @@ -'use client'; -import { useState } from 'react'; -import Button from '@/components/common/Button'; -import FormField from '@/components/common/formField'; -import { - ModalCloseButton, - ModalContainer, - ModalFooter, - ModalHeading, - ModalOverlay, - ModalPortal, -} from '@/components/common/modal'; -import useModalContext from '@/components/common/modal/core/useModalContext'; -import BouncingDots from '@/components/common/loading/BouncingDots'; -import { validateEmptyValue } from '@/utils/validators'; - -interface TasklistCreateModalProps { - modalId: string; - isLoading: boolean; - createTasklist: (name: string) => void; -} - -export default function TasklistCreateModal({ - modalId, - isLoading, - createTasklist, -}: TasklistCreateModalProps) { - const [name, setName] = useState(''); - const { closeModal } = useModalContext(); - - const handleChangeName = (e: React.ChangeEvent) => { - setName(e.target.value); - }; - - const clearName = () => { - setName(''); - }; - - const handleClickAddButton = async () => { - if (validateEmptyValue(name)) return; - createTasklist(name); - clearName(); - closeModal(modalId); - }; - - return ( - <> - - - - -
- ํ•  ์ผ ๋ชฉ๋ก ์ถ”๊ฐ€ - -
- - - -
-
-
- - ); -} diff --git a/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistDeleteModal.tsx b/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistDeleteModal.tsx index c685ce59..98a54c4c 100644 --- a/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistDeleteModal.tsx +++ b/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistDeleteModal.tsx @@ -7,9 +7,8 @@ import { ModalFooter, ModalHeading, ModalOverlay, - ModalPortal, } from '@/components/common/modal'; -import useModalContext from '@/components/common/modal/core/useModalContext'; +import { useModal, ModalPortal } from '@/contexts/ModalContext'; import BouncingDots from '@/components/common/loading/BouncingDots'; import { Tasklist } from '@/types/tasklist'; @@ -27,7 +26,7 @@ export default function TasklistDeleteModal({ deleteTasklist, }: TasklistDeleteModalProps) { const { name } = tasklist; - const { closeModal } = useModalContext(); + const { closeModal } = useModal(); const handleClickDeleteButton = async () => { deleteTasklist(tasklist); diff --git a/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistItemDropdown.tsx b/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistItemDropdown.tsx index 8561bb51..8f56a670 100644 --- a/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistItemDropdown.tsx +++ b/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistItemDropdown.tsx @@ -3,8 +3,7 @@ import Image from 'next/image'; import DropDown from '@/components/common/dropdown'; import kebabIcon from '@/../public/icons/kebab-icon.svg'; import { Tasklist } from '@/types/tasklist'; -import useModalContext from '@/components/common/modal/core/useModalContext'; - +import { useModal } from '@/contexts/ModalContext'; const ITEM_DROPDOWN_VALUE = ['์ˆ˜์ •ํ•˜๊ธฐ', '์‚ญ์ œํ•˜๊ธฐ']; type TasklistItemDropdownProps = { @@ -16,7 +15,7 @@ export default function TasklistItemDropdown({ onTriggerClick, tasklist, }: TasklistItemDropdownProps) { - const { openModal } = useModalContext(); + const { openModal } = useModal(); return (
) => { setName(e.target.value); diff --git a/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/index.tsx b/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/index.tsx index be7c9450..183ac8f4 100644 --- a/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/index.tsx +++ b/src/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/index.tsx @@ -1,12 +1,12 @@ 'use client'; import useTasklists from '@/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/useTasklists'; import TasklistItem from '@/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistItem'; -import TasklistCreateModal from '@/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistCreateModal'; import TasklistUpdateModal from '@/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistUpdateModal'; import TasklistDeleteModal from '@/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/TasklistDeleteModal'; -import { ModalTrigger } from '@/components/common/modal'; import { Group } from '@/types/group'; import { Tasklist } from '@/types/tasklist'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; type TasklistsProps = { groupId: Group['id']; @@ -18,18 +18,16 @@ export default function Tasklists({ groupId, tasklists }: TasklistsProps) { optimisticTasklists, selectedTasklist, setSelectedTasklist, - isCreateLoading, isUpdateLoading, isDeleteLoading, - createTasklist, updateTasklist, deleteTasklist, } = useTasklists(groupId, tasklists); - const tasklistCreateModalId = `tasklistCreate-${groupId}`; const tasklistUpdateModalId = selectedTasklist ? `tasklistUpdate-${selectedTasklist.id}` : ''; const tasklistDeleteModalId = selectedTasklist ? `tasklistDelete-${selectedTasklist.id}` : ''; const totalTasklistCount = optimisticTasklists.length; + const pathname = usePathname(); return ( <> @@ -38,9 +36,9 @@ export default function Tasklists({ groupId, tasklists }: TasklistsProps) {

ํ•  ์ผ ๋ชฉ๋ก ({totalTasklistCount}๊ฐœ)

- + + ์ƒˆ๋กœ์šด ๋ชฉ๋ก ์ถ”๊ฐ€ํ•˜๊ธฐ - +
    {tasklists.map((tasklist, index) => ( @@ -54,12 +52,6 @@ export default function Tasklists({ groupId, tasklists }: TasklistsProps) {
- - {selectedTasklist && ( ; +} + +export default async function DetailTaskContainer({ params }: Props) { + const taskId = (await params).taskId; + + return ( + <> + {!!taskId && ( + +
+
+
+ + +
+ +
+
+
+ )} + + ); +} diff --git a/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/Background.tsx b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/Background.tsx new file mode 100644 index 00000000..d2d6f0df --- /dev/null +++ b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/Background.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { ReactNode, useCallback, useEffect, useRef } from 'react'; + +interface Props { + children: ReactNode; + isOpen: boolean; +} +export default function Background({ children, isOpen }: Props) { + const detailTaskRef = useRef(null); + const router = useRouter(); + + const closeDetailTaskOutsideClick = useCallback( + (e: MouseEvent) => { + if (!isOpen) return; + + const target = e.target as Node; + + const isInsideDetail = detailTaskRef.current?.contains(target); + const modalPortal = document.querySelector('#modal-container'); + const isInsidePortal = modalPortal?.contains(target); + + if (!isInsideDetail && !isInsidePortal) { + router.back(); + } + }, + [isOpen, router] + ); + + useEffect(() => { + document.addEventListener('mousedown', closeDetailTaskOutsideClick); + return () => { + document.removeEventListener('mousedown', closeDetailTaskOutsideClick); + }; + }, [isOpen, closeDetailTaskOutsideClick]); + + return ( +
+
{children}
+
+ ); +} diff --git a/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/CloseButton.tsx b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/CloseButton.tsx new file mode 100644 index 00000000..97df9fa8 --- /dev/null +++ b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/CloseButton.tsx @@ -0,0 +1,18 @@ +'use client'; + +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; + +export default function CloseButton() { + const router = useRouter(); + + return ( + + ); +} diff --git a/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/CommentField.tsx b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/CommentField.tsx index f2f215b6..5751ed75 100644 --- a/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/CommentField.tsx +++ b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/CommentField.tsx @@ -4,7 +4,7 @@ import { Comment } from '@/components/comment/types'; import { useState } from 'react'; import EditCommentInput from './EditCommentInput'; import axiosClient from '@/lib/axiosClient'; -import useModalContext from '@/components/common/modal/core/useModalContext'; +import { useModal } from '@/contexts/ModalContext'; import RemoveCommentModal from '../../_tasklist/components/ModalContents/RemoveCommentModal'; import { Toast } from '@/components/common/Toastify'; import { revalidateTasks } from '../../_tasklist/actions/task-actions'; @@ -17,7 +17,7 @@ interface Props { export default function CommentField({ comment, taskId }: Props) { const [isEdit, setIsEdit] = useState(false); const [isDelete, setIsDelete] = useState(false); - const { openModal } = useModalContext(); + const { openModal } = useModal(); const [currentComment, setCurrentComment] = useState(comment); const [currentContent, setCurrentContent] = useState(comment.content); diff --git a/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/DetailTask.tsx b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/DetailTask.tsx deleted file mode 100644 index df845d95..00000000 --- a/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/DetailTask.tsx +++ /dev/null @@ -1,61 +0,0 @@ -'use client'; -import axiosClient from '@/lib/axiosClient'; -import { useCallback, useEffect, useState } from 'react'; -import { useTaskActions } from '../../_tasklist/hooks/use-task-actions'; -import Content from './DetailTaskContentField'; -import DetailTaskCommentField from './DetailTaskCommentsField'; -import Button from '@/components/common/Button'; -import Check from '@/assets/Check'; -import clsx from 'clsx'; -import { DetailTaskType } from '../../_tasklist/types/task-type'; - -interface Props { - groupId: string; - taskListId: number; - isDone: boolean; - setIsDone: () => void; - taskId: number; -} - -export default function DetailTask({ taskId, groupId, taskListId, isDone, setIsDone }: Props) { - const [currentTask, setCurrentTask] = useState(); - const [error, setError] = useState(null); - const { toggleTaskDone } = useTaskActions(currentTask); - const buttonText = isDone ? '์™„๋ฃŒ ์ทจ์†Œํ•˜๊ธฐ' : '์™„๋ฃŒ ํ•˜๊ธฐ'; - - const fetchTask = useCallback(async () => { - if (!taskId) return; - try { - const { data } = await axiosClient( - `/groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}` - ); - - setCurrentTask(data); - } catch { - setError(new Error('Task๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.')); - } - }, [groupId, taskListId, taskId]); - - useEffect(() => { - fetchTask(); - }, [fetchTask]); - - if (error) throw error; - if (!currentTask) return; - - return ( -
- - - -
- ); -} diff --git a/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/DetailTaskContentField.tsx b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/DetailTaskContentField.tsx index 9f2ac83c..4aad5c89 100644 --- a/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/DetailTaskContentField.tsx +++ b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/DetailTaskContentField.tsx @@ -1,11 +1,12 @@ +'use client'; import DropDown from '@/components/common/dropdown'; import Image from 'next/image'; import ProfileBadge from '@/components/profile-badge'; import Repeat from '@/assets/Repeat'; import { format } from 'date-fns'; import clsx from 'clsx'; -import { useTaskModals } from '../../_tasklist/hooks/use-task-modals'; import { getRepeatDescription } from '../../_tasklist/utils/format-repeat-schedule'; +import { useTaskModals } from '../../_tasklist/hooks/use-task-modals'; import { DetailTaskType } from '../../_tasklist/types/task-type'; interface Props { @@ -14,7 +15,7 @@ interface Props { } const DROPDOWN_OPTION_LIST = ['์ˆ˜์ •ํ•˜๊ธฐ', '์‚ญ์ œํ•˜๊ธฐ']; -export default function Content({ task, isDone }: Props) { +export default function DetailTaskContentField({ task, isDone }: Props) { const { name, doneBy, updatedAt, date, description, frequency } = task; const { popUpDeleteTaskModal, popUpEditTaskModal } = useTaskModals(); diff --git a/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/ExpandButton.tsx b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/ExpandButton.tsx new file mode 100644 index 00000000..c341b27f --- /dev/null +++ b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/ExpandButton.tsx @@ -0,0 +1,11 @@ +'use client'; + +import Image from 'next/image'; + +export default function ExpandButton() { + return ( + + ); +} diff --git a/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/ToggleDoneButton.tsx b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/ToggleDoneButton.tsx new file mode 100644 index 00000000..9abf15c5 --- /dev/null +++ b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/_components/ToggleDoneButton.tsx @@ -0,0 +1,28 @@ +'use client'; +import Check from '@/assets/Check'; +import Button from '@/components/common/Button'; +import clsx from 'clsx'; +import { DetailTaskType } from '../../_tasklist/types/task-type'; +import { useTaskActions } from '../../_tasklist/hooks/use-task-actions'; + +interface Props { + isDone: boolean; + task: DetailTaskType; +} + +export default function ToggleDoneButton({ isDone, task }: Props) { + const { toggleTaskDone } = useTaskActions(task); + + const buttonText = isDone ? '์™„๋ฃŒ ์ทจ์†Œํ•˜๊ธฐ' : '์™„๋ฃŒ ํ•˜๊ธฐ'; + return ( + + ); +} diff --git a/src/app/(content-layout)/[groupId]/tasklist/@detailTask/default.tsx b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/default.tsx new file mode 100644 index 00000000..ce313c06 --- /dev/null +++ b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/default.tsx @@ -0,0 +1,3 @@ +export default function DefaultRender() { + return null; +} diff --git a/src/app/(content-layout)/[groupId]/tasklist/@detailTask/page.tsx b/src/app/(content-layout)/[groupId]/tasklist/@detailTask/page.tsx deleted file mode 100644 index 54f4634c..00000000 --- a/src/app/(content-layout)/[groupId]/tasklist/@detailTask/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; -import Image from 'next/image'; -import { useCallback, useEffect, useRef } from 'react'; -import { ErrorBoundary } from 'react-error-boundary'; -import DetailTask from './_components/DetailTask'; - -interface Props { - groupId: string; - taskListId: number; - isOpen: boolean; - isDone: boolean; - setIsDone: () => void; - taskId: number; - closeDetailTask: () => void; -} - -export default function DetailTaskContainer({ taskId, isOpen, closeDetailTask, ...props }: Props) { - const detailTaskRef = useRef(null); - - const closingDetailTaskOutsideClick = useCallback( - (e: MouseEvent) => { - if (!isOpen || !taskId) return; - - const target = e.target as Node; - - const isInsideDetail = detailTaskRef.current?.contains(target); - const modalPortal = document.querySelector('#modal-container'); - const isInsidePortal = modalPortal?.contains(target); - - if (!isInsideDetail && !isInsidePortal) { - closeDetailTask(); - } - }, - [isOpen, taskId, closeDetailTask] - ); - - useEffect(() => { - document.addEventListener('mousedown', closingDetailTaskOutsideClick); - return () => { - document.removeEventListener('mousedown', closingDetailTaskOutsideClick); - }; - }, [isOpen, closingDetailTaskOutsideClick]); - - return ( - <> - {isOpen && ( -
-
- - ํ•ด๋‹น ํƒœ์Šคํฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
}> - - -
- - )} - - ); -} diff --git a/src/app/(content-layout)/[groupId]/tasklist/_tasklist/actions/task-actions.ts b/src/app/(content-layout)/[groupId]/tasklist/_tasklist/actions/task-actions.ts index d5ad847c..4af13840 100644 --- a/src/app/(content-layout)/[groupId]/tasklist/_tasklist/actions/task-actions.ts +++ b/src/app/(content-layout)/[groupId]/tasklist/_tasklist/actions/task-actions.ts @@ -12,6 +12,10 @@ export const revalidateTaskLists = async () => { revalidateTag(`getTaskList`); }; +export const revalidateDetailTask = async () => { + revalidateTag(`getDetailTask`); +}; + export const getTaskLists = async (groupId: string) => { try { const { data: taskListsData } = await axiosServer(`/groups/${groupId}`, { @@ -41,3 +45,13 @@ export const getTasks = async (groupId: string, taskListId: number, date: Date | } } }; +export const getDetailTask = async (taskId: string) => { + try { + const { data } = await axiosServer(`/groups/groupId/task-lists/taskListId/tasks/${taskId}`, { + fetchOptions: { next: { tags: ['getDetailTask'] } }, + }); + return data; + } catch (error: unknown) { + console.error(error); + } +}; diff --git a/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/ModalContents/CreateTaskListModal.tsx b/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/ModalContents/CreateTaskListModal.tsx index b4bb5465..5bad5bb8 100644 --- a/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/ModalContents/CreateTaskListModal.tsx +++ b/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/ModalContents/CreateTaskListModal.tsx @@ -9,10 +9,9 @@ import { ModalFooter, ModalHeading, ModalOverlay, - ModalPortal, ModalTrigger, } from '@/components/common/modal'; -import useModalContext from '@/components/common/modal/core/useModalContext'; +import { useModal, ModalPortal } from '@/contexts/ModalContext'; import axiosClient from '@/lib/axiosClient'; import { revalidateTaskLists } from '../../actions/task-actions'; import { Toast } from '@/components/common/Toastify'; @@ -28,7 +27,7 @@ export default function CreateTaskListModal({ groupId }: Props) { const [errorMessage, setErrorMessage] = useState(''); const [isForceShowError, setIsForceShowError] = useState(false); - const { closeModal } = useModalContext(); + const { closeModal } = useModal(); const clearState = () => { setCurrentValue(''); diff --git a/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/ModalContents/RemoveCommentModal.tsx b/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/ModalContents/RemoveCommentModal.tsx index a3de62fc..85f04039 100644 --- a/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/ModalContents/RemoveCommentModal.tsx +++ b/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/ModalContents/RemoveCommentModal.tsx @@ -1,23 +1,16 @@ 'use client'; -import { - ModalContainer, - ModalDescription, - ModalFooter, - ModalHeading, - ModalOverlay, - ModalPortal, -} from '@/components/common/modal'; + +import { ModalContainer, ModalFooter, ModalHeading, ModalOverlay } from '@/components/common/modal'; +import { useModal, ModalPortal } from '@/contexts/ModalContext'; import Image from 'next/image'; import Button from '@/components/common/Button'; -import useModalContext from '@/components/common/modal/core/useModalContext'; - interface Props { modalId: string; onDelete: () => void; } export default function RemoveCommentModal({ modalId, onDelete }: Props) { - const { closeModal } = useModalContext(); + const { closeModal } = useModal(); return ( <> diff --git a/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/ModalContents/RemoveTaskModal.tsx b/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/ModalContents/RemoveTaskModal.tsx index 7100bec1..f3d4f86d 100644 --- a/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/ModalContents/RemoveTaskModal.tsx +++ b/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/ModalContents/RemoveTaskModal.tsx @@ -5,12 +5,10 @@ import { ModalFooter, ModalHeading, ModalOverlay, - ModalPortal, } from '@/components/common/modal'; +import { useModal, ModalPortal } from '@/contexts/ModalContext'; import Image from 'next/image'; import Button from '@/components/common/Button'; -import useModalContext from '@/components/common/modal/core/useModalContext'; - interface Props { taskName: string; modalId: string; @@ -18,7 +16,7 @@ interface Props { } export default function RemoveTaskModal({ taskName, modalId, deleteTask }: Props) { - const { closeModal } = useModalContext(); + const { closeModal } = useModal(); return ( <> diff --git a/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/TaskLists.tsx b/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/TaskLists.tsx index 5a3d1251..946032cb 100644 --- a/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/TaskLists.tsx +++ b/src/app/(content-layout)/[groupId]/tasklist/_tasklist/components/TaskLists.tsx @@ -14,6 +14,9 @@ interface Props { export default function TaskLists({ taskLists, currentTaskListId }: Props) { const router = useRouter(); const searchParams = useSearchParams(); + const sortedTaskLists = taskLists.sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); const handleClickChangeCurrentTaskList = async (taskList: TaskList) => { const params = new URLSearchParams(searchParams.toString()); @@ -27,7 +30,7 @@ export default function TaskLists({ taskLists, currentTaskListId }: Props) { return ( }>
- {taskLists.map((taskList) => { + {sortedTaskLists.map((taskList) => { return (

(tasks); const [isClient, setIsClient] = useState(false); @@ -26,7 +26,7 @@ export default function Tasks({ groupId, tasks, currentTaskList }: Props) { setIsClient(true); }, [tasks]); - const { sensors, handleDragEnd } = useDndKit(currentTasks, currentTaskList!, orderCurrentTasks); + const { sensors, handleDragEnd } = useDndKit(currentTasks, taskListId!, orderCurrentTasks); if (!isClient) return; @@ -43,7 +43,7 @@ export default function Tasks({ groupId, tasks, currentTaskList }: Props) { {currentTasks.map((task) => { return ( (); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ @@ -30,6 +29,8 @@ export default function TasksWiseTask({ task, groupId, taskListId }: Props) { }); const [onDrag, setOnDrag] = useState(false); + const router = useRouter(); + useDndMonitor({ onDragStart: () => setOnDrag(true), onDragEnd: () => setOnDrag(false), @@ -77,11 +78,7 @@ export default function TasksWiseTask({ task, groupId, taskListId }: Props) { }; const openDetailTask = () => { - setIsDetailTaskOpen(true); - }; - - const closeDetailTask = () => { - setIsDetailTaskOpen(false); + router.push(`/${groupId}/tasks/${task.id}`); }; return ( @@ -101,27 +98,16 @@ export default function TasksWiseTask({ task, groupId, taskListId }: Props) { key={task.id} type="taskList" checkDropdownOpen={checkDropdownOpen} - onCheckStatusChange={() => - toggleTaskDone(groupId, taskListId, isDone, toggleTaskStatus) - } + onCheckStatusChange={() => toggleTaskDone(isDone, toggleTaskStatus)} onEdit={() => popUpEditTaskModal(createOrEditModalId)} onDelete={() => popUpDeleteTaskModal(taskDeleteModalId)} - onClick={() => openDetailTask()} isDone={isDone} + onClick={() => openDetailTask()} name={task.name} commentCount={task.commentCount} date={safeFormatDate(task.date)} frequency={task.frequency} /> - void ) { const { saveNewTaskOrder } = useTaskActions(); @@ -31,7 +31,7 @@ export default function useDndKit( const newIndex = currentTasks.findIndex((task) => task.id === over?.id); if (oldIndex !== -1 && newIndex !== -1) { sortCurrentTasks(arrayMove(currentTasks, oldIndex, newIndex)); - await saveNewTaskOrder(currentTaskList!.id, Number(active.id), newIndex); + await saveNewTaskOrder(taskListId, Number(active.id), newIndex); } } }; diff --git a/src/app/(content-layout)/[groupId]/tasklist/_tasklist/hooks/use-task-actions.ts b/src/app/(content-layout)/[groupId]/tasklist/_tasklist/hooks/use-task-actions.ts index c7b89988..32831bdb 100644 --- a/src/app/(content-layout)/[groupId]/tasklist/_tasklist/hooks/use-task-actions.ts +++ b/src/app/(content-layout)/[groupId]/tasklist/_tasklist/hooks/use-task-actions.ts @@ -1,6 +1,7 @@ import axiosClient from '@/lib/axiosClient'; import { Task } from '../types/task-type'; import { Toast } from '@/components/common/Toastify'; +import { revalidateDetailTask } from '../actions/task-actions'; export function useTaskActions(task?: Task) { const deleteTask = async ( @@ -22,21 +23,16 @@ export function useTaskActions(task?: Task) { } }; - const toggleTaskDone = async ( - groupId: string, - taskListId: number, - doneState: boolean, - toggleDoneState: () => void - ) => { + const toggleTaskDone = async (doneState: boolean, toggleDoneState?: () => void) => { if (!task) return; try { - await axiosClient.patch(`/groups/${groupId}/task-lists/${taskListId}/tasks/${task.id}`, { + await axiosClient.patch(`/groups/groupId/task-lists/taskListId/tasks/${task.id}`, { name: task.name, description: task.description, done: !doneState, }); - toggleDoneState(); - + toggleDoneState?.(); + revalidateDetailTask(); if (doneState) return Toast.success('ํ•  ์ผ ์™„๋ฃŒ ์ทจ์†Œ ์„ฑ๊ณต'); if (!doneState) return Toast.success('ํ•  ์ผ ์™„๋ฃŒ ์„ฑ๊ณต'); } catch { diff --git a/src/app/(content-layout)/[groupId]/tasklist/_tasklist/hooks/use-task-modals.ts b/src/app/(content-layout)/[groupId]/tasklist/_tasklist/hooks/use-task-modals.ts index b87d90e0..e5d97a9d 100644 --- a/src/app/(content-layout)/[groupId]/tasklist/_tasklist/hooks/use-task-modals.ts +++ b/src/app/(content-layout)/[groupId]/tasklist/_tasklist/hooks/use-task-modals.ts @@ -3,10 +3,9 @@ type TaskModals = { popUpEditTaskModal: (modalId: string) => void; }; -import useModalContext from '@/components/common/modal/core/useModalContext'; - +import { useModal } from '@/contexts/ModalContext'; export function useTaskModals(): TaskModals { - const { openModal } = useModalContext(); + const { openModal } = useModal(); const popUpEditTaskModal = (modalId: string) => { openModal(modalId); diff --git a/src/app/(content-layout)/[groupId]/tasklist/(tasklist)/layout.tsx b/src/app/(content-layout)/[groupId]/tasklist/layout.tsx similarity index 90% rename from src/app/(content-layout)/[groupId]/tasklist/(tasklist)/layout.tsx rename to src/app/(content-layout)/[groupId]/tasklist/layout.tsx index 68a61a79..f527a404 100644 --- a/src/app/(content-layout)/[groupId]/tasklist/(tasklist)/layout.tsx +++ b/src/app/(content-layout)/[groupId]/tasklist/layout.tsx @@ -1,10 +1,10 @@ export default function TaskListPageLayout({ children, detailTask, -}: Readonly<{ +}: { children: React.ReactNode; detailTask: React.ReactNode; -}>) { +}) { return ( <> {children} diff --git a/src/app/(content-layout)/[groupId]/tasklist/(tasklist)/loading.tsx b/src/app/(content-layout)/[groupId]/tasklist/loading.tsx similarity index 90% rename from src/app/(content-layout)/[groupId]/tasklist/(tasklist)/loading.tsx rename to src/app/(content-layout)/[groupId]/tasklist/loading.tsx index 15b1d56b..f45d0734 100644 --- a/src/app/(content-layout)/[groupId]/tasklist/(tasklist)/loading.tsx +++ b/src/app/(content-layout)/[groupId]/tasklist/loading.tsx @@ -1,4 +1,4 @@ -import { DateSkeleton, TaskListsSkeleton, TaskSkeleton } from '../_tasklist/components/Skeleton'; +import { DateSkeleton, TaskListsSkeleton, TaskSkeleton } from './_tasklist/components/Skeleton'; export default function TaskListPageLoading() { return ( diff --git a/src/app/(content-layout)/[groupId]/tasklist/(tasklist)/page.tsx b/src/app/(content-layout)/[groupId]/tasklist/page.tsx similarity index 84% rename from src/app/(content-layout)/[groupId]/tasklist/(tasklist)/page.tsx rename to src/app/(content-layout)/[groupId]/tasklist/page.tsx index d87af77d..35ef56cc 100644 --- a/src/app/(content-layout)/[groupId]/tasklist/(tasklist)/page.tsx +++ b/src/app/(content-layout)/[groupId]/tasklist/page.tsx @@ -1,13 +1,13 @@ -import { getTaskLists, getTasks } from '../_tasklist/actions/task-actions'; -import ManageTaskItemModal from '../_tasklist/components/manage-task-item-modal/MangeTaskItemModal'; -import DateSwitcher from '../_tasklist/components/DateSwitcher'; -import TaskLists from '../_tasklist/components/TaskLists'; -import Tasks from '../_tasklist/components/Tasks'; import { notFound } from 'next/navigation'; import { Metadata } from 'next'; import { cache } from 'react'; import { getGroupApiResponse, Group } from '@/types/group'; import axiosServer from '@/lib/axiosServer'; +import DateSwitcher from './_tasklist/components/DateSwitcher'; +import TaskLists from './_tasklist/components/TaskLists'; +import Tasks from './_tasklist/components/Tasks'; +import ManageTaskItemModal from './_tasklist/components/manage-task-item-modal/MangeTaskItemModal'; +import { getTaskLists, getTasks } from './_tasklist/actions/task-actions'; interface Props { params: Promise<{ groupId: string }>; @@ -66,7 +66,7 @@ export default async function Page({ params, searchParams }: Props) {

ํ•  ์ผ

- +
); diff --git a/src/app/(content-layout)/[groupId]/tasks/[taskId]/page.tsx b/src/app/(content-layout)/[groupId]/tasks/[taskId]/page.tsx new file mode 100644 index 00000000..d5c410df --- /dev/null +++ b/src/app/(content-layout)/[groupId]/tasks/[taskId]/page.tsx @@ -0,0 +1,27 @@ +import { ErrorBoundary } from 'react-error-boundary'; +import DetailTaskCommentField from '../../tasklist/@detailTask/_components/DetailTaskCommentsField'; +import DetailTaskContentField from '../../tasklist/@detailTask/_components/DetailTaskContentField'; +import ToggleDoneButton from '../../tasklist/@detailTask/_components/ToggleDoneButton'; +import { getDetailTask } from '../../tasklist/_tasklist/actions/task-actions'; +import { DetailTaskType } from '../../tasklist/_tasklist/types/task-type'; + +interface Props { + params: Promise<{ taskId: string }>; +} +export default async function DetailTaskPage({ params }: Props) { + const taskId = (await params).taskId; + + const task: DetailTaskType = await getDetailTask(taskId); + + const isDone = Boolean(task.doneAt); + + return ( + ํ•ด๋‹น ํƒœ์Šคํฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.}> +
+ + + +
+
+ ); +} diff --git a/src/app/(content-layout)/articles/[articleId]/_articleId/components/CommentList.tsx b/src/app/(content-layout)/articles/[articleId]/_articleId/components/CommentList.tsx index 64d69094..5f7850ff 100644 --- a/src/app/(content-layout)/articles/[articleId]/_articleId/components/CommentList.tsx +++ b/src/app/(content-layout)/articles/[articleId]/_articleId/components/CommentList.tsx @@ -8,7 +8,7 @@ import DangerModal from '@/components/danger-modal'; import { Toast } from '@/components/common/Toastify'; import CommentItem from '@/components/comment'; import BouncingDots from '@/components/common/loading/BouncingDots'; -import useModalContext from '@/components/common/modal/core/useModalContext'; +import { useModal } from '@/contexts/ModalContext'; import { ArticleComment, ArticleComments } from '@/components/comment/types'; import { deleteArticleComment, getArticleComments, patchArticleComment } from '../action'; import { validateEmptyValue } from '@/utils/validators'; @@ -25,7 +25,7 @@ export default function CommentList({ const [commentIdToDelete, setCommentIdToDelete] = useState(null); const [commentToEdit, setCommentToEdit] = useState<{ id: number; content: string } | null>(null); const [isPending, setIsPending] = useState(false); - const { openModal } = useModalContext(); + const { openModal } = useModal(); const [ref, inView] = useInView({ threshold: 1.0 }); const [moreComments, setMoreComments] = useState([]); diff --git a/src/app/(content-layout)/articles/[articleId]/_articleId/components/DetailArticleInfo.tsx b/src/app/(content-layout)/articles/[articleId]/_articleId/components/DetailArticleInfo.tsx index 35ba9d41..f07f58cb 100644 --- a/src/app/(content-layout)/articles/[articleId]/_articleId/components/DetailArticleInfo.tsx +++ b/src/app/(content-layout)/articles/[articleId]/_articleId/components/DetailArticleInfo.tsx @@ -10,7 +10,7 @@ import { GetArticleDetailResponse } from '@/types/article'; import { formatTimeDistance } from '@/utils/date'; import LikeToggleButton from '../../../_articles/components/LikeToggleButton'; import { useUser } from '@/contexts/UserContext'; -import useModalContext from '@/components/common/modal/core/useModalContext'; +import { useModal } from '@/contexts/ModalContext'; import DetailArticleDropdown from './DetailArticleDropdown'; import axiosClient from '@/lib/axiosClient'; @@ -19,7 +19,7 @@ const DEFAULT_IMAGE = process.env.NEXT_PUBLIC_DEFAULT_IMAGE; export default function DetailArticleInfo({ detail }: { detail: GetArticleDetailResponse }) { const router = useRouter(); const { user } = useUser(); - const { openModal } = useModalContext(); + const { openModal } = useModal(); const [isPending, setIsPending] = useState(false); const handleArticleDelete = async () => { diff --git a/src/app/(form-layout)/login/_login/SendResetPassword.tsx b/src/app/(form-layout)/login/_login/SendResetPassword.tsx index 1e111367..91e4e918 100644 --- a/src/app/(form-layout)/login/_login/SendResetPassword.tsx +++ b/src/app/(form-layout)/login/_login/SendResetPassword.tsx @@ -10,10 +10,9 @@ import { ModalFooter, ModalHeading, ModalOverlay, - ModalPortal, ModalTrigger, } from '@/components/common/modal'; -import useModalContext from '@/components/common/modal/core/useModalContext'; +import { useModal, ModalPortal } from '@/contexts/ModalContext'; import axiosClient from '@/lib/axiosClient'; import { validateEmail } from '@/utils/validators'; import { AUTH_ERROR_MESSAGES } from '@/constants/messages/signup'; @@ -22,7 +21,7 @@ import BouncingDots from '@/components/common/loading/BouncingDots'; const redirectUrl = process.env.NEXT_PUBLIC_RESET_PASSWORD; export default function SendResetPassword() { - const { closeModal } = useModalContext(); + const { closeModal } = useModal(); const modalId = `resetPassword`; const errorMessageConstant = AUTH_ERROR_MESSAGES.sendResetPassword; diff --git a/src/app/(form-layout)/signup/_signup/SignupForm.tsx b/src/app/(form-layout)/signup/_signup/SignupForm.tsx index 6700cb95..1924fa1c 100644 --- a/src/app/(form-layout)/signup/_signup/SignupForm.tsx +++ b/src/app/(form-layout)/signup/_signup/SignupForm.tsx @@ -2,13 +2,10 @@ import { useState, ChangeEvent } from 'react'; import { useRouter } from 'next/navigation'; -import axiosClient from '@/lib/axiosClient'; import FormField from '@/components/common/formField'; import Button from '@/components/common/Button'; import PasswordToggleButton from './PasswordToggleButton'; import usePasswordVisibility from '@/utils/use-password-visibility'; -import useModalContext from '@/components/common/modal/core/useModalContext'; -import SignupSuccessModal from '@/components/signup-alert-modal/SignupSuccessModal'; import { validateEmail, validatePassword, @@ -16,39 +13,36 @@ import { validateLengthLimit, } from '@/utils/validators'; import { AUTH_ERROR_MESSAGES } from '@/constants/messages/signup'; -import { Toast } from '@/components/common/Toastify'; -import { setClientCookie } from '@/lib/cookie/client'; - -interface ErrorResponse { - response?: { - data: { - message: string; - }; - }; -} +import SignupSuccessModal from './SignupSuccessModal'; +import BouncingDots from '@/components/common/loading/BouncingDots'; +import { useSignup } from '@/app/(form-layout)/signup/_signup/hooks/useSignup'; export default function SignupForm() { - const { openModal } = useModalContext(); const router = useRouter(); const { isPasswordVisible, togglePasswordVisibility } = usePasswordVisibility(); + const { + duplicateError, + isSuccess, + isLoading, + handleSignup, + handleAutoLogin, + cancelAutoLogin, + clearDuplicateError, + } = useSignup(); + const [formData, setFormData] = useState({ nickname: '', email: '', password: '', passwordConfirmation: '', }); + const setFieldValue = (key: keyof typeof formData, value: string) => { setFormData((prev) => ({ ...prev, [key]: value.trim(), })); }; - const [duplicateError, setDuplicateError] = useState({ - nickname: false, - email: false, - }); - const [isSuccess, setIsSuccess] = useState(false); - const [loginTimeoutId, setLoginTimeoutId] = useState(null); function getNicknameErrorMessage() { if (formData.nickname.trim() === '') { @@ -143,48 +137,10 @@ export default function SignupForm() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - try { - await axiosClient.post('/auth/signUp', { - email: formData.email, - password: formData.password, - passwordConfirmation: formData.passwordConfirmation, - nickname: formData.nickname, - }); - - const timeoutId = setTimeout(async () => { - try { - const loginRes = await axiosClient.post('/auth/signIn', { - email: formData.email, - password: formData.password, - }); - const { accessToken, refreshToken } = loginRes.data; + handleSignup(formData); - setClientCookie('accessToken', accessToken); - setClientCookie('refreshToken', refreshToken); - - router.push('/nogroup'); - } catch { - Toast.error('์ž๋™ ๋กœ๊ทธ์ธ ์‹คํŒจ. ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.'); - router.push('/login'); - } - }, 5000); - - setLoginTimeoutId(timeoutId); - - openModal('signup-success'); - setIsSuccess(true); - } catch (error: unknown) { - const err = error as ErrorResponse; - const message = err.response?.data?.message || ''; - - setDuplicateError({ - email: message.includes('์ด๋ฉ”์ผ'), - nickname: message.includes('๋‹‰๋„ค์ž„'), - }); - - Toast.error('ํšŒ์›๊ฐ€์ž… ์‹คํŒจ'); - } + handleAutoLogin(formData.email, formData.password); }; const isFormInvalid = @@ -210,7 +166,7 @@ export default function SignupForm() { setFieldValue(key, e.target.value); if (key === 'email' || key === 'nickname') { - setDuplicateError((prev) => ({ ...prev, [key]: false })); + clearDuplicateError(key); } }} placeholder={field.placeholder} @@ -218,14 +174,20 @@ export default function SignupForm() { /> ))} - {isSuccess && ( { - if (loginTimeoutId) clearTimeout(loginTimeoutId); + cancelAutoLogin(); router.push('/login'); }} /> diff --git a/src/components/signup-alert-modal/SignupSuccessModal.tsx b/src/app/(form-layout)/signup/_signup/SignupSuccessModal.tsx similarity index 93% rename from src/components/signup-alert-modal/SignupSuccessModal.tsx rename to src/app/(form-layout)/signup/_signup/SignupSuccessModal.tsx index 78377458..7fe5dddf 100644 --- a/src/components/signup-alert-modal/SignupSuccessModal.tsx +++ b/src/app/(form-layout)/signup/_signup/SignupSuccessModal.tsx @@ -7,9 +7,9 @@ import { ModalFooter, ModalHeading, ModalOverlay, - ModalPortal, } from '@/components/common/modal'; -import Button from '../common/Button'; +import { ModalPortal } from '@/contexts/ModalContext'; +import Button from '../../../../components/common/Button'; interface Props { nickname: string; diff --git a/src/app/(form-layout)/signup/_signup/hooks/useSignup.ts b/src/app/(form-layout)/signup/_signup/hooks/useSignup.ts new file mode 100644 index 00000000..6078bfed --- /dev/null +++ b/src/app/(form-layout)/signup/_signup/hooks/useSignup.ts @@ -0,0 +1,170 @@ +import { useState, useRef, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { useMutation } from '@tanstack/react-query'; +import axiosClient from '@/lib/axiosClient'; +import { useModal } from '@/contexts/ModalContext'; +import { Toast } from '@/components/common/Toastify'; +import { setClientCookie } from '@/lib/cookie/client'; +import { useUser } from '@/contexts/UserContext'; + +export interface SignupRequest { + email: string; + password: string; + passwordConfirmation: string; + nickname: string; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface LoginResponse { + accessToken: string; + refreshToken: string; +} + +export interface ErrorResponse { + response?: { + data: { + message: string; + }; + }; +} + +const signupUser = async (data: SignupRequest): Promise => { + await axiosClient.post('/auth/signUp', data); +}; + +const loginUser = async (data: LoginRequest): Promise => { + const response = await axiosClient.post('/auth/signIn', data); + return response.data; +}; + +export const useSignup = () => { + const { openModal } = useModal(); + const router = useRouter(); + const { fetchUser } = useUser(); + + const [duplicateError, setDuplicateError] = useState({ + nickname: false, + email: false, + }); + + const [isSuccess, setIsSuccess] = useState(false); + const [pendingLogin, setPendingLogin] = useState(null); + + const loginTimeoutRef = useRef(null); + const autoLoginCancelledRef = useRef(false); + + const signupMutation = useMutation({ + mutationFn: signupUser, + onSuccess: () => { + openModal('signup-success'); + setIsSuccess(true); + autoLoginCancelledRef.current = false; + + if (pendingLogin) { + loginTimeoutRef.current = setTimeout(() => { + if (!autoLoginCancelledRef.current) { + loginMutation.mutate(pendingLogin); + } + }, 5000); + } + }, + onError: (error: unknown) => { + const err = error as ErrorResponse; + const message = err.response?.data?.message || ''; + + setDuplicateError({ + email: message.includes('์ด๋ฉ”์ผ'), + nickname: message.includes('๋‹‰๋„ค์ž„'), + }); + + Toast.error('ํšŒ์›๊ฐ€์ž… ์‹คํŒจ'); + setPendingLogin(null); + autoLoginCancelledRef.current = true; + }, + }); + + const loginMutation = useMutation({ + mutationFn: loginUser, + onSuccess: async (data: LoginResponse) => { + const { accessToken, refreshToken } = data; + + setClientCookie('accessToken', accessToken); + setClientCookie('refreshToken', refreshToken); + + await fetchUser(); + router.push('/nogroup'); + }, + onError: () => { + Toast.error('์ž๋™ ๋กœ๊ทธ์ธ ์‹คํŒจ.'); + router.push('/login'); + }, + }); + + const handleSignup = useCallback( + (formData: SignupRequest) => { + if (signupMutation.isPending || loginMutation.isPending) { + return; + } + + if (loginTimeoutRef.current) { + clearTimeout(loginTimeoutRef.current); + loginTimeoutRef.current = null; + } + + signupMutation.mutate(formData); + }, + [signupMutation, loginMutation] + ); + + const handleAutoLogin = useCallback((email: string, password: string) => { + setPendingLogin({ email, password }); + }, []); + + const cancelAutoLogin = useCallback(() => { + if (loginTimeoutRef.current) { + clearTimeout(loginTimeoutRef.current); + loginTimeoutRef.current = null; + } + + autoLoginCancelledRef.current = true; + setPendingLogin(null); + }, []); + + const clearDuplicateError = useCallback((field: 'email' | 'nickname') => { + setDuplicateError((prev) => ({ ...prev, [field]: false })); + }, []); + + const cleanup = useCallback(() => { + if (loginTimeoutRef.current) { + clearTimeout(loginTimeoutRef.current); + loginTimeoutRef.current = null; + } + autoLoginCancelledRef.current = true; + }, []); + + const isFormDisabled = signupMutation.isPending || loginMutation.isPending; + + const isLoading = signupMutation.isPending || loginMutation.isPending; + + return { + duplicateError, + isSuccess, + isLoading, + isFormDisabled, + + signupMutation, + loginMutation, + + handleSignup, + handleAutoLogin, + cancelAutoLogin, + clearDuplicateError, + cleanup, + + isPendingAutoLogin: !!pendingLogin && !autoLoginCancelledRef.current, + }; +}; diff --git a/src/app/@modal/(...)[...modal]/create-tasklist/page.tsx b/src/app/@modal/(...)[...modal]/create-tasklist/page.tsx new file mode 100644 index 00000000..49be2bb1 --- /dev/null +++ b/src/app/@modal/(...)[...modal]/create-tasklist/page.tsx @@ -0,0 +1,58 @@ +'use client'; +import { useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Button from '@/components/common/Button'; +import FormField from '@/components/common/formField'; +import Modal from '@/components/common/modal/newModal'; +import BouncingDots from '@/components/common/loading/BouncingDots'; +import { validateEmptyValue } from '@/utils/validators'; +import { createTasklistAction } from '@/app/(content-layout)/[groupId]/(groupId)/_[groupId]/Tasklists/actions'; + +export default function Page() { + const { groupId } = useParams<{ groupId: string }>(); + const router = useRouter(); + const [name, setName] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleChangeName = (e: React.ChangeEvent) => { + setName(e.target.value); + }; + + const handleClickAddButton = async () => { + setIsLoading(true); + if (validateEmptyValue(name)) { + setIsLoading(false); + return; + } + createTasklistAction(Number(groupId), name).then(() => { + setIsLoading(false); + router.back(); + }); + }; + + return ( + + + +
+ ํ•  ์ผ ๋ชฉ๋ก ์ถ”๊ฐ€ + +
+ + + +
+
+ ); +} diff --git a/src/app/@modal/default.tsx b/src/app/@modal/default.tsx new file mode 100644 index 00000000..6ddf1b76 --- /dev/null +++ b/src/app/@modal/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return null; +} diff --git a/src/app/[...modal]/page.tsx b/src/app/[...modal]/page.tsx new file mode 100644 index 00000000..e7c23934 --- /dev/null +++ b/src/app/[...modal]/page.tsx @@ -0,0 +1,5 @@ +'use client'; + +import notFound from '@/app/not-found'; + +export default notFound; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9670033b..879c4182 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,10 @@ import type { Metadata } from 'next'; import './globals.css'; import Header from '@/components/layout/gnb/Header'; -import { ModalProvider } from '@/components/common/modal'; import ToastProvider from '@/components/common/Toastify/ToasProvider'; import { UserProvider } from '@/contexts/UserContext'; +import { ModalProvider } from '@/contexts/ModalContext'; +import QueryProvider from '@/lib/query/queryProvider'; export const metadata: Metadata = { title: 'Coworkers', @@ -11,23 +12,28 @@ export const metadata: Metadata = { export default function RootLayout({ children, + modal, }: Readonly<{ children: React.ReactNode; + modal: React.ReactNode; }>) { return ( - - - -
-
- {children} -
- - - - + + + + +
+
+ {children} +
+
{modal}
+ + + + + ); diff --git a/src/app/mypage/MypageClient.tsx b/src/app/mypage/MypageClient.tsx index 2ee7a466..d97fb150 100644 --- a/src/app/mypage/MypageClient.tsx +++ b/src/app/mypage/MypageClient.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import { useState, useEffect } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { updateUserNickname, verifyPassword } from './_mypage/action'; import ProfileImageUploader from './_mypage/ProfileImageUploader'; import NicknameField from './_mypage/NicknameField'; @@ -10,7 +11,7 @@ import { Toast } from '@/components/common/Toastify'; import ChangePasswordModal from './_mypage/mypage-modal/ChangePasswordModal'; import DeleteAccountModal from './_mypage/mypage-modal/DeleteAccountModal'; import ConfirmDeleteAccountModal from './_mypage/mypage-modal/ConfirmDeleteAccountModal'; -import useModalContext from '@/components/common/modal/core/useModalContext'; +import { useModal } from '@/contexts/ModalContext'; import FormField from '@/components/common/formField'; import { useUser } from '@/contexts/UserContext'; @@ -20,7 +21,39 @@ export default function MyPageClient() { const [nickname, setNickname] = useState(user?.nickname ?? ''); const [nicknameError, setNicknameError] = useState(''); const [password, setPassword] = useState(''); - const { openModal, closeModal } = useModalContext(); + const [passwordError, setPasswordError] = useState(''); + const { openModal, closeModal } = useModal(); + const queryClient = useQueryClient(); + + const updateNicknameMutation = useMutation({ + mutationFn: (newNickname: string) => updateUserNickname(newNickname), + onSuccess: async () => { + await fetchUser(); + queryClient.invalidateQueries({ queryKey: ['user'] }); + Toast.success('๋‹‰๋„ค์ž„ ๋ณ€๊ฒฝ ์„ฑ๊ณต'); + }, + onError: (error) => { + const errorObj = error as { response?: { data?: { message?: string } } }; + const message = errorObj?.response?.data?.message || '๋‹‰๋„ค์ž„์„ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'; + setNicknameError(message); + Toast.error('๋‹‰๋„ค์ž„ ๋ณ€๊ฒฝ ์‹คํŒจ'); + }, + }); + + const verifyPasswordMutation = useMutation({ + mutationFn: ({ email, password }: { email: string; password: string }) => + verifyPassword(email, password), + onSuccess: (res) => { + if (res?.accessToken) { + setPasswordError(''); + openModal('change-password'); + } + }, + onError: () => { + setPasswordError('์˜ฌ๋ฐ”๋ฅธ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.'); + Toast.error('๋น„๋ฐ€๋ฒˆํ˜ธ ์ธ์ฆ ์‹คํŒจ'); + }, + }); useEffect(() => { if (user?.image) { @@ -42,6 +75,16 @@ export default function MyPageClient() { return false; }; + const handleNicknameUpdate = async () => { + if (isSameNickname()) return; + updateNicknameMutation.mutate(nickname); + }; + + const handlePasswordVerification = async () => { + if (!email) return; + verifyPasswordMutation.mutate({ email, password }); + }; + return (
@@ -60,35 +103,17 @@ export default function MyPageClient() { nicknameError={nicknameError} setNickname={setNickname} setNicknameError={setNicknameError} - onClick={async () => { - if (isSameNickname()) return; - try { - await updateUserNickname(nickname); - await fetchUser(); - Toast.success('๋‹‰๋„ค์ž„ ๋ณ€๊ฒฝ ์„ฑ๊ณต'); - } catch (error: unknown) { - const errorObj = error as { response?: { data?: { message?: string } } }; - const message = - errorObj?.response?.data?.message || '๋‹‰๋„ค์ž„์„ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'; - setNicknameError(message); - Toast.error('๋‹‰๋„ค์ž„ ๋ณ€๊ฒฝ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); - } - }} + onClick={handleNicknameUpdate} + isLoading={updateNicknameMutation.isPending} /> { - try { - const res = await verifyPassword(email!, password); - if (res?.accessToken) { - openModal('change-password'); - } - } catch (e) { - Toast.error('๋น„๋ฐ€๋ฒˆํ˜ธ ์ธ์ฆ ์‹คํŒจ'); - } - }} + onClick={handlePasswordVerification} + isLoading={verifyPasswordMutation.isPending} + passwordError={passwordError} + setPasswordError={setPasswordError} />
} diff --git a/src/app/mypage/_mypage/PasswordField.tsx b/src/app/mypage/_mypage/PasswordField.tsx index d280032f..f1b33dfa 100644 --- a/src/app/mypage/_mypage/PasswordField.tsx +++ b/src/app/mypage/_mypage/PasswordField.tsx @@ -2,14 +2,25 @@ import Button from '@/components/common/Button'; import FormField from '@/components/common/formField'; +import BouncingDots from '@/components/common/loading/BouncingDots'; interface PasswordFieldProps { password: string; setPassword: (value: string) => void; onClick: () => void; + isLoading?: boolean; + passwordError?: string; + setPasswordError?: (value: string) => void; } -export default function PasswordField({ password, setPassword, onClick }: PasswordFieldProps) { +export default function PasswordField({ + password, + setPassword, + onClick, + isLoading = false, + passwordError, + setPasswordError, +}: PasswordFieldProps) { return ( setPassword(e.target.value)} + onChange={(e) => { + setPassword(e.target.value); + setPasswordError?.(''); + }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onClick(); } }} + isFailure={!!passwordError} + errorMessage={passwordError} rightSlot={
-
} diff --git a/src/app/mypage/_mypage/mypage-modal/ChangePasswordModal.tsx b/src/app/mypage/_mypage/mypage-modal/ChangePasswordModal.tsx index 984bb9b5..e4e5afac 100644 --- a/src/app/mypage/_mypage/mypage-modal/ChangePasswordModal.tsx +++ b/src/app/mypage/_mypage/mypage-modal/ChangePasswordModal.tsx @@ -2,14 +2,8 @@ import { useState, useTransition } from 'react'; import { useRouter } from 'next/navigation'; -import { - ModalContainer, - ModalFooter, - ModalHeading, - ModalOverlay, - ModalPortal, -} from '@/components/common/modal'; -import useModalContext from '@/components/common/modal/core/useModalContext'; +import { ModalContainer, ModalFooter, ModalHeading, ModalOverlay } from '@/components/common/modal'; +import { useModal, ModalPortal } from '@/contexts/ModalContext'; import { validatePassword, validateConfirmPassword } from '@/utils/validators'; import FormField from '@/components/common/formField'; import Button from '@/components/common/Button'; @@ -26,7 +20,7 @@ interface PasswordChangeSuccessModalProps { } export default function ChangePasswordModal({ onClose }: PasswordChangeSuccessModalProps) { - const { closeModal } = useModalContext(); + const { closeModal } = useModal(); const router = useRouter(); const [isPending, startTransition] = useTransition(); const { logoutUser } = useUser(); diff --git a/src/app/mypage/_mypage/mypage-modal/ConfirmDeleteAccountModal.tsx b/src/app/mypage/_mypage/mypage-modal/ConfirmDeleteAccountModal.tsx index 0629d6c7..2bb8f339 100644 --- a/src/app/mypage/_mypage/mypage-modal/ConfirmDeleteAccountModal.tsx +++ b/src/app/mypage/_mypage/mypage-modal/ConfirmDeleteAccountModal.tsx @@ -8,16 +8,15 @@ import { ModalFooter, ModalHeading, ModalOverlay, - ModalPortal, } from '@/components/common/modal'; -import useModalContext from '@/components/common/modal/core/useModalContext'; +import { useModal, ModalPortal } from '@/contexts/ModalContext'; import axiosClient from '@/lib/axiosClient'; import Button from '@/components/common/Button'; import { Toast } from '@/components/common/Toastify'; import { deleteClientCookie } from '@/lib/cookie/client'; export default function ConfirmDeleteAccountModal() { - const { closeModal } = useModalContext(); + const { closeModal } = useModal(); const { logoutUser } = useUser(); return ( diff --git a/src/app/mypage/_mypage/mypage-modal/DeleteAccountModal.tsx b/src/app/mypage/_mypage/mypage-modal/DeleteAccountModal.tsx index b214bcfb..a257cb3e 100644 --- a/src/app/mypage/_mypage/mypage-modal/DeleteAccountModal.tsx +++ b/src/app/mypage/_mypage/mypage-modal/DeleteAccountModal.tsx @@ -7,13 +7,12 @@ import { ModalFooter, ModalHeading, ModalOverlay, - ModalPortal, } from '@/components/common/modal'; -import useModalContext from '@/components/common/modal/core/useModalContext'; +import { useModal, ModalPortal } from '@/contexts/ModalContext'; import Button from '@/components/common/Button'; export default function DeleteAccountModal() { - const { closeModal, openModal } = useModalContext(); + const { closeModal, openModal } = useModal(); return ( <> diff --git a/src/components/common/ErrorModal/index.tsx b/src/components/common/ErrorModal/index.tsx index e43ea4c5..5484ee81 100644 --- a/src/components/common/ErrorModal/index.tsx +++ b/src/components/common/ErrorModal/index.tsx @@ -4,11 +4,10 @@ import { ModalDescription, ModalFooter, ModalOverlay, - ModalPortal, } from '@/components/common/modal'; +import { useModal, ModalPortal } from '@/contexts/ModalContext'; import Image from 'next/image'; import Button from '@/components/common/Button'; -import useModalContext from '@/components/common/modal/core/useModalContext'; interface Props { modalId: string; @@ -17,7 +16,7 @@ interface Props { buttonText?: string; } export default function ErrorModal({ modalId, description, onClick, buttonText }: Props) { - const { closeModal } = useModalContext(); + const { closeModal } = useModal(); const modalButtonText = buttonText ? buttonText : '๋‹ซ๊ธฐ'; return ( diff --git a/src/components/common/formField/Fields.tsx b/src/components/common/formField/Fields.tsx new file mode 100644 index 00000000..94b82820 --- /dev/null +++ b/src/components/common/formField/Fields.tsx @@ -0,0 +1,32 @@ +'use client'; + +import clsx from 'clsx'; +import { GAP_SIZE, LABEL_SIZE } from './style'; +import { FieldProps } from './type'; + +export default function Fields({ + label, + required, + errorMessage, + gapSize = '12', + labelSize = '16/16', + render, +}: FieldProps) { + const showError = !!errorMessage; + + return ( +
+
+ {label && ( + + )} + {render()} +
+ + {showError && {errorMessage}} +
+ ); +} diff --git a/src/components/common/formField/compound/ImageUploader.tsx b/src/components/common/formField/compound/ImageUploader.tsx index 30691c3f..4fef0de7 100644 --- a/src/components/common/formField/compound/ImageUploader.tsx +++ b/src/components/common/formField/compound/ImageUploader.tsx @@ -41,12 +41,12 @@ export default function ImageUploader({ imageUploaderType, image, inputRef }: Im + ); +} diff --git a/src/components/common/modal/newModal/Container.tsx b/src/components/common/modal/newModal/Container.tsx new file mode 100644 index 00000000..e95b4a6b --- /dev/null +++ b/src/components/common/modal/newModal/Container.tsx @@ -0,0 +1,15 @@ +import clsx from 'clsx'; + +export default function Container({ className, children, ...props }: React.ComponentProps<'div'>) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/common/modal/newModal/Description.tsx b/src/components/common/modal/newModal/Description.tsx new file mode 100644 index 00000000..75ffbb04 --- /dev/null +++ b/src/components/common/modal/newModal/Description.tsx @@ -0,0 +1,15 @@ +import clsx from 'clsx'; + +export default function Description({ className, children, ...props }: React.ComponentProps<'p'>) { + return ( +

+ {children} +

+ ); +} diff --git a/src/components/common/modal/newModal/Footer.tsx b/src/components/common/modal/newModal/Footer.tsx new file mode 100644 index 00000000..1ff370c8 --- /dev/null +++ b/src/components/common/modal/newModal/Footer.tsx @@ -0,0 +1,9 @@ +import clsx from 'clsx'; + +export default function Footer({ className, children, ...props }: React.ComponentProps<'div'>) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/common/modal/newModal/Heading.tsx b/src/components/common/modal/newModal/Heading.tsx new file mode 100644 index 00000000..59aa69a0 --- /dev/null +++ b/src/components/common/modal/newModal/Heading.tsx @@ -0,0 +1,12 @@ +import clsx from 'clsx'; + +export default function Heading({ className, children, ...props }: React.ComponentProps<'h2'>) { + return ( +

+ {children} +

+ ); +} diff --git a/src/components/common/modal/newModal/Overlay.tsx b/src/components/common/modal/newModal/Overlay.tsx new file mode 100644 index 00000000..4b823283 --- /dev/null +++ b/src/components/common/modal/newModal/Overlay.tsx @@ -0,0 +1,47 @@ +'use client'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import clsx from 'clsx'; + +interface OverlayProps extends React.ComponentProps<'div'> { + disableOverlayClose?: boolean; +} + +export default function Overlay({ + disableOverlayClose = false, + onClick, + className, + children, + ...props +}: OverlayProps) { + const router = useRouter(); + + const handleClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget && !disableOverlayClose) { + onClick?.(e); + router.back(); + } + }; + + useEffect(function lockBodyScroll() { + const originalStyle = window.getComputedStyle(document.body).overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = originalStyle; + }; + }, []); + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/common/modal/newModal/index.ts b/src/components/common/modal/newModal/index.ts new file mode 100644 index 00000000..d0138340 --- /dev/null +++ b/src/components/common/modal/newModal/index.ts @@ -0,0 +1,17 @@ +import CloseButton from './CloseButton'; +import Container from './Container'; +import Description from './Description'; +import Footer from './Footer'; +import Heading from './Heading'; +import Overlay from './Overlay'; + +const Modal = { + CloseButton, + Container, + Description, + Footer, + Heading, + Overlay, +}; + +export default Modal; diff --git a/src/components/danger-modal/index.tsx b/src/components/danger-modal/index.tsx index 8c2d1599..61e5c0c5 100644 --- a/src/components/danger-modal/index.tsx +++ b/src/components/danger-modal/index.tsx @@ -5,11 +5,10 @@ import { ModalFooter, ModalHeading, ModalOverlay, - ModalPortal, } from '@/components/common/modal'; +import { useModal, ModalPortal } from '@/contexts/ModalContext'; import Image from 'next/image'; import Button from '@/components/common/Button'; -import useModalContext from '@/components/common/modal/core/useModalContext'; interface DangerModalProps { modalId: string; @@ -29,7 +28,7 @@ export default function DangerModal({ onConfirm, disabled, }: DangerModalProps) { - const { closeModal } = useModalContext(); + const { closeModal } = useModal(); const closeButton = closeButtonText ? closeButtonText : '๋‹ซ๊ธฐ'; diff --git a/src/components/layout/gnb/Header.tsx b/src/components/layout/gnb/Header.tsx index a79ecd6a..7b69c7ca 100644 --- a/src/components/layout/gnb/Header.tsx +++ b/src/components/layout/gnb/Header.tsx @@ -111,7 +111,7 @@ export default function Header() {
- {user && ( + {!isLoading && user && ( > & Pick; -interface MangeGroupProps { +interface ManageGroupProps { groupData?: ManageGroup; groupNames: string[]; } -export default function ManageGroup({ groupData, groupNames }: MangeGroupProps) { +export default function ManageGroup({ groupData, groupNames }: ManageGroupProps) { const isEdit = !!groupData; + const groupButtonText = isEdit ? '์ˆ˜์ •ํ•˜๊ธฐ' : '์ƒ์„ฑํ•˜๊ธฐ'; const { - group, - isNameFailure, - isImageEmpty, - isSubmit, + form: { + register, + handleSubmit, + formState: { errors }, + }, + image, isPending, - imageErrorMessage, - nameErrorMessage, - handleNameChange, handleImageChange, - handleManageGroupSubmit, - } = useManageGroup({ - isEdit, - groupData, - groupNames, - }); - - const groupButtonText = isEdit ? '์ˆ˜์ •ํ•˜๊ธฐ' : '์ƒ์„ฑํ•˜๊ธฐ'; + handleSubmitGroup, + } = useManageGroup({ isEdit, groupData, groupNames }); return ( -
+
- ( + + {({ inputRef }) => ( + + )} + + )} /> - { + const { onBlur, ...inputProps } = register('name'); + return ( + + ); + }} />
+
- - - - - ); -} diff --git a/src/contexts/ModalContext.tsx b/src/contexts/ModalContext.tsx new file mode 100644 index 00000000..8d8a8d86 --- /dev/null +++ b/src/contexts/ModalContext.tsx @@ -0,0 +1,81 @@ +'use client'; +import { useContext, createContext, useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +type ModalContextValue = { + toggleModal: (id: string) => void; + openModal: (id: string) => void; + closeModal: (id: string) => void; + checkIsModalOpen: (id: string) => boolean; +}; + +const ModalContext = createContext(null); + +type ModalState = { + [id: string]: { isOpen: boolean }; +}; + +export const ModalProvider = ({ children }: { children: React.ReactNode }) => { + const [modals, setModals] = useState({}); + + const openModal = (id: string) => { + setModals((prev) => ({ + ...prev, + [id]: { isOpen: true }, + })); + }; + + const closeModal = (id: string) => { + setModals((prev) => ({ + ...prev, + [id]: { isOpen: false }, + })); + }; + + const toggleModal = (id: string) => { + setModals((prev) => ({ + ...prev, + [id]: { isOpen: prev[id]?.isOpen !== true }, + })); + }; + + const checkIsModalOpen = (id: string) => { + return !!modals[id]?.isOpen; + }; + + return ( + + {children} + + ); +}; + +export const useModal = () => { + const ctx = useContext(ModalContext); + if (!ctx) throw new Error('useModal must be used within ModalProvider'); + return ctx; +}; + +export function ModalPortal({ children, modalId }: { children: React.ReactNode; modalId: string }) { + const [modalPortal, setModalPortal] = useState(null); + const { checkIsModalOpen } = useModal(); + + useEffect( + function initializeModalPortal() { + const modalElement = document.querySelector('#modal-container'); + setModalPortal(modalElement); + }, + [setModalPortal] + ); + + if (!modalPortal) return null; + + return <>{createPortal(checkIsModalOpen(modalId) ? children : null, modalPortal)}; +} diff --git a/src/hooks/useZodForm.ts b/src/hooks/useZodForm.ts new file mode 100644 index 00000000..f9770ed1 --- /dev/null +++ b/src/hooks/useZodForm.ts @@ -0,0 +1,19 @@ +import { useForm, UseFormProps, UseFormReturn } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { ZodSchema } from 'zod'; + +export default function useZodForm>({ + validationSchema, + defaultValues, + ...rest +}: { + validationSchema: ZodSchema; + defaultValues: UseFormProps['defaultValues']; +} & Omit, 'resolver' | 'defaultValues'>): UseFormReturn { + return useForm({ + resolver: zodResolver(validationSchema), + defaultValues, + mode: 'all', + ...rest, + }); +} diff --git a/src/lib/query/queryProvider.tsx b/src/lib/query/queryProvider.tsx new file mode 100644 index 00000000..a9630dbe --- /dev/null +++ b/src/lib/query/queryProvider.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { useState } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +export default function QueryProvider({ children }: { children: React.ReactNode }) { + const [queryClient] = useState(() => new QueryClient()); + + return {children}; +}