diff --git a/next.config.ts b/next.config.ts index 6087927e..19c9dc49 100644 --- a/next.config.ts +++ b/next.config.ts @@ -21,7 +21,8 @@ const nextConfig: NextConfig = { }, { test: /\.svg$/i, - resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, + include: path.resolve(__dirname, "src/assets/icons"), + resourceQuery: { not: /url/ }, use: [ { loader: "@svgr/webpack", @@ -31,6 +32,30 @@ const nextConfig: NextConfig = { }, ], }, + { + test: /\.svg$/i, + include: path.resolve(__dirname, "src/assets/landing"), + resourceQuery: { not: /url/ }, + use: [ + { + loader: "@svgr/webpack", + options: { + icon: false, + dimensions: false, + svgo: true, + svgoConfig: { + plugins: [ + "preset-default", + { name: "removeDimensions", active: true }, + { name: "convertPathData", active: true }, + { name: "cleanupIDs", active: true }, + { name: "removeUnusedNS", active: true }, + ], + }, + }, + }, + ], + }, ); fileLoaderRule.exclude = /\.svg$/i; return config; diff --git a/package-lock.json b/package-lock.json index 3574026c..7b1567d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "plango", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.2.2", "@tanstack/react-query": "^5.90.6", "@tanstack/react-query-devtools": "^5.90.2", @@ -15,11 +18,14 @@ "axios": "^1.13.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lodash": "^4.17.21", + "lucide-react": "^0.555.0", + "motion": "^12.23.24", "next": "^15.5.6", - "react": "19.2.0", + "react": "^19.2.0", "react-datepicker": "^8.9.0", - "react-dom": "19.2.0", + "react-dom": "^19.2.0", "react-hook-form": "^7.66.0", "react-loading-skeleton": "^3.5.0", "react-responsive": "^10.0.1", @@ -1864,6 +1870,60 @@ "storybook": "^0.0.0-0 || ^9.0.0 || ^9.1.0-0 || ^9.2.0-0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz", @@ -9854,6 +9914,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", + "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "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-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -10040,9 +10127,9 @@ } }, "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==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -11617,9 +11704,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -12106,6 +12193,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.555.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz", + "integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -12368,6 +12464,47 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz", + "integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.23.24", + "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.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "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/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", diff --git a/package.json b/package.json index 684ea686..5d6bbe61 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "build-storybook": "storybook build" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.2.2", "@tanstack/react-query": "^5.90.6", "@tanstack/react-query-devtools": "^5.90.2", @@ -20,11 +23,14 @@ "axios": "^1.13.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lodash": "^4.17.21", + "lucide-react": "^0.555.0", + "motion": "^12.23.24", "next": "^15.5.6", - "react": "19.2.0", + "react": "^19.2.0", "react-datepicker": "^8.9.0", - "react-dom": "19.2.0", + "react-dom": "^19.2.0", "react-hook-form": "^7.66.0", "react-loading-skeleton": "^3.5.0", "react-responsive": "^10.0.1", diff --git a/public/assets/images/img-done.svg b/public/assets/images/img-done.svg index cff0807d..6b0607c6 100644 --- a/public/assets/images/img-done.svg +++ b/public/assets/images/img-done.svg @@ -1,14 +1,32 @@ - - - - - - + + + + + + + + + + + + + + + + + + + + - - + + + + + + diff --git a/public/assets/images/img-team.svg b/public/assets/images/img-team.svg new file mode 100644 index 00000000..69d7564e --- /dev/null +++ b/public/assets/images/img-team.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/images/img-todo1.svg b/public/assets/images/img-todo1.svg new file mode 100644 index 00000000..e838fec3 --- /dev/null +++ b/public/assets/images/img-todo1.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/images/img-todo2.svg b/public/assets/images/img-todo2.svg new file mode 100644 index 00000000..c676df0a --- /dev/null +++ b/public/assets/images/img-todo2.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/images/img-todo3.svg b/public/assets/images/img-todo3.svg new file mode 100644 index 00000000..28733a05 --- /dev/null +++ b/public/assets/images/img-todo3.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/landing/img-avatar-1.jpg b/public/assets/landing/img-avatar-1.jpg new file mode 100644 index 00000000..bad27801 Binary files /dev/null and b/public/assets/landing/img-avatar-1.jpg differ diff --git a/public/assets/landing/img-avatar-3.jpg b/public/assets/landing/img-avatar-3.jpg new file mode 100644 index 00000000..01921851 Binary files /dev/null and b/public/assets/landing/img-avatar-3.jpg differ diff --git a/public/assets/landing/img-avatar-4.jpeg b/public/assets/landing/img-avatar-4.jpeg new file mode 100644 index 00000000..ee9df660 Binary files /dev/null and b/public/assets/landing/img-avatar-4.jpeg differ diff --git a/public/assets/landing/wave1.svg b/public/assets/landing/wave1.svg new file mode 100644 index 00000000..3846aeb1 --- /dev/null +++ b/public/assets/landing/wave1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/assets/landing/wave2.svg b/public/assets/landing/wave2.svg new file mode 100644 index 00000000..fa79e87c --- /dev/null +++ b/public/assets/landing/wave2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/api/.gitkeep b/src/api/.gitkeep deleted file mode 100644 index 74c14402..00000000 --- a/src/api/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -// api 디렉토리 diff --git a/src/api/article/get-article-detail-ssr.ts b/src/api/article/get-article-detail-ssr.ts new file mode 100644 index 00000000..633103d6 --- /dev/null +++ b/src/api/article/get-article-detail-ssr.ts @@ -0,0 +1,13 @@ +import { ArticleDetail } from "@/types/article"; +import { serverFetch } from "@/lib/server/server-fetch"; + +const getArticleDetailSSR = async ({ + articleId, +}: { + articleId: number; +}): Promise => { + const res = await serverFetch(`/articles/${articleId}`); + return res; +}; + +export default getArticleDetailSSR; diff --git a/src/api/article/get-article-detail.ts b/src/api/article/get-article-detail.ts index 4d7a1545..406f8df1 100644 --- a/src/api/article/get-article-detail.ts +++ b/src/api/article/get-article-detail.ts @@ -1,9 +1,9 @@ +import axiosInstance from "@/lib/axios"; import { ArticleDetail } from "@/types/article"; -import { serverFetch } from "@/lib/server/server-fetch"; const getArticleDetail = async ({ articleId }: { articleId: number }): Promise => { - const res = await serverFetch(`/articles/${articleId}`); - return res; + const res = await axiosInstance.get(`/articles/${articleId}`); + return res.data; }; export default getArticleDetail; diff --git a/src/api/tasklist/index-server.ts b/src/api/tasklist/index-server.ts index 3bb83afd..7b551d84 100644 --- a/src/api/tasklist/index-server.ts +++ b/src/api/tasklist/index-server.ts @@ -1,10 +1,13 @@ -import serverAxios from "@/lib/axios-server"; +import { serverFetch } from "@/lib/server/server-fetch"; import { TaskListProps } from "@/types/task"; +import { GroupTaskList } from "@/types/tasklist"; -export async function getGroupTaskListsforServer(groupId: number) { +export async function getGroupTaskListsforServer(groupId: number): Promise { try { - const res = await serverAxios.get(`/groups/${groupId}`); - return res.data; + return await serverFetch(`/groups/${groupId}`, { + method: "GET", + cache: "no-store", + }); } catch (e) { console.error(e); throw e; @@ -16,10 +19,10 @@ export async function getTaskListForServer({ groupId, taskListId, date }: TaskLi const params = new URLSearchParams(); params.append("date", date); - const res = await serverAxios.get( - `/groups/${groupId}/task-lists/${taskListId}?${params.toString()}`, - ); - return res.data; + return await serverFetch(`/groups/${groupId}/task-lists/${taskListId}?${params.toString()}`, { + method: "GET", + cache: "no-store", + }); } catch (e) { console.error(e); throw e; diff --git a/src/api/tasklist/index.ts b/src/api/tasklist/index.ts index e9c9286d..6a40fc0c 100644 --- a/src/api/tasklist/index.ts +++ b/src/api/tasklist/index.ts @@ -1,7 +1,10 @@ "use client"; -import { TaskDetailProps, TaskListProps } from "@/types/task"; +import { TaskCommonProps, TaskDetailProps, TaskListProps } from "@/types/task"; import axiosInstance from "@/lib/axios"; +import { MemberPermissionProps } from "@/types/tasklist"; +import z4 from "zod/v4"; +import { taskDetailSchema } from "@/lib/schema"; export async function getGroupTaskLists(groupId: number) { try { @@ -39,3 +42,195 @@ export async function getTaskDetail({ groupId, taskListId, taskId }: TaskDetailP throw e; } } + +export async function getMemberInfo({ groupId, userId }: MemberPermissionProps) { + try { + const res = await axiosInstance.get(`/groups/${groupId}/member/${userId}`); + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function postRecurring({ + groupId, + taskListId, + recurringData, +}: TaskCommonProps & { + recurringData: z4.infer; + dateString?: string; +}) { + try { + const res = await axiosInstance.post( + `/groups/${groupId}/task-lists/${taskListId}/recurring`, + recurringData, + ); + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function postTask({ groupId, name }: { groupId: number; name: string }) { + try { + const res = await axiosInstance.post(`/groups/${groupId}/task-lists`, { name }); + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function patchRecurring({ + groupId, + taskListId, + taskId, + name, + description, +}: TaskDetailProps & { name?: string; description?: string; dateString: string }) { + try { + const payload = Object.fromEntries( + Object.entries({ name, description }).filter(([, value]) => value !== undefined), + ); + + const res = await axiosInstance.patch( + `/groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}`, + payload, + ); + + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function patchRecurringDoneAt({ + groupId, + taskListId, + taskId, + done, +}: TaskDetailProps & { done: boolean; dateString: string }) { + try { + const res = await axiosInstance.patch( + `/groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}`, + { done }, + ); + + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function deleteOneRecurring({ + groupId, + taskListId, + taskId, +}: TaskDetailProps & { dateString: string }) { + try { + const res = await axiosInstance.delete( + `/groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}`, + ); + + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function deleteAllRecurring({ + groupId, + taskListId, + taskId, + recurringId, +}: TaskDetailProps & { recurringId: number; dateString: string }) { + try { + const res = await axiosInstance.delete( + `/groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}/recurring/${recurringId}`, + ); + + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function getTaskComments(taskId: number) { + try { + const res = await axiosInstance.get(`/tasks/${taskId}/comments`); + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function postComment({ + comment, + taskId, +}: TaskDetailProps & { comment: string; dateString: string }) { + try { + const res = await axiosInstance.post(`/tasks/${taskId}/comments`, { content: comment }); + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function deleteComment({ + commentId, + taskId, +}: TaskDetailProps & { commentId: number; dateString: string }) { + try { + const res = await axiosInstance.delete(`/tasks/${taskId}/comments/${commentId}`); + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function patchComment({ + comment, + commentId, + taskId, +}: TaskDetailProps & { comment: string; commentId: number; dateString: string }) { + try { + const res = await axiosInstance.patch(`/tasks/${taskId}/comments/${commentId}`, { + content: comment, + }); + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} + +export async function patchTaskOrder({ + groupId, + taskListId, + taskId, + newIndex, +}: TaskDetailProps & { + newIndex: number; +}) { + try { + const res = await axiosInstance.patch( + `/groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}/order`, + { + displayIndex: newIndex, + }, + ); + return res.data; + } catch (e) { + console.error(e); + throw e; + } +} diff --git a/src/api/team/delete-team.ts b/src/api/team/delete-team.ts new file mode 100644 index 00000000..bf4e3fcb --- /dev/null +++ b/src/api/team/delete-team.ts @@ -0,0 +1,8 @@ +import axiosInstance from "@/lib/axios"; + +const deleteTeam = async (groupId: number) => { + const res = await axiosInstance.delete(`/groups/${groupId}`); + return res; +}; + +export default deleteTeam; diff --git a/src/api/team/delete-todo.ts b/src/api/team/delete-todo.ts new file mode 100644 index 00000000..bec45f9c --- /dev/null +++ b/src/api/team/delete-todo.ts @@ -0,0 +1,7 @@ +import axiosInstance from "@/lib/axios"; + +const deleteTodo = async ({ groupId, taskListId }: { groupId: number; taskListId: number }) => { + await axiosInstance.delete(`/groups/${groupId}/task-lists/${taskListId}`); +}; + +export default deleteTodo; diff --git a/src/api/team/get-invite-token.ts b/src/api/team/get-invite-token.ts new file mode 100644 index 00000000..dff6ba1a --- /dev/null +++ b/src/api/team/get-invite-token.ts @@ -0,0 +1,8 @@ +import axiosInstance from "@/lib/axios"; + +const getInviteToken = async (groupId: number) => { + const res = await axiosInstance.get(`/groups/${groupId}/invitation`); + return res.data; +}; + +export default getInviteToken; diff --git a/src/api/team/get-user-groups.ts b/src/api/team/get-user-groups.ts new file mode 100644 index 00000000..165d4956 --- /dev/null +++ b/src/api/team/get-user-groups.ts @@ -0,0 +1,10 @@ +import axiosInstance from "@/lib/axios"; +import { GetUserGroup } from "@/types/group"; + +const getUserGroups = async (): Promise => { + const res = await axiosInstance.get("/user/groups"); + + return res.data; +}; + +export default getUserGroups; diff --git a/src/api/team/patch-groups.ts b/src/api/team/patch-groups.ts new file mode 100644 index 00000000..912baa0c --- /dev/null +++ b/src/api/team/patch-groups.ts @@ -0,0 +1,9 @@ +import axiosInstance from "@/lib/axios"; +import { GroupUpdateBody } from "@/types/group"; + +const patchGroups = async (groupId: number, updates: GroupUpdateBody) => { + const res = await axiosInstance.patch(`/groups/${groupId}`, updates); + return res.data; +}; + +export default patchGroups; diff --git a/src/api/team/patch-todo.ts b/src/api/team/patch-todo.ts new file mode 100644 index 00000000..c1e3536e --- /dev/null +++ b/src/api/team/patch-todo.ts @@ -0,0 +1,16 @@ +import axiosInstance from "@/lib/axios"; + +type patchTodoprops = { + groupId: number; + taskListId: number; + name: string; +}; + +const patchTodo = async ({ groupId, taskListId, name }: patchTodoprops) => { + const payload = { name: name }; + + const res = await axiosInstance.patch(`/groups/${groupId}/task-lists/${taskListId}`, payload); + return res; +}; + +export default patchTodo; diff --git a/src/api/team/post-join-team.ts b/src/api/team/post-join-team.ts new file mode 100644 index 00000000..77edd0a6 --- /dev/null +++ b/src/api/team/post-join-team.ts @@ -0,0 +1,10 @@ +import axiosInstance from "@/lib/axios"; +import { GroupJoinRequest } from "@/types/group"; + +const postTeamJoin = async (payload: GroupJoinRequest) => { + const res = await axiosInstance.post(`/groups/accept-invitation`, payload); + + return res.data; +}; + +export default postTeamJoin; diff --git a/src/api/team/post-todo.ts b/src/api/team/post-todo.ts new file mode 100644 index 00000000..974a0d2e --- /dev/null +++ b/src/api/team/post-todo.ts @@ -0,0 +1,14 @@ +import axiosInstance from "@/lib/axios"; + +type postTodoprops = { + groupId: number; + param: string; +}; + +const postTodo = async ({ groupId, param }: postTodoprops) => { + const payload = { name: param }; + const res = await axiosInstance.post(`/groups/${groupId}/task-lists`, payload); + return res; +}; + +export default postTodo; diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index b50bb956..5517650e 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -20,10 +20,10 @@ const title = "로그인"; export default function Page() { const authSuccess = useAuthSuccess(); const { isOpen, setOpen, setClose } = useToggle(); - const { mutate, isPending } = useSignInMutation(authSuccess); + const { mutateAsync, isPending } = useSignInMutation(authSuccess); const handleSubmit = async (data: SignInSchema) => { - mutate(data); + await mutateAsync(data); }; return ( diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 90686c90..aa016886 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -19,9 +19,9 @@ const title = "회원가입"; export default function Page() { const authSuccess = useAuthSuccess(); - const { mutate, isPending } = useSignUpMutation(authSuccess); + const { mutateAsync, isPending } = useSignUpMutation(authSuccess); const handleSubmit = async (data: SignUpSchema) => { - mutate(data); + await mutateAsync(data); }; return ( <> diff --git a/src/app/(routes)/(board)/article/[articleId]/page.tsx b/src/app/(routes)/(board)/article/[articleId]/page.tsx index 5686f853..b61e56e9 100644 --- a/src/app/(routes)/(board)/article/[articleId]/page.tsx +++ b/src/app/(routes)/(board)/article/[articleId]/page.tsx @@ -1,10 +1,11 @@ import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"; -import getArticleDetail from "@/api/article/get-article-detail"; +import getArticleDetailSSR from "@/api/article/get-article-detail-ssr"; import { notFound } from "next/navigation"; import { AxiosError } from "axios"; import { Container } from "@/components/layout"; import { Floating, ScrollTopButton } from "@/components/ui"; import { ArticleDetailInfo, ArticleCommentSection } from "@/components/features/article"; +import ArticleEditToast from "@/components/features/article/article-detail/article-edit-toast"; import { ARTICLE_COMMON_STYLES, ARTICLE_DETAIL_STYLES, @@ -19,7 +20,7 @@ export default async function ArticleDetailPage({ const articleIdNum = Number(articleId); const queryClient = new QueryClient(); - const article = await getArticleDetail({ articleId: articleIdNum }).catch(e => { + const article = await getArticleDetailSSR({ articleId: articleIdNum }).catch(e => { if (e instanceof AxiosError && e.response?.status === 404) notFound(); throw e; }); @@ -31,6 +32,7 @@ export default async function ArticleDetailPage({

자유게시판

+
diff --git a/src/app/(routes)/(board)/article/create/page.tsx b/src/app/(routes)/(board)/article/create/page.tsx index 66ac6ab8..70e3c958 100644 --- a/src/app/(routes)/(board)/article/create/page.tsx +++ b/src/app/(routes)/(board)/article/create/page.tsx @@ -7,6 +7,7 @@ import postImagesUpload from "@/api/image/post-images-upload"; import postArticle from "@/api/article/post-article"; import { articleFormSchema, ArticleFormSchema } from "@/lib/schema"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useToast } from "@/providers/toast-provider"; import { CreateArticleData } from "@/types/article"; import { Container } from "@/components/layout"; import { ArticleFormFields } from "@/components/features/article"; @@ -20,8 +21,8 @@ export default function CreateArticlesPage() { const [selectedFile, setSelectedFile] = useState(null); const queryClient = useQueryClient(); const router = useRouter(); + const { showToast } = useToast(); - // TODO: isSuccess, isError, error 처리는 추후 토스트로 처리 예정 const { mutate, isPending: isMutating } = useMutation({ mutationFn: async (data: ArticleFormSchema) => { let imageUrl: string | undefined; @@ -47,8 +48,12 @@ export default function CreateArticlesPage() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["getArticles"] }); + sessionStorage.setItem("articleCreateToast", "게시글이 등록되었습니다."); router.replace("/article"); }, + onError: () => { + showToast("게시글 등록에 실패했습니다.", "error"); + }, }); const handleImageChange = (fileOrUrl: File | string | null) => { diff --git a/src/app/(routes)/(board)/article/page.tsx b/src/app/(routes)/(board)/article/page.tsx index f1dd1e7e..3836b378 100644 --- a/src/app/(routes)/(board)/article/page.tsx +++ b/src/app/(routes)/(board)/article/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { Suspense, useState } from "react"; +import { Suspense, useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuthStore } from "@/store/auth.store"; +import { useToast } from "@/providers/toast-provider"; import { Container } from "@/components/layout"; import { SearchBar, BestArticleSection, AllArticleSection } from "@/components/features/article"; import { Floating, ScrollTopButton, CircleButton } from "@/components/ui"; @@ -20,6 +21,22 @@ export default function ArticlesPage() { const router = useRouter(); const isLogin = !!currentUser; const [showLoginModal, setShowLoginModal] = useState(false); + const { showToast } = useToast(); + + useEffect(() => { + setTimeout(() => { + const deleteToastMsg = sessionStorage.getItem("articleDeleteToast"); + if (deleteToastMsg) { + sessionStorage.removeItem("articleDeleteToast"); + showToast(deleteToastMsg, "success"); + } + const createToastMsg = sessionStorage.getItem("articleCreateToast"); + if (createToastMsg) { + sessionStorage.removeItem("articleCreateToast"); + showToast(createToastMsg, "success"); + } + }, 120); + }, [showToast]); const handleWriteClick = () => { if (!isLogin) { diff --git a/src/app/(routes)/(landing)/page.tsx b/src/app/(routes)/(landing)/page.tsx new file mode 100644 index 00000000..b991ea73 --- /dev/null +++ b/src/app/(routes)/(landing)/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { motion } from "motion/react"; +import { + IntroBanner, + ProblemSection, + SolutionSection, + DailySummarySection, + WhyPlangoSection, + PopularPosts, + DeveloperStory, + ProductDemo, + Footer, +} from "@/components/features/landing"; + +const sectionBackgrounds = [ + "var(--gray-900)", + "#091014", + "linear-gradient(to bottom, #091014, var(--gray-900))", + "var(--gray-900)", + "var(--gray-900)", + "var(--gray-900)", + "var(--gray-900)", + "linear-gradient(45deg, var(--gray-900), var(--gray-800))", +]; + +export default function Home() { + const [scrollProgress, setScrollProgress] = useState(0); + const containerRef = useRef(null); + + useEffect(() => { + const handleScroll = () => { + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const scrollHeight = document.documentElement.scrollHeight - window.innerHeight; + const progress = scrollTop / scrollHeight; + setScrollProgress(progress); + }; + + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + const getCurrentBackground = () => { + const sectionCount = sectionBackgrounds.length; + const sectionProgress = scrollProgress * (sectionCount - 1); + const currentSectionIndex = Math.floor(sectionProgress); + const nextSectionIndex = Math.min(currentSectionIndex + 1, sectionCount - 1); + const sectionBlend = sectionProgress - currentSectionIndex; + + return { + current: sectionBackgrounds[currentSectionIndex], + next: sectionBackgrounds[nextSectionIndex], + blend: sectionBlend, + }; + }; + + const { current, next, blend } = getCurrentBackground(); + + return ( + <> + + + +
+ + + + + + + + +
+
+ + ); +} diff --git a/src/app/(routes)/my/page.tsx b/src/app/(routes)/my/page.tsx index 3b385f29..9c135b3c 100644 --- a/src/app/(routes)/my/page.tsx +++ b/src/app/(routes)/my/page.tsx @@ -27,15 +27,17 @@ export default function My() { const handleSubmit = async (data: ChangeProfileSchema) => { const prevUser = useAuthStore.getState().user; const payload: ChangeProfileSchema = {}; + if (data.nickname !== prevUser?.nickname) { payload.nickname = data.nickname; } - if (data.image !== prevUser?.image) { + if (data.image !== undefined && data.image !== prevUser?.image) { payload.image = data.image; } if (Object.keys(payload).length === 0) { return showToast("변경된 내용이 없습니다.", "error"); } + console.log(payload); mutate(payload); }; diff --git a/src/app/(routes)/team/[id]/edit/page.tsx b/src/app/(routes)/team/[id]/edit/page.tsx new file mode 100644 index 00000000..8db817da --- /dev/null +++ b/src/app/(routes)/team/[id]/edit/page.tsx @@ -0,0 +1,180 @@ +"use client"; +import { useState, useEffect } from "react"; +import Image from "next/image"; +import _ from "lodash"; +import { useRouter, useParams } from "next/navigation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button, Input } from "@/components/ui"; +import { Container } from "@/components/layout"; +import { GroupUpdateRequest, GetGroupsResponse, GroupUpdateBody } from "@/types/group"; +import getGroups from "@/api/team/get-groups"; +import postImagesUpload from "@/api/image/post-images-upload"; +import patchGroups from "@/api/team/patch-groups"; +import IcProfile from "@/assets/icons/ic-image-circle.svg"; +import IcEdit from "@/assets/icons/ic-pencil-border.svg"; +import { devConsoleError } from "@/lib/error"; +import { useToast } from "@/providers/toast-provider"; +import TeamEditSkeleton from "@/components/skeleton-ui/team-edit-skeleton"; + +export default function TeamEditPagae() { + const param = useParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const groupId = Number(param.id); + + const { isPending, data } = useQuery({ + queryKey: ["getGroups", groupId], + queryFn: () => getGroups(groupId), + }); + + const [formData, setFormData] = useState({}); + const [selectedImgFile, setSelectedImgFile] = useState(); + + useEffect(() => { + setFormData({ + name: data?.name, + image: data?.image, + }); + }, [data]); + + const uploadImageMutate = useMutation({ + mutationFn: postImagesUpload, + onSuccess: res => { + const imageUrl = res.url as string; + const newData: GroupUpdateBody = {}; + newData.name = formData.name; + newData.image = imageUrl; + + updateGroupMutate.mutate({ groupId: groupId, payload: newData }); + }, + onError: error => { + devConsoleError(error); + showToast("이미지 업로드에 문제가 생겼습니다.", "error"); + }, + }); + + const updateGroupMutate = useMutation({ + mutationFn: async ({ groupId, payload }: GroupUpdateRequest) => { + await patchGroups(groupId, payload); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["getGroups", groupId], + }); + sessionStorage.setItem("teamEditMessage", "팀이 수정되었습니다."); + router.replace(`/team/${groupId}`); + }, + onError: error => { + devConsoleError(error); + showToast("팀 수정에 문제가 생겼습니다.", "error"); + }, + }); + + if (!data) return ; + if (isPending) return ; + + const handleImageChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + const reader = new FileReader(); + if (file) { + reader.readAsDataURL(file); + reader.onloadend = () => { + formData.image = reader.result as string; + setSelectedImgFile(file); + }; + } + }; + + const handleNameChange = (event: React.ChangeEvent) => { + const inputName = event.target.value; + setFormData(prev => ({ ...prev, name: inputName })); + }; + + const handleFormValidate = (form: GroupUpdateBody) => { + if (!form.name) { + alert("이름은 공란일 수 없습니다"); + return; + } + + const newData: GroupUpdateBody = {}; + if (data.name !== form.name) { + newData.name = form.name; + } + if (data.image !== form.image) { + newData.image = form.image; + } + + if (_.isEmpty(newData)) { + alert("수정된 내용이 없습니다."); + return; + } + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + handleFormValidate(formData); + + if (selectedImgFile) { + uploadImageMutate.mutate({ url: selectedImgFile }); + } else { + updateGroupMutate.mutate({ groupId, payload: formData }); + } + }; + + return ( + +

팀 수정하기

+
+
+

팀 프로필

+ +
+ + + + + + + +

+ 팀 이름은 회사명이나 모임 이름 등으로 설정하면 좋아요. +

+
+ ); +} diff --git a/src/app/(routes)/team/[id]/page.tsx b/src/app/(routes)/team/[id]/page.tsx index 5f0060c7..e31d1009 100644 --- a/src/app/(routes)/team/[id]/page.tsx +++ b/src/app/(routes)/team/[id]/page.tsx @@ -9,9 +9,12 @@ import { GetGroupsResponse, TodoListProps } from "@/types/group"; import { Member } from "@/types/tasklist"; import { TeamTitle, TodoList, TeamMember, TeamReport } from "@/components/features/team"; import { useAuthStore } from "@/store/auth.store"; +import { useToast } from "@/providers/toast-provider"; +import TeamSkeleton from "@/components/skeleton-ui/team-skeleton"; export default function TeamPages() { const param = useParams(); + const { showToast } = useToast(); const groupId = Number(param.id); const user = useAuthStore(state => state.user); const initialized = useAuthStore(state => state.initialized); @@ -25,6 +28,16 @@ export default function TeamPages() { queryFn: () => getGroups(groupId), }); + useEffect(() => { + setTimeout(() => { + const teamJoinMessage = sessionStorage.getItem("teamJoinMessage"); + if (teamJoinMessage) { + sessionStorage.removeItem("teamJoinMessage"); + showToast(teamJoinMessage, "success"); + } + }, 150); + }, [showToast]); + useEffect(() => { if (groupData) { setMembers(groupData.members); @@ -35,12 +48,14 @@ export default function TeamPages() { useEffect(() => { if (user?.memberships) { const isBeing = user.memberships.filter(mb => mb.groupId === Number(groupId)); - setUserRole(isBeing[0].role); + if (isBeing[0]?.role) { + setUserRole(isBeing[0].role); + } } }, [user]); if (!initialized) { - return null; + return ; } if (!user) { @@ -48,15 +63,15 @@ export default function TeamPages() { } const { id: userId } = user; - if (isPending) return
로딩중
; + if (isPending) return ; if (!groupData) return

팀이 없습니다.

; return ( - - - {userRole === "ADMIN" && } - + + + {userRole === "ADMIN" && } + ); } diff --git a/src/app/(routes)/team/[id]/tasklist/@detail/(.)[taskId]/page.tsx b/src/app/(routes)/team/[id]/tasklist/@detail/(.)[taskId]/page.tsx deleted file mode 100644 index 0f061bc8..00000000 --- a/src/app/(routes)/team/[id]/tasklist/@detail/(.)[taskId]/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -// import TaskDetailWrapper from "@/components/features/tasklist/task-detail-wrapper"; - -export default async function TaskDetailPage({ - params, -}: { - params: Promise<{ taskId: string; id: string }>; -}) { - const { taskId, id } = await params; - - return

{taskId + id}

; - // ; -} diff --git a/src/app/(routes)/team/[id]/tasklist/[taskId]/page.tsx b/src/app/(routes)/team/[id]/tasklist/[taskId]/page.tsx deleted file mode 100644 index 365bf7f7..00000000 --- a/src/app/(routes)/team/[id]/tasklist/[taskId]/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -// import TaskDetailWrapper from "@/components/features/tasklist/task-detail-wrapper"; - -export default async function TaskDetailPage({ - params, -}: { - params: Promise<{ taskId: string; id: string }>; -}) { - const { taskId, id } = await params; - - //모달 아님 - return

{taskId + id}

; - // ; -} diff --git a/src/app/(routes)/team/[id]/tasklist/[taskListId]/@detail/(.)[taskId]/page.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/@detail/(.)[taskId]/page.tsx new file mode 100644 index 00000000..54a60b45 --- /dev/null +++ b/src/app/(routes)/team/[id]/tasklist/[taskListId]/@detail/(.)[taskId]/page.tsx @@ -0,0 +1,11 @@ +import TaskDetailWrapper from "@/components/features/tasklist/task-detail/task-detail-wrapper"; + +export default async function TaskDetailModal({ + params, +}: { + params: Promise<{ taskId: string; id: string }>; +}) { + const { taskId, id } = await params; + + return ; +} diff --git a/src/app/(routes)/team/[id]/tasklist/@detail/default.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/@detail/default.tsx similarity index 100% rename from src/app/(routes)/team/[id]/tasklist/@detail/default.tsx rename to src/app/(routes)/team/[id]/tasklist/[taskListId]/@detail/default.tsx diff --git a/src/app/(routes)/team/[id]/tasklist/[taskListId]/[taskId]/page.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/[taskId]/page.tsx new file mode 100644 index 00000000..ce916ee6 --- /dev/null +++ b/src/app/(routes)/team/[id]/tasklist/[taskListId]/[taskId]/page.tsx @@ -0,0 +1,12 @@ +import TaskDetailWrapper from "@/components/features/tasklist/task-detail/task-detail-wrapper"; + +export default async function TaskDetailPage({ + params, +}: { + params: Promise<{ taskId: string; id: string }>; +}) { + const { taskId, id } = await params; + + //모달 아님 + return ; +} diff --git a/src/app/(routes)/team/[id]/tasklist/[taskListId]/layout-content.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/layout-content.tsx new file mode 100644 index 00000000..e0b02028 --- /dev/null +++ b/src/app/(routes)/team/[id]/tasklist/[taskListId]/layout-content.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { layoutStyle } from "../index.styles"; +import { usePathname } from "next/navigation"; +import useModalStore from "@/store/modal.store"; +import { createPortal } from "react-dom"; + +interface CommonProps { + children: React.ReactNode; + detail: React.ReactNode; +} + +export default function LayoutContent({ children, detail }: CommonProps) { + const pathname = usePathname(); + const { isOpen } = useModalStore(); + + const [isOpenDetailModal, setIsOpenDetailModal] = useState(false); + + useEffect(() => { + const checkModal = () => { + if (isOpen) { + setIsOpenDetailModal(true); + } else { + setIsOpenDetailModal(false); + } + }; + + checkModal(); + }, [pathname]); + + return ( + <> +
+
{children}
+ + {isOpenDetailModal && + createPortal(
{detail}
, document.body)} +
+ + ); +} diff --git a/src/app/(routes)/team/[id]/tasklist/[taskListId]/layout.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/layout.tsx new file mode 100644 index 00000000..7688f4fd --- /dev/null +++ b/src/app/(routes)/team/[id]/tasklist/[taskListId]/layout.tsx @@ -0,0 +1,23 @@ +import TaskListProvider from "./tasklist-provider"; +import LayoutContent from "./layout-content"; +import { formatDateToISOString, setDateTime } from "@/lib/utils"; + +interface LayoutProps { + children: React.ReactNode; + detail: React.ReactNode; + params: Promise<{ id: string }>; +} + +export default async function TasklistLayout({ children, detail, params }: LayoutProps) { + const { id } = await params; + const groupId = Number(id); + + const currentDate = setDateTime(new Date()); + const formattedDate = formatDateToISOString(currentDate); + + return ( + + {children} + + ); +} diff --git a/src/app/(routes)/team/[id]/tasklist/[taskListId]/page.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/page.tsx new file mode 100644 index 00000000..479b3191 --- /dev/null +++ b/src/app/(routes)/team/[id]/tasklist/[taskListId]/page.tsx @@ -0,0 +1,33 @@ +import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"; +import TaskListClient from "./tasklist-client"; +import { isEmpty } from "@/lib/utils"; +import { notFound } from "next/navigation"; +import { getGroupTaskListsforServer } from "@/api/tasklist/index-server"; + +export default async function TasklistPage({ + params, +}: { + params: Promise<{ id: string; taskListId: string }>; +}) { + const { id, taskListId } = await params; + + if (isEmpty(id) || isEmpty(taskListId)) { + notFound(); + } + + const groupId = Number(id); + const queryClient = new QueryClient(); + + const groupResult = await getGroupTaskListsforServer(groupId); + + const existTaskList = groupResult.taskLists.some(taskList => taskList.id === Number(taskListId)); + if (!existTaskList) notFound(); + + return ( + <> + + + + + ); +} diff --git a/src/app/(routes)/team/[id]/tasklist/[taskListId]/tasklist-client.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/tasklist-client.tsx new file mode 100644 index 00000000..bc0e3cf6 --- /dev/null +++ b/src/app/(routes)/team/[id]/tasklist/[taskListId]/tasklist-client.tsx @@ -0,0 +1,356 @@ +"use client"; + +import { Container } from "@/components/layout"; +import LeftArrowIcon from "@/assets/icons/ic-arrow-left-circle.svg"; +import RightArrowIcon from "@/assets/icons/ic-arrow-right-circle.svg"; +import CalendarIcon from "@/assets/icons/ic-calendar-circle.svg"; +import PlusIcon from "@/assets/icons/ic-plus.svg"; +import { + useGroupTaskLists, + useRecurringMutation, + useTaskListMutation, +} from "@/hooks/taskList/use-tasklist"; +import { + formatDateForToMonthAndDays, + formatDateToISOString, + isEmpty, + setDateTime, +} from "@/lib/utils"; +import { Button, Floating, SingleDatepicker } from "@/components/ui"; +import { useToggle } from "@/hooks"; +import TaskAddTemplate from "@/components/features/tasklist/task-add-modal"; +import TaskRecurringAddModal from "@/components/features/tasklist/task-recurring/task-recurring-add-modal"; +import { useEffect, useRef, useState } from "react"; +import { useAlert } from "@/providers/alert-provider"; +import { GroupTaskList } from "@/types/tasklist"; +import { useRouter, useSearchParams } from "next/navigation"; +import { taskDetailSchema, taskSchema } from "@/lib/schema"; +import z4 from "zod/v4"; +import { dateTitleStyle, hiddenBrStyle, newListbuttonStyle } from "../index.styles"; +import { useTaskListContext } from "./tasklist-provider"; +import TaskCardField from "@/components/features/tasklist/task-card-field"; +import { debounce } from "lodash"; +import { useQueryClient } from "@tanstack/react-query"; + +interface TaskListPageProps { + groupData: GroupTaskList; + taskListId: string; +} + +type ModalType = "task" | "recurring"; +type ArrowType = "prev" | "next"; + +export default function TasklistClient({ + groupData: initialGroupData, + taskListId, +}: TaskListPageProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const queryClient = useQueryClient(); + + // 서버단의 fetch data 캐시 + useEffect(() => { + queryClient.setQueryData(["groupTaskLists", initialGroupData.id], initialGroupData); + }, [initialGroupData, queryClient]); + + const { data: groupData = initialGroupData } = useGroupTaskLists(initialGroupData.id); + + const { showAlert } = useAlert(); + + const { + isTeam, + isLoading, + permissionCheck, + dateString, + currentISOStrDate, + setCurrentISOStrDate, + } = useTaskListContext(); + + const { create: createRecurring } = useRecurringMutation(); + const { create: createTaskList } = useTaskListMutation(); + + const { isOpen: isOpenTask, setOpen: setOpenTask, setClose: setCloseTask } = useToggle(); + const { + isOpen: isOpenRecurring, + setOpen: setOpenRecurring, + setClose: setCloseRecurring, + } = useToggle(); + const { + isOpen: isOpenCalendar, + setOpen: setOpenCalendar, + setClose: setCloseCalendar, + } = useToggle(); + + const queryDate = searchParams.get("date"); + + const [activeTab, setActiveTab] = useState(Number(taskListId)); + const [titleCurrentDate, setTitleCurrentDate] = useState(""); + const [startDate, setStartDate] = useState(null); + + const calendarButtonRef = useRef(null); + const [calendarPosition, setCalendarPosition] = useState({ top: 0, left: 0 }); + + useEffect(() => { + const urlTaskListId = Number(taskListId); + if (urlTaskListId !== activeTab) { + setActiveTab(urlTaskListId); + } + }, [taskListId]); + + useEffect(() => { + if (queryDate) { + setCurrentISOStrDate(queryDate); + setTitleCurrentDate(formatDateForToMonthAndDays(queryDate)); + } else { + const today = new Date(); + today.setHours(10, 0, 0, 0); + const initialDate = formatDateToISOString(today); + + const params = new URLSearchParams(searchParams.toString()); + params.set("date", initialDate); + router.replace(`${window.location.pathname}?${params.toString()}`, { scroll: false }); + setCurrentISOStrDate(initialDate); + setTitleCurrentDate(formatDateForToMonthAndDays(initialDate)); + } + }, [queryDate, router, searchParams, setCurrentISOStrDate]); + + const handleButtonClick = (type: ModalType) => { + if (type === "task") { + setOpenTask(); + } else if (type === "recurring") { + if (!activeTab) { + showAlert("할 일 그룹을 먼저 추가하여야 합니다."); + return; + } + setOpenRecurring(); + } + }; + const handleTaskSubmit = async (value: z4.infer) => { + const result = await permissionCheck(); + if (result) { + const resultValue = value.name; + if (!isEmpty(resultValue)) + createTaskList.mutate( + { + groupId: groupData.id, + name: resultValue, + }, + { + onSuccess: () => { + setCloseTask(); + }, + }, + ); + } + }; + + const handleTaskRecurringSubmit = async (value: z4.infer) => { + const result = await permissionCheck(); + + if (!activeTab) { + showAlert("할 일 목록을 먼저 선택하여야 합니다."); + return; + } + + if (result) { + createRecurring.mutate( + { + groupId: groupData.id, + taskListId: activeTab, + recurringData: value, + dateString: dateString, + }, + { + onSuccess: () => { + setCloseRecurring(); + }, + }, + ); + } + }; + + const handleArrowClick = (type: ArrowType) => { + const currentDate = new Date(currentISOStrDate); + let newDate: Date; + newDate = currentDate; + + if (type === "prev") { + newDate.setDate(newDate.getDate() - 1); + } else if (type === "next") { + newDate.setDate(newDate.getDate() + 1); + } + + setCurrentISOStrDate(formatDateToISOString(newDate)); + + const newDateStr = formatDateToISOString(newDate); + setCurrentISOStrDate(newDateStr); + + const params = new URLSearchParams(searchParams.toString()); + params.set("date", newDateStr); + router.replace(`${window.location.pathname}?${params.toString()}`, { scroll: false }); + + setTitleCurrentDate(formatDateForToMonthAndDays(newDateStr)); + }; + + const handleDateChange = (date: Date | null) => { + if (date == null) return; + + setStartDate(setDateTime(date)); + + const params = new URLSearchParams(searchParams.toString()); + params.set("date", formatDateToISOString(date)); + router.replace(`${window.location.pathname}?${params.toString()}`, { scroll: false }); + setCloseCalendar(); + }; + + const handleTodayPickerClick = () => { + handleDateChange(new Date()); + }; + + useEffect(() => { + if (!isOpenCalendar) return; + + const updateCalendarPos = () => { + if (!calendarButtonRef.current) return; + + const rect = calendarButtonRef.current.getBoundingClientRect(); + setCalendarPosition({ + top: rect.bottom + window.scrollY + 8, + left: rect.left + window.scrollX - 150, + }); + }; + + const handleResize = debounce(() => { + requestAnimationFrame(updateCalendarPos); + }, 250); // ms + + updateCalendarPos(); + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + handleResize.cancel(); + }; + }, [isOpenCalendar]); + + useEffect(() => { + if (!isLoading && !isTeam) { + showAlert("해당 팀에 권한이 없습니다."); + router.push("/"); + } + }, [isLoading, isTeam, router, showAlert]); + + return ( + <> + +
+

할 일

+
+
+
+
+ {titleCurrentDate} +
+ + +
+ +
+ +
+ {activeTab ? ( + + ) : ( +
+ + 아직 할 일 목록이 없습니다. +
+ 새로운 목록을 추가해주세요. +
+
+ )} +
+
+ + + +
+
+ + {isOpenTask && ( + + )} + + {isOpenRecurring && ( + + )} + + {isOpenCalendar && ( + <> +
+
+
+ handleDateChange(date)} + startDate={startDate} + /> + +
+
+ + )} + + ); +} diff --git a/src/app/(routes)/team/[id]/tasklist/[taskListId]/tasklist-provider.tsx b/src/app/(routes)/team/[id]/tasklist/[taskListId]/tasklist-provider.tsx new file mode 100644 index 00000000..a173b0e7 --- /dev/null +++ b/src/app/(routes)/team/[id]/tasklist/[taskListId]/tasklist-provider.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { userMemberPermission } from "@/hooks/taskList/use-tasklist"; +import { useAlert } from "@/providers/alert-provider"; +import { useAuthStore } from "@/store/auth.store"; +import { Member } from "@/types/tasklist"; +import React, { createContext, useContext, useEffect, useMemo, useState } from "react"; + +type TaskListProviderType = { + isTeam: boolean; + isLoading: boolean; + memberInfo: Member | null; + refresh: () => Promise; + permissionCheck: () => Promise; + currentISOStrDate: string; + setCurrentISOStrDate: (date: string) => void; + dateString: string; +}; + +const TaskListProviderContext = createContext(undefined); + +export default function TaskListProvider({ + groupId, + date, + children, +}: { + groupId: number; + date: string; + children: React.ReactNode; +}) { + const { user, initialized } = useAuthStore(); + const [memberInfo, setMemberInfo] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [currentISOStrDate, setCurrentISOStrDate] = useState(date); + + const { showAlert } = useAlert(); + + const dateString = useMemo(() => { + return currentISOStrDate.split("T")[0]; + }, [currentISOStrDate]); + + const permissionCheck = async () => { + try { + await refresh(); + + if (isTeam) { + return true; + } else { + await showAlert("팀 권한이 없습니다."); + return false; + } + } catch (error) { + console.error("권한 체크 중 에러 발생:", error); + await showAlert("팀 권한 확인 중 오류가 발생했습니다."); + return false; + } + }; + + const { + data, + refetch, + isLoading: queryLoading, + } = userMemberPermission({ + groupId, + userId: user?.id, + }); + + useEffect(() => { + if (!initialized) return; + + setIsLoading(queryLoading); + setMemberInfo(data ?? null); + }, [initialized, queryLoading, data]); + + const refresh = async () => { + setIsLoading(true); + const result = await refetch(); + setMemberInfo(result.data ?? null); + setIsLoading(false); + }; + + if (!initialized || !user) return null; + + const isTeam = !!memberInfo; + + return ( + + {children} + + ); +} + +export function useTaskListContext() { + const context = useContext(TaskListProviderContext); + if (!context) { + throw new Error("team permission context error"); + } + return context; +} diff --git a/src/app/(routes)/team/[id]/tasklist/index.styles.ts b/src/app/(routes)/team/[id]/tasklist/index.styles.ts index f4c72039..42758c43 100644 --- a/src/app/(routes)/team/[id]/tasklist/index.styles.ts +++ b/src/app/(routes)/team/[id]/tasklist/index.styles.ts @@ -24,6 +24,6 @@ export const hiddenBrStyle = ["block", "mobile:hidden"].join(" "); export const addTaskListStyle = ["px-[20px]", "mobile:px-[36px]"].join(" "); export const layoutStyle = [ - "fixed right-0 top-0 z-50 h-full w-full overflow-y-auto bg-gray-800 w-[100%]", + "fixed right-0 top-0 z-20 h-full w-full overflow-y-auto bg-gray-800 scroll-bar", "mobile:w-[60%] max-w-[779px]", ].join(" "); diff --git a/src/app/(routes)/team/[id]/tasklist/layout.tsx b/src/app/(routes)/team/[id]/tasklist/layout.tsx deleted file mode 100644 index d24bb6de..00000000 --- a/src/app/(routes)/team/[id]/tasklist/layout.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { layoutStyle } from "./index.styles"; -import { usePathname } from "next/navigation"; - -interface CommonProps { - children: React.ReactNode; - detail: React.ReactNode; -} - -export default function LayoutContent({ children, detail }: CommonProps) { - const pathname = usePathname(); - - const [isOpenDetailModal, setIsOpenDetailModal] = useState(false); - - useEffect(() => { - const isClose = sessionStorage.getItem("closeDetailModal"); - const isModal = sessionStorage.getItem("openDetailModal"); - - if (isModal) { - setIsOpenDetailModal(true); - sessionStorage.removeItem("openDetailModal"); - } else if (isClose) { - setIsOpenDetailModal(false); - sessionStorage.removeItem("closeDetailModal"); - } - }, [pathname]); - - console.log("isOpenDetailModal: ", isOpenDetailModal); - - return ( - <> -
-
{children}
- - {isOpenDetailModal &&
{detail}
} -
- - ); -} diff --git a/src/app/(routes)/team/[id]/tasklist/page.tsx b/src/app/(routes)/team/[id]/tasklist/page.tsx index fb6d4034..387193e3 100644 --- a/src/app/(routes)/team/[id]/tasklist/page.tsx +++ b/src/app/(routes)/team/[id]/tasklist/page.tsx @@ -1,54 +1,5 @@ -import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"; -import TaskListClient from "./tasklist-client"; -import { isEmpty } from "@/lib/utils"; -import { GroupTaskList } from "@/types/tasklist"; -import { redirect } from "next/navigation"; -import { getGroupTaskListsforServer, getTaskListForServer } from "@/api/tasklist/index-server"; +import { notFound } from "next/navigation"; -export default async function TasklistPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - if (isEmpty(id)) { - redirect("/"); - } - - const groupId = Number(id); - const queryClient = new QueryClient(); - - const groupResult: GroupTaskList = await queryClient.fetchQuery({ - queryKey: ["taskList", groupId], - queryFn: () => getGroupTaskListsforServer(groupId), - }); - - const currentDate = new Date(); - const dateString = currentDate.toISOString().split("T")[0]; - - let firstTaskListId: number | null = null; - if (groupResult?.taskLists?.length) { - const first = groupResult.taskLists.find(t => t.displayIndex === 0); - if (first) { - firstTaskListId = first.id; - } - } - - if (firstTaskListId !== null) { - if (!isEmpty(firstTaskListId)) { - await queryClient.prefetchQuery({ - queryKey: ["taskList", groupId, firstTaskListId, dateString], - queryFn: () => - getTaskListForServer({ - groupId: groupId, - taskListId: firstTaskListId, - date: currentDate.toString(), - }), - }); - } - } - - return ( - <> - - - - - ); +export default function TaskWrapperPage() { + notFound(); } diff --git a/src/app/(routes)/team/[id]/tasklist/tasklist-client.tsx b/src/app/(routes)/team/[id]/tasklist/tasklist-client.tsx deleted file mode 100644 index 4dd3c065..00000000 --- a/src/app/(routes)/team/[id]/tasklist/tasklist-client.tsx +++ /dev/null @@ -1,215 +0,0 @@ -"use client"; - -import { Container } from "@/components/layout"; -import LeftArrowIcon from "@/assets/icons/ic-arrow-left-circle.svg"; -import RightArrowIcon from "@/assets/icons/ic-arrow-right-circle.svg"; -import CalendarIcon from "@/assets/icons/ic-calendar-circle.svg"; -import PlusIcon from "@/assets/icons/ic-plus.svg"; -import Task from "@/components/features/tasklist/task"; -import { useTaskList } from "@/hooks/taskList/use-tasklist"; -import { formatDateForToMonthAndDays, isEmpty } from "@/lib/utils"; -import { Button, Floating } from "@/components/ui"; -import { useToggle } from "@/hooks"; -import TaskAddTemplate from "@/components/features/tasklist/task-add-modal"; -import TaskRecurringAddModal from "@/components/features/tasklist/task-recurring-add-modal"; -import { useEffect, useState } from "react"; -import { useAlert } from "@/providers/alert-provider"; -import { GroupTaskList } from "@/types/tasklist"; -import cn from "@/lib/cn"; -import { usePathname, useRouter } from "next/navigation"; -import { taskSchema } from "@/lib/schema"; -import z4 from "zod/v4"; -import { dateTitleStyle, hiddenBrStyle, newListbuttonStyle, tabButtonStyle } from "./index.styles"; - -interface TaskListPageProps { - groupData: GroupTaskList; - taskListId?: number | null; - date: Date; -} - -type ModalType = "task" | "recurring"; - -export default function TasklistClient({ groupData, taskListId, date }: TaskListPageProps) { - const { isOpen: isOpenTask, setOpen: setOpenTask, setClose: setCloseTask } = useToggle(); - const { - isOpen: isOpenRecurring, - setOpen: setOpenRecurring, - setClose: setCloseRecurring, - } = useToggle(); - const { showAlert } = useAlert(); - - const currentDateStr = formatDateForToMonthAndDays(date); - - const handleButtonClick = (type: ModalType) => { - if (type === "task") { - setOpenTask(); - } else if (type === "recurring") { - if (!taskListId) { - showAlert("할 일 그룹을 먼저 추가하여야 합니다."); - return; - } - setOpenRecurring(); - } - }; - - const handleTaskSubmit = (value: string) => { - console.log("tasklist 그룹 추가", value); - }; - - const handleTaskRecurringSubmit = (value: z4.infer) => { - console.log("task 추가", value); - }; - - return ( - <> - -
-

할 일

-
-
-
-
- {currentDateStr} -
-
- -
-
- -
-
-
- -
-
- -
- {!taskListId ? ( -
- - 아직 할 일 목록이 없습니다. -
- 새로운 목록을 추가해주세요. -
-
- ) : ( - - )} -
-
- - - -
-
- - {isOpenTask && ( - - )} - {isOpenRecurring && ( - - )} - - ); -} - -function TaskCardField({ groupData, taskListId, date }: TaskListPageProps) { - const router = useRouter(); - const pathname = usePathname(); - - const groupId = groupData.id; - const [activeTab, setActiveTab] = useState(taskListId || 0); - const tabs = groupData.taskLists - .sort((a, b) => a.displayIndex - b.displayIndex) - .map(taskList => ({ id: taskList.id, label: taskList.name })); - - const { data: taskListData } = useTaskList({ - groupId: groupId, - taskListId: activeTab, - date: date.toISOString(), - }); - - const storageDatas = { - taskGroupId: groupId.toString(), - taskListId: activeTab, - taskDate: date, - }; - - const handleTaskClick = (id: number) => { - sessionStorage.setItem("taskStorageProps", JSON.stringify(storageDatas)); - sessionStorage.setItem("openDetailModal", "true"); - router.push(`/team/${groupId}/tasklist/${id}`); - }; - - // 탭이동시 상세보기 화면 닫기 - useEffect(() => { - const pathParts = pathname.split("/").filter(Boolean); - if (pathParts.length >= 4 && pathParts[2] === "tasklist") { - router.back(); - } - }, [activeTab, router]); - - if (!taskListId || isEmpty(taskListData)) return null; - - return ( - <> -
-
- {!isEmpty(tabs) && - tabs.map(tab => { - return ( - - ); - })} -
-
- {taskListData ? ( -
- {taskListData.tasks.map(task => ( -
handleTaskClick(task.id)} - > - -
- ))} -
- ) : ( -
- - 아직 할 일 목록이 없습니다. -
- 새로운 목록을 추가해주세요. -
-
- )} - - ); -} diff --git a/src/app/(routes)/team/create/page.tsx b/src/app/(routes)/team/create/page.tsx index 2e20e963..9aeb05c5 100644 --- a/src/app/(routes)/team/create/page.tsx +++ b/src/app/(routes)/team/create/page.tsx @@ -9,9 +9,12 @@ import postImagesUpload from "@/api/image/post-images-upload"; import postGroups from "@/api/team/post-gruops"; import IcProfile from "@/assets/icons/ic-image-circle.svg"; import IcEdit from "@/assets/icons/ic-pencil-border.svg"; +import { devConsoleError } from "@/lib/error"; +import { useToast } from "@/providers/toast-provider"; export default function TeamCreatePage() { const router = useRouter(); + const { showToast } = useToast(); const [formData, setFormData] = useState({ name: "", @@ -46,15 +49,22 @@ export default function TeamCreatePage() { image: imageUrl, }); }, - onError: error => console.log(error.message), + onError: error => { + devConsoleError(error); + showToast("팀 생성에 문제가 생겼습니다.", "error"); + }, }); const createGroupMutate = useMutation({ mutationFn: postGroups, onSuccess: res => { + sessionStorage.setItem("teatCreateMessage", "팀이 생성되었습니다."); router.replace(`/team/${res.id}`); }, - onError: error => console.log(error.message), + onError: error => { + devConsoleError(error); + showToast("팀 생성에 문제가 생겼습니다.", "error"); + }, }); const handleSubmit = (event: React.FormEvent) => { diff --git a/src/app/(routes)/team/join/page.tsx b/src/app/(routes)/team/join/page.tsx index 202ed754..f521cbe5 100644 --- a/src/app/(routes)/team/join/page.tsx +++ b/src/app/(routes)/team/join/page.tsx @@ -1,3 +1,78 @@ -export default function TeamJoin() { - return <>; +"use client"; +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useMutation } from "@tanstack/react-query"; +import { Button, Input } from "@/components/ui"; +import { GroupJoinRequest } from "@/types/group"; +import { useAuthStore } from "@/store/auth.store"; +import postTeamJoin from "@/api/team/post-join-team"; +import { useToast } from "@/providers/toast-provider"; + +export default function TeamJoinPage() { + const router = useRouter(); + const user = useAuthStore(state => state.user); + const { showToast } = useToast(); + + const [formData, setFormData] = useState({ + userEmail: "", + token: "", + }); + + useEffect(() => { + const sessionToken = sessionStorage.getItem("joinToken") || null; + if (sessionToken) { + setFormData(fd => ({ ...fd, token: sessionToken })); + } + }); + + useEffect(() => { + if (user) { + setFormData(fd => ({ ...fd, userEmail: user.email })); + } + }, [user]); + + const joinMutation = useMutation({ + mutationFn: postTeamJoin, + onSuccess: res => { + sessionStorage.setItem("teamJoinMessage", "팀에 합류했습니다."); + router.push(`/team/${res.groupId}`); + }, + onError: error => { + console.log(error); + showToast("오류가 발생했습니다", "error"); + }, + }); + + const handleNameToken = (event: React.ChangeEvent) => { + const inputToken = event.target.value; + setFormData(prev => ({ ...prev, token: inputToken })); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + joinMutation.mutate(formData); + }; + return ( +
+

팀 참여하기

+
+ + + + + + +

+ 공유받은 팀 토큰을 통해 참여할 수 있어요. +

+
+ ); } diff --git a/src/app/(routes)/team/page.tsx b/src/app/(routes)/team/page.tsx index 07a0596f..0d844a6e 100644 --- a/src/app/(routes)/team/page.tsx +++ b/src/app/(routes)/team/page.tsx @@ -1,3 +1,46 @@ +"use client"; +import Image from "next/image"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Container } from "@/components/layout"; +import { Button } from "@/components/ui"; +import { useQuery } from "@tanstack/react-query"; +import getUserGroups from "@/api/team/get-user-groups"; + export default function TeamPage() { - return

팀페이지

; + const router = useRouter(); + + const groups = useQuery({ + queryKey: ["userGroups"], + queryFn: getUserGroups, + }); + + useEffect(() => { + if (groups.data && groups.data.length > 0) { + router.replace(`/team/${groups.data[0].id}`); + } + }, [groups.data, router]); + + if (groups.isPending || (groups.data && groups.data.length > 0)) { + return null; + } + + return ( + +
+ +
+

+ 아직 소속된 팀이 없습니다.
팀을 생성하거나 팀에 참여해보세요. +

+
+ + +
+
+ ); } diff --git a/src/app/favicon.ico b/src/app/favicon.ico index 718d6fea..7e06628f 100644 Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index 6cf02ae7..00000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Home() { - return
Home Page
; -} diff --git a/src/assets/icons/ic-done-type2.svg b/src/assets/icons/ic-done-type2.svg new file mode 100644 index 00000000..8369b17a --- /dev/null +++ b/src/assets/icons/ic-done-type2.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/ic-done.svg b/src/assets/icons/ic-done.svg index 60c96ac3..99325979 100644 --- a/src/assets/icons/ic-done.svg +++ b/src/assets/icons/ic-done.svg @@ -1,6 +1,6 @@ - + diff --git a/src/assets/icons/ic-todo.svg b/src/assets/icons/ic-todo.svg new file mode 100644 index 00000000..141a916b --- /dev/null +++ b/src/assets/icons/ic-todo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/landing/ic-plango-character.svg b/src/assets/landing/ic-plango-character.svg new file mode 100644 index 00000000..45ff7f4f --- /dev/null +++ b/src/assets/landing/ic-plango-character.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/landing/ic-quotes.svg b/src/assets/landing/ic-quotes.svg new file mode 100644 index 00000000..f9c9283a --- /dev/null +++ b/src/assets/landing/ic-quotes.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/landing/logo-flat.svg b/src/assets/landing/logo-flat.svg new file mode 100644 index 00000000..0e653edd --- /dev/null +++ b/src/assets/landing/logo-flat.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/landing/visual-banner.svg b/src/assets/landing/visual-banner.svg new file mode 100644 index 00000000..8e2ea504 --- /dev/null +++ b/src/assets/landing/visual-banner.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/landing/visual-todo-list.svg b/src/assets/landing/visual-todo-list.svg new file mode 100644 index 00000000..c61d7a04 --- /dev/null +++ b/src/assets/landing/visual-todo-list.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/features/.gitkeep b/src/components/features/.gitkeep deleted file mode 100644 index c3a2c1f3..00000000 --- a/src/components/features/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -// features 컴포넌트 디렉토리 diff --git a/src/components/features/article/actions/copy-token.tsx b/src/components/features/article/actions/copy-token.tsx index 2cc9ec5a..444a20c5 100644 --- a/src/components/features/article/actions/copy-token.tsx +++ b/src/components/features/article/actions/copy-token.tsx @@ -1,11 +1,11 @@ "use client"; -import cn from "@/lib/cn"; import { useState, useCallback } from "react"; import { useRouter } from "next/navigation"; import { useAuthStore } from "@/store/auth.store"; +import { useToast } from "@/providers/toast-provider"; import { ArticleConfirmModal } from "@/components/features/article/layout"; -import { Input } from "@/components/ui"; +import { Button } from "@/components/ui"; import { isTokenExpire } from "@/lib/utils"; import { ARTICLE_FORM_STYLES } from "@/components/features/article/index.styles"; @@ -15,12 +15,9 @@ export default function CopyToken({ token }: { token: string }) { const user = useAuthStore(state => state.user); const [showLoginModal, setShowLoginModal] = useState(false); const [showJoinModal, setShowJoinModal] = useState(false); + const { showToast } = useToast(); const handleInputClick = useCallback(() => { - if (isExpired) { - // TODO: 만료 토스트 추가 예정 - return; - } if (!user) { setShowLoginModal(true); return; @@ -28,46 +25,41 @@ export default function CopyToken({ token }: { token: string }) { setShowJoinModal(true); }, [isExpired, user]); - // TODO: 복사 토스트 추가 예정 const handleCopy = useCallback(() => { if (!isExpired) { navigator.clipboard.writeText(token ?? ""); + showToast("토큰이 복사되었습니다.", "success"); } - }, [token, isExpired]); + }, [token, isExpired, showToast]); const handleJoin = useCallback(() => { - router.replace(`/team/join?token=${encodeURIComponent(token)}`); + sessionStorage.setItem("joinToken", token); + router.replace("/team/join"); }, [token, router]); const handleLogin = useCallback(() => { - router.replace(`/team/join?token=${encodeURIComponent(token)}`); - }, [router, token]); + sessionStorage.setItem("joinToken", token); + router.replace(`/login?redirect=${encodeURIComponent("/team/join")}`); + }, [token, router]); return ( <> - +

+ 팀 참여하기 + 토큰을 복사해 팀에 참여해보세요. +

+ + {showLoginModal && ( state.user); const { showAlert } = useAlert(); + const { showToast } = useToast(); const { mutate: deleteArticleMutate } = useMutation({ mutationFn: () => deleteArticle({ articleId: article.id }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["getArticles"] }); + sessionStorage.setItem("articleDeleteToast", "게시글이 삭제되었습니다."); router.replace("/article"); }, + onError: () => { + showToast("게시글 삭제에 실패했습니다.", "error"); + }, }); const handleEdit = () => { diff --git a/src/components/features/article/article-detail/article-comment-list.tsx b/src/components/features/article/article-detail/article-comment-list.tsx index 4d7ba944..71bf127d 100644 --- a/src/components/features/article/article-detail/article-comment-list.tsx +++ b/src/components/features/article/article-detail/article-comment-list.tsx @@ -51,6 +51,7 @@ export default function ArticleCommentList({ {comments.map((comment: ArticleComment) => { const replyComment = { ...comment, + content: comment.content.replace(/\n{2,}/g, "\n"), user: { ...comment.writer, image: comment.writer.image ?? null, diff --git a/src/components/features/article/article-detail/article-comment-section.tsx b/src/components/features/article/article-detail/article-comment-section.tsx index ece31490..469914e0 100644 --- a/src/components/features/article/article-detail/article-comment-section.tsx +++ b/src/components/features/article/article-detail/article-comment-section.tsx @@ -12,11 +12,14 @@ import { ArticleComments } from "@/types/article-comment"; import { useInfiniteObserver } from "@/hooks"; import useArticleDetail from "@/hooks/article/use-article-detail"; import { useAlert } from "@/providers/alert-provider"; +import { useToast } from "@/providers/toast-provider"; import { ReplyInput } from "@/components/ui"; import { ArticleConfirmModal } from "../layout"; import { ARTICLE_COMMENT_STYLES } from "../index.styles"; import { NEXT_CURSOR } from "./article-comment-list"; +const singleLineBreaks = (str: string) => str.replace(/\n{2,}/g, "\n"); + export default function ArticleCommentSection({ articleId }: { articleId: number }) { const queryClient = useQueryClient(); const router = useRouter(); @@ -26,6 +29,7 @@ export default function ArticleCommentSection({ articleId }: { articleId: number const [showLoginModal, setShowLoginModal] = useState(false); const [comment, setComment] = useState(""); const { showAlert } = useAlert(); + const { showToast } = useToast(); const { data: article } = useArticleDetail(articleId); @@ -78,16 +82,49 @@ export default function ArticleCommentSection({ articleId }: { articleId: number const { mutate: updateComment } = useMutation({ mutationFn: ({ commentId, content }: { commentId: number; content: string }) => patchArticleComment(commentId, { content }), - onMutate: ({ commentId }) => { - const target = comments.find(current => current.id === commentId); - prevContentRef.current = target ? target.content : null; + onMutate: async ({ commentId, content }) => { + await queryClient.cancelQueries({ queryKey: ["getArticleComments", articleId] }); + const previousData = queryClient.getQueryData(["getArticleComments", articleId]); + queryClient.setQueryData( + ["getArticleComments", articleId], + (oldData?: InfiniteData) => { + if (!oldData) return oldData; + return { + ...oldData, + pages: oldData.pages.map(page => ({ + ...page, + list: page.list.map(comment => + comment.id === commentId + ? { ...comment, content: singleLineBreaks(content) } + : comment, + ), + })), + }; + }, + ); + return { previousData }; + }, + onSuccess: () => { + invalidateAllQueries(); + showToast("댓글이 수정되었습니다.", "success"); + }, + onError: (err, variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(["getArticleComments", articleId], context.previousData); + } + showToast("댓글 수정에 실패했습니다.", "error"); }, - onSuccess: invalidateAllQueries, }); const { mutate: removeComment } = useMutation({ mutationFn: ({ commentId }: { commentId: number }) => deleteArticleComment({ commentId }), - onSuccess: invalidateAllQueries, + onSuccess: () => { + invalidateAllQueries(); + showToast("댓글이 삭제되었습니다.", "success"); + }, + onError: () => { + showToast("댓글 삭제에 실패했습니다.", "error"); + }, }); useEffect(() => { @@ -106,13 +143,17 @@ export default function ArticleCommentSection({ articleId }: { articleId: number function handleAddComment(e: React.FormEvent) { e.preventDefault(); - if (!comment.trim()) return; - createComment({ content: comment }); + const normalized = singleLineBreaks(comment).trim(); + if (!normalized) return; + createComment({ content: normalized }); } const handleEditSave = useCallback( (commentId: number, updatedContent: string) => { - updateComment({ commentId, content: updatedContent }); + const normalized = singleLineBreaks(updatedContent).trim(); + if (!normalized) return; + updateComment({ commentId, content: normalized }); + setEditingId(null); }, [updateComment], ); diff --git a/src/components/features/article/article-detail/article-detail-info.tsx b/src/components/features/article/article-detail/article-detail-info.tsx index 9856f2fa..e36924de 100644 --- a/src/components/features/article/article-detail/article-detail-info.tsx +++ b/src/components/features/article/article-detail/article-detail-info.tsx @@ -1,7 +1,7 @@ import Image from "next/image"; import Link from "next/link"; -import { getTimeAgo, formatDateToFullStr } from "@/lib/utils"; -import { ArticleContent, ArticleDetail } from "@/types/article"; +import { getTimeAgo, formatDateToFullStr, parseArticleContent } from "@/lib/utils"; +import { ArticleDetail } from "@/types/article"; import { Button } from "@/components/ui"; import ArticleMetaCounts from "@/components/features/article/article-detail/article-meta-counts"; import ArticleLike from "@/components/features/article/actions/article-like"; @@ -15,18 +15,7 @@ export default function ArticleDetailInfo({ article }: ArticleDetailInfoProps) { if (!article) return null; const DATE_TIME = article.createdAt; - const getParsedContent = (content: string | ArticleContent) => { - if (typeof content === "string") { - try { - return JSON.parse(content); - } catch { - return { content, token: "" }; - } - } - return content; - }; - - const parsedContent = getParsedContent(article.content); + const parsedContent = parseArticleContent(article.content); return (
diff --git a/src/components/features/article/article-detail/article-edit-toast.tsx b/src/components/features/article/article-detail/article-edit-toast.tsx new file mode 100644 index 00000000..4fef896e --- /dev/null +++ b/src/components/features/article/article-detail/article-edit-toast.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect } from "react"; +import { useToast } from "@/providers/toast-provider"; + +export default function ArticleEditToast() { + const { showToast } = useToast(); + useEffect(() => { + setTimeout(() => { + const editToastMsg = sessionStorage.getItem("articleEditToast"); + if (editToastMsg) { + sessionStorage.removeItem("articleEditToast"); + showToast(editToastMsg, "success"); + } + }, 120); + }, [showToast]); + return null; +} diff --git a/src/components/features/article/article-form/article-edit-form.tsx b/src/components/features/article/article-form/article-edit-form.tsx index f4a1eff8..d2549db2 100644 --- a/src/components/features/article/article-form/article-edit-form.tsx +++ b/src/components/features/article/article-form/article-edit-form.tsx @@ -9,6 +9,7 @@ import patchArticle from "@/api/article/patch-article"; import getArticleDetail from "@/api/article/get-article-detail"; import { zodResolver } from "@hookform/resolvers/zod"; import { articleFormSchema, ArticleFormSchema } from "@/lib/schema"; +import { parseArticleContent } from "@/lib/utils"; import { CreateArticleData } from "@/types/article"; import { Container } from "@/components/layout"; import { ArticleFormFields } from "@/components/features/article"; @@ -18,10 +19,12 @@ import { ARTICLE_COMMON_STYLES, ARTICLE_FORM_STYLES, } from "@/components/features/article/index.styles"; +import { useToast } from "@/providers/toast-provider"; export default function ArticleEditForm({ articleId }: ArticleEditFormProps) { const router = useRouter(); const queryClient = useQueryClient(); + const { showToast } = useToast(); const [selectedFile, setSelectedFile] = useState(null); const [isImageDeleted, setIsImageDeleted] = useState(false); const { data: article } = useQuery({ @@ -30,30 +33,24 @@ export default function ArticleEditForm({ articleId }: ArticleEditFormProps) { enabled: !!articleId, }); - // TODO: isSuccess, isError, error 처리는 추후 토스트로 처리 예정 const { mutate, isPending: isMutating } = useMutation({ mutationFn: ({ articleId, patchBody }: { articleId: number; patchBody: CreateArticleData }) => patchArticle(articleId, patchBody), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["getArticleDetail", articleId] }); queryClient.invalidateQueries({ queryKey: ["getArticles"] }); + sessionStorage.setItem("articleEditToast", "게시글이 수정되었습니다."); router.replace(`/article/${articleId}`); }, + onError: () => { + showToast("게시글 수정에 실패했습니다.", "error"); + }, }); const defaultValues: ArticleFormSchema | undefined = article ? { title: article.title, - content: - typeof article.content === "string" - ? (() => { - try { - return JSON.parse(article.content); - } catch { - return { content: article.content, token: "" }; - } - })() - : article.content, + content: parseArticleContent(article.content), image: article.image ?? "", } : undefined; diff --git a/src/components/features/article/article-form/article-form-fields.tsx b/src/components/features/article/article-form/article-form-fields.tsx index 984a2a59..5338c2c6 100644 --- a/src/components/features/article/article-form/article-form-fields.tsx +++ b/src/components/features/article/article-form/article-form-fields.tsx @@ -33,7 +33,7 @@ export default function ArticleFormFields({
+ + +
+
+

+ copyright © 2025 Codeit Plango. All rights reserved. +

+
+ + ); +} diff --git a/src/components/features/landing/index.ts b/src/components/features/landing/index.ts new file mode 100644 index 00000000..64c92d5e --- /dev/null +++ b/src/components/features/landing/index.ts @@ -0,0 +1,9 @@ +export { default as PopularPosts } from "@/components/features/landing/popular-posts"; +export { default as ProductDemo } from "@/components/features/landing/product-demo"; +export { default as DeveloperStory } from "@/components/features/landing/developer-story"; +export { default as Footer } from "@/components/features/landing/footer"; +export { default as IntroBanner } from "@/components/features/landing/intro-section"; +export { default as ProblemSection } from "@/components/features/landing/problem-section"; +export { default as WhyPlangoSection } from "@/components/features/landing/why-plango-section"; +export { default as SolutionSection } from "@/components/features/landing/solution-section"; +export { default as DailySummarySection } from "@/components/features/landing/daily-summary-section"; diff --git a/src/components/features/landing/intro-section.tsx b/src/components/features/landing/intro-section.tsx new file mode 100644 index 00000000..d682a575 --- /dev/null +++ b/src/components/features/landing/intro-section.tsx @@ -0,0 +1,29 @@ +"use client"; +import Visual from "@/assets/landing/visual-banner.svg"; +import Logo from "@/assets/landing/logo-flat.svg"; +import cn from "@/lib/cn"; +import { Button } from "@/components/ui"; +import { introContainer, introWave, introWaveWrapper } from "./landing.style"; + +export default function IntroBanner() { + return ( +
+
+

함께 계획하고 완성하는 ✨

+ +

+ 혼자서는 힘들었던 목표, 이제 친구들과 함께 재밌게!
+ 루틴부터 여행 계획까지, 모두 함께 완성해가는 공간 +

+
+ +
+ +
+
+
+
+ ); +} diff --git a/src/components/features/landing/landing.style.ts b/src/components/features/landing/landing.style.ts new file mode 100644 index 00000000..8ec387e4 --- /dev/null +++ b/src/components/features/landing/landing.style.ts @@ -0,0 +1,96 @@ +import { cva } from "class-variance-authority"; + +export const introContainer = [ + "flex flex-col text-center ", + "px-[1.25rem] tablet:px-6 ", + "gap-4 tablet:gap-10 pt-10 tablet:pt-24", +].join(""); +export const introWaveWrapper = + "overflow-hidden relative pb-8 mobile:pb-[50px] tablet:pb-[80px] desktop:pb-[150px]"; +export const introWave = [ + "w-full h-[60px] absolute left-0 bottom-0 z-[1] ", + "border border-transparent bg-repeat-x bg-[auto_60px] ", + "mobile:h-[90px] mobile:bg-[auto_90px] ", + "tablet:h-[140px] tablet:bg-[auto_140px] ", + "desktop:h-[250px] desktop:bg-[auto_250px] ", +].join(""); +export const sectionWrapper = "py-10 tablet:py-24 px-[1.25rem] tablet:px-6"; +export const sectionTitle = "text-center text-3xl tablet:text-5xl font-bold break-keep"; +export const sectionTitleGradient = cva("bg-gradient-to-r bg-clip-text text-transparent", { + variants: { + color: { + orangeRose: "from-orange-400 to-rose-400", + pinkYellow: "from-pink-400 to-yellow-400", + orangeGreen: "from-[#E98744] to-[#64992E]", + purplePink: "from-purple-400 to-pink-400", + }, + }, +}); +export const sectionInnerContainer = cva("mx-auto max-w-[760px] flex", { + variants: { + layout: { + problem: "flex-col items-center gap-10 pt-10 tablet:pt-24", + whyPlango: "flex-col", + solution: "flex-col gap-10 pt-10 tablet:gap-20 tablet:pt-24", + dailySummary: + "flex-col tablet:flex-row tablet:flex-nowrap items-center gap-10 tablet:gap-18 justify-center max-w-[1028px]", + }, + }, +}); +export const sectionDescription = + "mt-6 text-center text-base tablet:text-xl text-gray-200 break-keep"; + +export const sectionContentBox = cva("flex flex-col rounded-3xl border p-5 tablet:p-6", { + variants: { + color: { + gray: "border-gray-700 bg-[#1e293b94]", + orange: "border-orange-200 bg-orange-800", + green: "border-green-200 bg-green-800", + blue: "border-blue-200 bg-blue-800", + purple: "border-purple-200 bg-purple-800", + pink: "border-pink-200 bg-[#5a14284d]", + }, + layout: { + problem: + "w-full tablet:max-w-[360px] gap-2 tablet:gap-4 text-center odd:mr-auto even:ml-auto", + solution: "flex flex-1 gap-3 tablet:gap-6 ", + whyPlango: "p-3 rounded-2xl tablet:rounded-3xl", + }, + }, +}); + +export const sectionContentTitle = cva("text-lg tablet:text-2xl text-gray-100 break-keep", { + variants: { + theme: { + problem: "font-bold", + solution: "font-semibold flex flex-1 items-center gap-2", + }, + }, +}); + +export const problemContent = cva("text-2xl text-gray-100", { + variants: { + theme: { + title: "text-2xl text-gray-100 font-bold", + solution: "font-semibold", + }, + }, +}); + +export const progressbar = cva("w-full h-3 rounded-xl ", { + variants: { + gradient: { + orange: "bg-[linear-gradient(to_right,var(--orange-200),var(--orange-800)_80%)]", + blue: "bg-[linear-gradient(to_right,var(--blue-200),var(--blue-800)_73%)]", + green: "bg-[linear-gradient(to_right,var(--green-200),var(--green-800)_85%)]", + purple: "bg-[linear-gradient(to_right,var(--purple-200),var(--purple-800)_91%)]", + }, + text: { + orange: "text-orange-200", + blue: "text-blue-200", + green: "text-green-200", + purple: "text-purple-200", + }, + }, +}); +export const summaryText = "text-center text-base hidden tablet:block text-gray-100"; diff --git a/src/components/features/landing/layout.tsx b/src/components/features/landing/layout.tsx new file mode 100644 index 00000000..621cedc2 --- /dev/null +++ b/src/components/features/landing/layout.tsx @@ -0,0 +1,34 @@ +export function SectionHeader({ + title = "", + gradientTitle = "", + description = "", + gradientColor = "", + align = "center", +}: { + title: string; + description?: string; + gradientTitle?: string; + gradientColor?: string; + align?: "left" | "center" | "right"; +}) { + return ( +
+

+ {title} +
+ + {gradientTitle} + +

+ {description &&

{description}

} +
+ ); +} diff --git a/src/components/features/landing/popular-posts.tsx b/src/components/features/landing/popular-posts.tsx new file mode 100644 index 00000000..2be1b2d5 --- /dev/null +++ b/src/components/features/landing/popular-posts.tsx @@ -0,0 +1,126 @@ +"use client"; + +import cn from "@/lib/cn"; +import { useState, useRef, useEffect } from "react"; +import { motion, useScroll, useTransform, useInView } from "motion/react"; +import { Container } from "@/components/layout"; +import { BestArticleSection } from "@/components/features/article"; +import { SectionHeader } from "./layout"; + +const generateFloatingHearts = (count: number) => { + return Array.from({ length: count }, (_, i) => { + return { + id: i, + left: Math.random() * 100, + delay: Math.random() * 2, + duration: 3 + Math.random() * 2, + size: 1.5 + Math.random() * 2, + emoji: ["✈️", "📚", "🏃🏻", "💕", "👍🏻", "🎹"][Math.floor(Math.random() * 6)], + swayAmount: 20 + Math.random() * 40, + swayDuration: 2 + Math.random() * 2, + }; + }); +}; + +export default function PopularPosts() { + const [showFloatingHearts, setShowFloatingHearts] = useState(false); + const [floatingHeartsData] = useState(() => generateFloatingHearts(35)); + const sectionRef = useRef(null); + const isInView = useInView(sectionRef, { amount: 0.25, once: false }); + + useEffect(() => { + if (isInView && !showFloatingHearts) { + setShowFloatingHearts(true); + const timer = setTimeout(() => { + setShowFloatingHearts(false); + }, 7000); + return () => clearTimeout(timer); + } + }, [isInView, showFloatingHearts]); + + const { scrollYProgress } = useScroll({ + target: sectionRef, + offset: ["start start", "end start"], + }); + + const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0]); + const scale = useTransform(scrollYProgress, [0, 0.5], [1, 0.8]); + const y = useTransform(scrollYProgress, [0, 1], [0, 200]); + + return ( +
+ + + + + + + + + + + {showFloatingHearts && ( +
+ {floatingHeartsData.map(heart => ( + + {heart.emoji} + + ))} +
+ )} +
+ ); +} diff --git a/src/components/features/landing/problem-section.tsx b/src/components/features/landing/problem-section.tsx new file mode 100644 index 00000000..60fbcf86 --- /dev/null +++ b/src/components/features/landing/problem-section.tsx @@ -0,0 +1,59 @@ +import cn from "@/lib/cn"; +import { + sectionContentBox, + sectionContentTitle, + sectionInnerContainer, + sectionTitle, + sectionTitleGradient, + sectionWrapper, +} from "./landing.style"; + +const values = [ + { + icon: "📊", + title: "업무 중심 도구", + description: "딱딱한 업무용 툴은 일상 속 작은 목표에 어울리지 않아요.", + solution: "“가벼운 일정은 가볍게 기록하고 싶잖아요?”", + }, + { + icon: "😔", + title: "동기부여 부족", + description: "혼자서는 목표를 유지하기 어려워요", + solution: "“함께하면 더 잘할 수 있을 것 같은데...”", + }, + { + icon: "🤷", + title: "일정 공유 누락", + description: "친구들과 계획 체크리스트를 공유하고 싶어요", + solution: "“카톡에 흩어져버린 체크리스트, 결국 아무도 기억 못 하죠”", + }, +]; + +export default function ProblemSection() { + return ( +
+

+ 🤔 + + 이런 고민 있으신가요? + +

+
    + {values.map(v => { + const { icon, title, description, solution } = v; + return ( +
  • +

    + {icon}
    + {title} +

    +

    {description}

    +

    {solution}

    +
  • + ); + })} +
  • +
+
+ ); +} diff --git a/src/components/features/landing/product-demo.tsx b/src/components/features/landing/product-demo.tsx new file mode 100644 index 00000000..aac4a305 --- /dev/null +++ b/src/components/features/landing/product-demo.tsx @@ -0,0 +1,199 @@ +"use client"; + +import cn from "@/lib/cn"; +import { motion, useScroll, useTransform } from "motion/react"; +import { useRef, useState } from "react"; +import { Container } from "@/components/layout"; +import { SectionHeader } from "./layout"; +import { CircularProgressBar } from "@tomickigrzegorz/react-circular-progress-bar"; +import { reportChartProps } from "@/components/features/team/team.props"; +import { formatDateToFullStr } from "@/lib/utils"; +import IcDone from "@/assets/icons/ic-done-type2.svg"; +import IcTodo from "@/assets/icons/ic-todo.svg"; +import IcCheck from "@/assets/icons/ic-check.svg"; +import IcCalendar from "@/assets/icons/ic-calendar.svg"; +import IcRepeat from "@/assets/icons/ic-repeat.svg"; + +const mockTasks = [ + { + id: 1, + title: "팀 회고 작성하기", + completed: true, + frequencyType: "매주 반복", + }, + { + id: 2, + title: "데일리 스크럼/회의록 작성하기", + completed: true, + frequencyType: "매일 반복", + }, + { + id: 3, + title: "빌표자료 준비하기", + completed: false, + frequencyType: "한 번", + }, +]; + +const DATE = new Date(); + +export default function ProductDemo() { + const sectionRef = useRef(null); + const [tasks, setTasks] = useState(mockTasks); + + const { scrollYProgress } = useScroll({ + target: sectionRef, + offset: ["start end", "end start"], + }); + + const scale = useTransform(scrollYProgress, [0, 0.5, 1], [0.7, 1, 0.7]); + const rotateX = useTransform(scrollYProgress, [0, 0.5, 1], [15, 0, -15]); + + const toggleTask = (id: number) => { + setTasks(tasks.map(task => (task.id === id ? { ...task, completed: !task.completed } : task))); + }; + + return ( +
+ + + + + + + + +
+
5팀 리포트 🦩
+
+
+
+ t.completed).length / tasks.length) * 100) + } + {...reportChartProps} + /> +
+
+ + 오늘의 +
+ 진행 상황 +
+ + {tasks.length === 0 + ? 0 + : Math.round((tasks.filter(t => t.completed).length / tasks.length) * 100)} + % + +
+
+
+
+ + 오늘의 할 일 + + {tasks.length}개 + + + +
+
+ + 한 일 + + {tasks.filter(t => t.completed).length}개 + + + +
+
+
+ +
+ {tasks.map(task => ( + +
toggleTask(task.id)} + > +
+ + {task.completed && ( + + + + )} + + +

+ {task.title} +

+
+
+ + {formatDateToFullStr({ date: DATE, type: "korean" })} + + {task.frequencyType} +
+
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/components/features/landing/solution-section.tsx b/src/components/features/landing/solution-section.tsx new file mode 100644 index 00000000..ae715d43 --- /dev/null +++ b/src/components/features/landing/solution-section.tsx @@ -0,0 +1,165 @@ +import cn from "@/lib/cn"; +import { + progressbar, + sectionContentBox, + sectionContentTitle, + sectionDescription, + sectionInnerContainer, + sectionTitle, + sectionTitleGradient, + sectionWrapper, +} from "./landing.style"; +import IcDone from "@/assets/icons/ic-done.svg"; + +type Color = "orange" | "blue" | "green" | "purple"; +type ContentValueType = { + icon: string; + description: string; + theme: string; + solution: string; + solution2: string; + color: Color; + percent: number; + checkList: string[]; +}[]; +const values: ContentValueType = [ + { + icon: "🎯", + description: "같은 목표를 가진 친구들과 함께!", + theme: "🏃 러닝 크루", + solution: "같이뛰면 포기 안해요", + solution2: "작심삼일을 뛰어넘도록 도와주는 러닝 크루 경험", + color: "orange", + percent: 80, + checkList: ["매일 러닝 인증", "서로 응원 댓글", "월말 기록 공유"], + }, + { + icon: "✈️", + description: "일상, 여행, 취미 등 자유롭게!", + theme: "📋 여행 계획", + solution: "친구들과 완벽한 여행", + solution2: "일정 나누기부터 체크리스트까지 한 곳에서", + color: "blue", + percent: 73, + checkList: ["일정 함께 짜기", "체크리스트 관리", "가고 싶은곳 공유"], + }, + { + icon: "💪", + description: "서로 응원하며 꾸준히!", + theme: "📚 스터디", + solution: "함께 공부하면 집중 돼요", + solution2: "스터디 기록이 쌓일수록 동기부여 UP", + color: "green", + percent: 85, + checkList: ["공부 인증샷", "서로 질문 답변", "주차별 목표 설정"], + }, + { + icon: "🎉", + description: "함께 성장하고 완성해요!", + theme: "🎨 취미 활동", + solution: "취미도 같이하면 재미도 두배", + solution2: "같이 성장하는 소소한 루틴 만들기", + color: "purple", + percent: 91, + checkList: ["취미 작품 공유", "주말 카페 투어", "모임 일정 공유"], + }, +]; +type ProgressbarProps = { + color: Color; + percent: number; +}; +type ChecklistProps = { + color: Color; + checkList: string[]; +}; + +function SectionH3() { + return ( +

+ 🪴
+ Plango에서
+ + 친구들과 이런 걸 함께해요 + +

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

+ 다양한 활동들을 함께해요
+ 운동, 여행, 공부, 취미 뭐든지 함께하면 더 즐거워져요 +

+ ); +} +function Progressbar({ color, percent }: ProgressbarProps) { + return ( +
+
+ {percent}% +
+ ); +} +function CheckList({ color, checkList }: ChecklistProps) { + return ( +
    + {checkList.map((v, index) => { + return ( +
  • + + + + {v} +
  • + ); + })} +
+ ); +} +function SolutionList() { + return ( +
    + {values.map(v => { + const { icon, theme, description, solution2, solution, color, percent, checkList } = v; + return ( +
  • +
    + {icon} + {description} +
    + +
    +
    +

    {theme}

    +

    {solution}

    +

    {solution2}

    +
    +
    + + +
    +
    +
  • + ); + })} +
+ ); +} + +export default function SolutionSection() { + return ( +
+ + + +
+ ); +} diff --git a/src/components/features/landing/why-plango-section.tsx b/src/components/features/landing/why-plango-section.tsx new file mode 100644 index 00000000..67f28ac0 --- /dev/null +++ b/src/components/features/landing/why-plango-section.tsx @@ -0,0 +1,52 @@ +import cn from "@/lib/cn"; +import { + sectionContentBox, + sectionDescription, + sectionInnerContainer, + sectionTitle, + sectionTitleGradient, + sectionWrapper, +} from "./landing.style"; + +const values = [ + "🧑‍🤝‍🧑 같은 목표를 가진 친구들과", + "💫 함께 계획하고 함께 이루어내고", + "🌱 작지만 확실한 성장의 순간들", + "🚀 가볍게 시작하고, 꾸준히 이어가는 힘", +]; +export default function WhyPlangoSection() { + return ( +
+
+

+ Plango가 준비했어요 ✨ +

+ +
+ 다같이 꾸준히 이어가기 위한 공간
+ 혼자서는 어렵지만,
+ 함께라면 달라져요 💪 +
+
    + {values.map((v, index) => ( +
  • + {v} +
  • + ))} +
+
+
+ ); +} diff --git a/src/components/features/my/user-profile.tsx b/src/components/features/my/user-profile.tsx index c91fcb4a..2171c5da 100644 --- a/src/components/features/my/user-profile.tsx +++ b/src/components/features/my/user-profile.tsx @@ -117,6 +117,8 @@ export function ProfileUpdateFormField({ control, formState: { errors }, } = useFormContext(); + + const isKakaoUser = email?.toLowerCase().endsWith("@kakao.com"); return ( <> - - -
- - -
-
+ {!isKakaoUser && ( + +
+ + +
+
+ )} ); } diff --git a/src/components/features/tasklist/daily-frequency-options.tsx b/src/components/features/tasklist/daily-frequency-options.tsx index 63030ad4..50eaa9a0 100644 --- a/src/components/features/tasklist/daily-frequency-options.tsx +++ b/src/components/features/tasklist/daily-frequency-options.tsx @@ -26,6 +26,7 @@ export default function DailyFrequencyOptions({ intent="tertiary" className={cn("w-full px-[13px]", isSelected ? "bg-pink-400 text-white" : "")} onClick={() => onChange(list.value)} + type="button" > {list.label} diff --git a/src/components/features/tasklist/sortable-task.tsx b/src/components/features/tasklist/sortable-task.tsx new file mode 100644 index 00000000..1fdbc293 --- /dev/null +++ b/src/components/features/tasklist/sortable-task.tsx @@ -0,0 +1,38 @@ +import { Task as TaskType } from "@/types/task"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import Task from "@/components/features/tasklist/task"; + +interface SortableTaskProps { + task: TaskType; + onKebabClick: ({ + taskId, + recurringId, + type, + }: { + taskId: number; + recurringId: number; + type: KebabType; + }) => void; + onClick: () => void; +} + +type KebabType = "update" | "delete"; + +export default function SortableTask({ task, onKebabClick, onClick }: SortableTaskProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: task.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? "0.3" : "1", + }; + + return ( +
+ +
+ ); +} diff --git a/src/components/features/tasklist/task-add-modal.tsx b/src/components/features/tasklist/task-add-modal.tsx index fac75d91..aac8eeec 100644 --- a/src/components/features/tasklist/task-add-modal.tsx +++ b/src/components/features/tasklist/task-add-modal.tsx @@ -1,50 +1,94 @@ "use client"; import { addTaskListStyle } from "@/app/(routes)/team/[id]/tasklist/index.styles"; -import { Input, Modal } from "@/components/ui"; -import { ChangeEvent, useState } from "react"; +import { Form, Input, Modal } from "@/components/ui"; +import { taskSchema } from "@/lib/schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { Controller, SubmitHandler, useFormContext } from "react-hook-form"; +import z4 from "zod/v4"; interface TaskAddProps { isOpen: boolean; onClose: () => void; - onSubmit: (value: string) => void; + onSubmit: (value: z4.infer) => Promise; + isPending: boolean; } -export default function TaskAddTemplate({ isOpen, onClose, onSubmit }: TaskAddProps) { - const [taskName, setTaskName] = useState(""); +export default function TaskAddTemplate({ isOpen, onClose, onSubmit, isPending }: TaskAddProps) { + const [isSubmitting, setIsSubmitting] = useState(false); - const handleMakeClick = () => { - onSubmit(taskName); - setTaskName(""); - onClose(); + const handleSubmit: SubmitHandler> = async data => { + if (isSubmitting) return; + setIsSubmitting(true); + + try { + await onSubmit(data); + } finally { + setIsSubmitting(false); + } }; return ( - -
- -
- - 할 일에 대한 목록을 추가하고 -
- 목록별 할 일을 만들 수 있습니다. -
-
- - - ) => setTaskName(e.target.value)} - className="mb-[24px]" - /> - -
- -
+
+ +
+ + +
+
); } + +function FormField() { + const { + control, + formState: { errors }, + } = useFormContext>(); + + return ( + +
+ + 할 일에 대한 목록을 추가하고 +
+ 목록별 할 일을 만들 수 있습니다. +
+
+ +
+ + + ( + <> + + + + )} + /> + +
+
+ ); +} diff --git a/src/components/features/tasklist/task-card-field.tsx b/src/components/features/tasklist/task-card-field.tsx new file mode 100644 index 00000000..35d7a685 --- /dev/null +++ b/src/components/features/tasklist/task-card-field.tsx @@ -0,0 +1,377 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Task, { KebabType } from "@/components/features/tasklist/task"; +import { tabButtonStyle } from "@/app/(routes)/team/[id]/tasklist/index.styles"; +import { GroupTaskList } from "@/types/tasklist"; +import cn from "@/lib/cn"; +import { notFound, useParams, useRouter, useSearchParams } from "next/navigation"; +import TaskDetailUpdateTemplate from "@/components/features/tasklist/task-recurring/task-recurring-update-modal"; +import { + useRecurringMutation, + useTaskList, + useTaskListMutation, +} from "@/hooks/taskList/use-tasklist"; +import { isEmpty } from "@/lib/utils"; +import { useToggle } from "@/hooks"; +import z4 from "zod/v4"; +import { taskDetailUpdateSchema } from "@/lib/schema"; +import { useAlert } from "@/providers/alert-provider"; +import { useTaskListContext } from "@/app/(routes)/team/[id]/tasklist/[taskListId]/tasklist-provider"; +import TaskDeleteSheet from "./task-recurring/task-recurring-delete-sheet"; +import { DeleteType } from "@/types/task"; +import useModalStore from "@/store/modal.store"; +import SortableTask from "./sortable-task"; +import { + closestCenter, + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { arrayMove, SortableContext } from "@dnd-kit/sortable"; +import { TabsSkeleton, TaskListSkeleton } from "@/components/skeleton-ui/tasklist-skeleton"; + +interface TaskListPageProps { + groupData: GroupTaskList; + date: string; +} + +interface TaskFieldProps extends TaskListPageProps { + activeTab: number; + setActiveTab: React.Dispatch>; +} + +export default function TaskCardField({ + groupData, + date, + activeTab, + setActiveTab, +}: TaskFieldProps) { + const router = useRouter(); + const { id: groupId, taskId } = useParams(); + const searchParams = useSearchParams(); + + if (groupId == null) notFound(); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + ); + + const { openModal: openDetailModal, closeModal: closeDetailModal } = useModalStore(); + const { + isOpen: isOpenUpdateTaskDetail, + setOpen: setOpenUpdateTaskDetail, + setClose: setCloseUpdateTaskDetail, + } = useToggle(); + const { + isOpen: isOpenDeleteSheet, + setOpen: setOpenDeleteSheet, + setClose: setCloseDeleteSheet, + } = useToggle(); + + const { showAlert } = useAlert(); + + const { permissionCheck, dateString } = useTaskListContext(); + const { updateOrder } = useTaskListMutation(); + const { update: updateRecurring, remove: deleteRecurring } = useRecurringMutation(); + + const [selectedTaskId, setSelectedTaskId] = useState(null); // 카드 케밥용 + const [selectedRecurringId, setSelectedRecurringId] = useState(null); + + const tabs = groupData?.taskLists + .sort((a, b) => a.displayIndex - b.displayIndex) + .map(taskList => ({ id: taskList.id, label: taskList.name })); + + const { data: taskListData, isLoading: isLoadingTaskList } = useTaskList({ + groupId: Number(groupId), + taskListId: activeTab, + date: date, + dateString: dateString, + }); + + if (taskListData) { + if (taskListData?.groupId !== groupData.id) { + notFound(); + } + } + + const [tasks, setTasks] = useState(taskListData?.tasks || []); + const [dragActiveId, setDragActiveId] = useState(null); + + const handleTaskClick = (id: number) => { + const resultRecurringId = groupData?.taskLists + .find(taskList => taskList.id === activeTab) + ?.tasks.find(task => task.id === id)?.recurringId; + + sessionStorage.setItem("recurringId", resultRecurringId ? resultRecurringId.toString() : ""); + + const params = new URLSearchParams(searchParams.toString()); + const dateParam = searchParams.get("date"); + params.set("date", dateParam ? dateParam : ""); + + openDetailModal(); + router.push(`/team/${groupId}/tasklist/${activeTab}/${id}?${params.toString()}`); + }; + + const handleKebabClick = ({ + taskId, + recurringId, + type, + }: { + taskId: number; + recurringId: number; + type: KebabType; + }) => { + setSelectedTaskId(taskId); + setSelectedRecurringId(recurringId); + if (type === "update") { + setOpenUpdateTaskDetail(); + } else { + setOpenDeleteSheet(); + } + }; + + const handleTaskUpdateSubmit = async ( + value: z4.infer>, + ) => { + if (selectedTaskId === null) return; + + const result = await permissionCheck(); + if (result) { + updateRecurring.mutate( + { + groupId: groupData.id, + taskListId: activeTab, + dateString: dateString, + taskId: selectedTaskId, + name: value.name, + }, + { + onSuccess: () => { + setCloseUpdateTaskDetail(); + }, + }, + ); + } + }; + + const handleClickDelete = async (type: DeleteType) => { + if (selectedTaskId === null) { + showAlert("선택된 할 일이 없습니다."); + return; + } + + const result = await permissionCheck(); + + if (result) { + if (type === "One") { + deleteRecurring.mutate( + { + groupId: groupData.id, + taskListId: activeTab, + dateString: dateString, + taskId: selectedTaskId, + }, + { + onSuccess: () => { + setCloseDeleteSheet(); + + if (taskId && Number(taskId) === selectedTaskId) { + closeDetailModal(); + + const params = new URLSearchParams(searchParams.toString()); + const dateParam = searchParams.get("date"); + params.set("date", dateParam ? dateParam : ""); + router.push(`/team/${groupId}/tasklist/${activeTab}?${params.toString()}`); + } + router.refresh(); + }, + }, + ); + } else if (type === "All") { + if (selectedRecurringId === null) { + showAlert("선택된 할 일이 없습니다."); + return; + } + + deleteRecurring.mutate( + { + groupId: groupData.id, + taskListId: activeTab, + dateString: dateString, + taskId: selectedTaskId, + recurringId: selectedRecurringId, + }, + { + onSuccess: () => { + setCloseDeleteSheet(); + + if (taskId && Number(taskId) === selectedTaskId) { + closeDetailModal(); + + const params = new URLSearchParams(searchParams.toString()); + const dateParam = searchParams.get("date"); + params.set("date", dateParam ? dateParam : ""); + router.push(`/team/${groupId}/tasklist/${activeTab}?${params.toString()}`); + } + router.refresh(); + }, + }, + ); + } + } + }; + + const handleTabClick = (tabId: number) => { + setActiveTab(tabId); + + const params = new URLSearchParams(searchParams.toString()); + const dateParam = searchParams.get("date"); + params.set("date", dateParam ? dateParam : ""); + router.replace(`/team/${groupId}/tasklist/${tabId}?${params.toString()}`, { + scroll: false, + }); + }; + + const handleDragStart = (event: DragStartEvent) => { + const activeId = Number(event.active.id); + setDragActiveId(activeId); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over) return; + + if (active.id !== over.id) { + const oldIndex = tasks.findIndex(t => t.id === active.id); + const newIndex = tasks.findIndex(t => t.id === over.id); + + const newOrder = arrayMove(tasks, oldIndex, newIndex); + setTasks(newOrder); + + const orderPayload = newOrder.map((t, i) => ({ id: t.id, index: i })); + + updateOrder.mutate({ + groupId: groupData.id, + taskListId: activeTab, + dateString: dateString, + taskId: Number(active.id), + newIndex, + orderPayload, + }); + } + }; + + useEffect(() => { + setTasks(taskListData?.tasks || []); + }, [taskListData]); + + // 탭이동시 상세보기 화면 닫기 + useEffect(() => { + closeDetailModal(); + }, [activeTab, router]); + + if (!activeTab) return null; + + return ( + <> +
+
+ {isLoadingTaskList ? ( + + ) : ( + !isEmpty(tabs) && + tabs.map(tab => { + return ( + + ); + }) + )} +
+
+ {isLoadingTaskList ? ( + + ) : taskListData ? ( +
+ + task.id)}> + {tasks.map(task => ( +
+ handleTaskClick(task.id)} + /> +
+ ))} +
+ + {dragActiveId && + (() => { + const activeTask = tasks.find(task => task.id === dragActiveId); + if (!activeTask) return null; + + return ( +
+ {}} /> +
+ ); + })()} +
+
+
+ ) : ( +
+ + 아직 할 일 목록이 없습니다. +
+ 새로운 목록을 추가해주세요. +
+
+ )} + + {isOpenUpdateTaskDetail && ( + task.id === selectedTaskId)?.name ?? ""} + type="nameOnly" + isPending={updateRecurring.isPending} + /> + )} + + {isOpenDeleteSheet && ( + + )} + + ); +} diff --git a/src/components/features/tasklist/task-detail/comment/new-comment.tsx b/src/components/features/tasklist/task-detail/comment/new-comment.tsx new file mode 100644 index 00000000..b92ce83a --- /dev/null +++ b/src/components/features/tasklist/task-detail/comment/new-comment.tsx @@ -0,0 +1,26 @@ +import { ReplyInput } from "@/components/ui"; +import { taskCommentsSchema } from "@/lib/schema"; +import { Controller, useFormContext } from "react-hook-form"; +import z4 from "zod/v4"; + +export default function NewCommentField() { + const { + control, + formState: { errors }, + } = useFormContext>(); + + return ( +
+

+ {errors.content && errors.content.message} +

+
+ } + /> +
+
+ ); +} diff --git a/src/components/features/tasklist/task-detail/comment/task-comments.tsx b/src/components/features/tasklist/task-detail/comment/task-comments.tsx new file mode 100644 index 00000000..6c527434 --- /dev/null +++ b/src/components/features/tasklist/task-detail/comment/task-comments.tsx @@ -0,0 +1,85 @@ +import { Reply } from "@/components/ui"; +import { taskCommentsSchema } from "@/lib/schema"; +import { useAuthStore } from "@/store/auth.store"; +import { Comment } from "@/types/comments"; +import { useState } from "react"; + +export default function TaskCommentsField({ + commentsData, + onSubmit, + onDelete, +}: { + commentsData: Comment[]; + onSubmit: (commentId: number) => (comment: string, onSuccess: () => void) => Promise; + onDelete: (commentId: number) => void; +}) { + const { user } = useAuthStore(); + const userId = user?.id ?? null; + + const [editingCommentId, setEditingCommentId] = useState(null); + const [editError, setEditError] = useState>({}); + + const handleCommentSaveClick = async ( + comment: string, + commentId: number, + originalComment: string, + ) => { + const validation = taskCommentsSchema.safeParse({ content: comment }); + if (originalComment.trim() === comment.trim()) { + setEditingCommentId(null); + return; + } + if (!validation.success) { + setEditError({ [commentId]: validation.error.issues[0].message }); + return; + } + + const submit = onSubmit(commentId); + await submit(comment, () => { + setEditError({}); + setEditingCommentId(null); + }); + }; + + return ( + <> + {commentsData && + commentsData.map(comment => ( +
+ { + setEditingCommentId(null); + setEditError({}); + }} + onSaveEdit={value => handleCommentSaveClick(value, comment.id, comment.content)} + actions={[ + { + label: "수정하기", + onClick: () => { + setEditingCommentId(comment.id); + setEditError({}); + }, + }, + { + label: "삭제하기", + onClick: () => { + onDelete(comment.id); + }, + }, + ]} + variant="secondary" + /> + {editError && editError[comment.id] && ( +

+ {editError[comment.id]} +

+ )} +
+ ))} + + ); +} diff --git a/src/components/features/tasklist/task-detail/task-detail-main.tsx b/src/components/features/tasklist/task-detail/task-detail-main.tsx new file mode 100644 index 00000000..eb55fa0a --- /dev/null +++ b/src/components/features/tasklist/task-detail/task-detail-main.tsx @@ -0,0 +1,210 @@ +import KebabIcon from "@/assets/icons/ic-kebab.svg"; +import { Avatar, Dropdown, Form } from "@/components/ui"; +import { formatDateToFullStr, formatTimeToStr, getFrequencyLabel } from "@/lib/utils"; +import CalendarIcon from "@/assets/icons/ic-calendar.svg"; +import RepeatIcon from "@/assets/icons/ic-repeat.svg"; +import TimeIcon from "@/assets/icons/ic-time.svg"; +import { TaskDetail } from "@/types/task"; +import { KebabType } from "../task"; +import { SubmitHandler, useFormContext } from "react-hook-form"; +import z4 from "zod/v4"; +import { taskCommentsSchema } from "@/lib/schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Comment } from "@/types/comments"; +import { useToast } from "@/providers/toast-provider"; +import { useEffect } from "react"; +import { useParams } from "next/navigation"; +import { useTaskListContext } from "@/app/(routes)/team/[id]/tasklist/[taskListId]/tasklist-provider"; +import { useTaskCommentsMutation } from "@/hooks/taskList/use-tasklist"; +import { useAlert } from "@/providers/alert-provider"; +import NewCommentField from "./comment/new-comment"; +import TaskCommentsField from "./comment/task-comments"; + +interface TaskDetailProps { + taskDetail: TaskDetail; + onKebabClick: (type: KebabType) => void; +} + +interface TaskDetailPageProps extends TaskDetailProps { + commentsData: Comment[]; +} + +export default function TaskDetailMain({ + commentsData, + taskDetail, + onKebabClick, +}: TaskDetailPageProps) { + const { showToast } = useToast(); + const { showAlert } = useAlert(); + const { id: groupId, taskListId } = useParams(); + const { dateString } = useTaskListContext(); + + const { + create: createComment, + update: updateComment, + remove: deleteComment, + } = useTaskCommentsMutation(); + + const handleKebabClick = (type: KebabType) => { + onKebabClick(type); + }; + + const handleNewReplySubmit: SubmitHandler> = comment => { + if (groupId == null || taskListId == null || dateString == null) { + showToast("댓글 등록 중 오류가 발생하였습니다.", "error"); + return; + } + createComment.mutate({ + groupId: Number(groupId), + taskListId: Number(taskListId), + dateString: dateString, + taskId: taskDetail.id, + comment: comment.content, + }); + }; + + const handleModifiedReplySubmit = + (commentId: number) => + async (comment: string, onSuccess: () => void): Promise => { + if (groupId == null || taskListId == null || dateString == null) { + showToast("댓글 수정 중 오류가 발생하였습니다.", "error"); + return; + } + + updateComment.mutate( + { + groupId: Number(groupId), + taskListId: Number(taskListId), + dateString: dateString, + taskId: taskDetail.id, + commentId: commentId, + comment: comment, + }, + { + onSuccess: () => { + onSuccess(); + }, + }, + ); + }; + + const handleCommentDeleteClick = async (commentId: number) => { + const confirmAlert = await showAlert("해당 댓글을 삭제 하시겠습니까?", { + descriptionMessage: "삭제하면 복구 할 수 없습니다.", + type: "confirm", + }); + + if (confirmAlert) { + handleDeleteComment(commentId); + } + }; + + const handleDeleteComment = (commentId: number) => { + deleteComment.mutate({ + groupId: Number(groupId), + taskListId: Number(taskListId), + dateString: dateString, + taskId: taskDetail.id, + commentId: commentId, + }); + }; + + function ResetAfterSubmit() { + const { reset, formState } = useFormContext(); + + useEffect(() => { + if (formState.isSubmitSuccessful) { + reset({ content: "" }); + } + }, [formState.isSubmitSuccessful, reset]); + + return null; + } + + return ( + <> +
+
+

{taskDetail.name}

+ + + + + + handleKebabClick("update")}> + 수정하기 + + handleKebabClick("delete")}> + 삭제하기 + + + +
+
+
+ + {taskDetail.writer.nickname} +
+ + {formatDateToFullStr({ date: taskDetail.date, type: "korean" })} + +
+
+
+
+ +
+ + {formatDateToFullStr({ date: taskDetail.date, type: "korean" })} + +
+ | +
+
+ +
+ + {formatTimeToStr({ date: taskDetail.date, type: "meridiem" })} + +
+ | +
+ {taskDetail?.frequency !== "ONCE" && ( +
+ +
+ )} + + {getFrequencyLabel(taskDetail.frequency)} + +
+
+
+ {taskDetail.description &&

{taskDetail.description}

} +
+
+
+
+ + + + +
+ +
+
+ + ); +} diff --git a/src/components/features/tasklist/task-detail/task-detail-wrapper.tsx b/src/components/features/tasklist/task-detail/task-detail-wrapper.tsx new file mode 100644 index 00000000..a97c76fb --- /dev/null +++ b/src/components/features/tasklist/task-detail/task-detail-wrapper.tsx @@ -0,0 +1,232 @@ +"use client"; +import CancelIcon from "@/assets/icons/ic-cancel.svg"; +import { Container } from "@/components/layout"; +import { useRecurring, useRecurringMutation, useTaskComments } from "@/hooks/taskList/use-tasklist"; +import { notFound, useParams, useRouter, useSearchParams } from "next/navigation"; +import TaskDetailMain from "./task-detail-main"; +import { KebabType } from "../task"; +import { useToggle } from "@/hooks"; +import TaskDetailUpdateTemplate from "../task-recurring/task-recurring-update-modal"; +import z4 from "zod/v4"; +import { taskDetailUpdateSchema } from "@/lib/schema"; +import { useTaskListContext } from "@/app/(routes)/team/[id]/tasklist/[taskListId]/tasklist-provider"; +import { useAlert } from "@/providers/alert-provider"; +import TaskDeleteSheet from "../task-recurring/task-recurring-delete-sheet"; +import { DeleteType } from "@/types/task"; +import { Button, Floating } from "@/components/ui"; +import CheckIcon from "@/assets/icons/ic-check.svg"; +import CheckColorIcon from "@/assets/icons/ic-check-color.svg"; +import useModalStore from "@/store/modal.store"; +import { TaskDetailSkeleton } from "@/components/skeleton-ui/tasklist-skeleton"; + +export default function TaskDetailWrapper({ + taskId, + groupId, +}: { + taskId: number; + groupId: number; +}) { + const router = useRouter(); + const { taskListId: taskListIdParam } = useParams(); + const searchParams = useSearchParams(); + + if (taskListIdParam == null) notFound(); + + const { closeModal: closeDetailModal } = useModalStore(); + const { + isOpen: isOpenUpdateTaskDetail, + setOpen: setOpenUpdateTaskDetail, + setClose: setCloseUpdateTaskDetail, + } = useToggle(); + const { + isOpen: isOpenDeleteSheet, + setOpen: setOpenDeleteSheet, + setClose: setCloseDeleteSheet, + } = useToggle(); + + const { showAlert } = useAlert(); + const { permissionCheck, dateString } = useTaskListContext(); + + const { data: commentsData } = useTaskComments(taskId); + const { data: recurringData, isLoading: isLoadingRecurring } = useRecurring({ + groupId, + taskListId: Number(taskListIdParam), + taskId, + }); + const { + update: updateRecurring, + remove: deleteRecurring, + updateDoneAt: updateRecurringDoneAt, + } = useRecurringMutation(); + + const taskListId = Number(taskListIdParam); + const storedRecurringId = sessionStorage.getItem("recurringId"); + + if (storedRecurringId == null) { + return
로딩중...
; + } + + const recurringId = Number(storedRecurringId); + + const handleKebabClick = (type: KebabType) => { + if (type === "update") { + setOpenUpdateTaskDetail(); + } else { + setOpenDeleteSheet(); + } + }; + + const handleCloseButton = () => { + closeDetailModal(); + const params = new URLSearchParams(searchParams.toString()); + const dateParam = searchParams.get("date"); + params.set("date", dateParam ? dateParam : ""); + router.push(`/team/${groupId}/tasklist/${taskListId}?${params.toString()}`); + }; + + const handleTaskUpdateSubmit = async ( + value: z4.infer>, + ) => { + if (taskId === null) return; + + const result = await permissionCheck(); + if (result) { + updateRecurring.mutate( + { + groupId: groupId, + taskListId: taskListId, + dateString: dateString, + taskId: taskId, + ...value, + }, + { + onSuccess: () => { + setCloseUpdateTaskDetail(); + }, + }, + ); + } + }; + + const handleClickDelete = async (type: DeleteType) => { + if (taskId === null) { + showAlert("선택된 할 일이 없습니다."); + return; + } + + const result = await permissionCheck(); + + if (result) { + if (type === "One") { + deleteRecurring.mutate( + { + groupId: groupId, + taskListId: taskListId, + dateString: dateString, + taskId: taskId, + }, + { + onSuccess: () => { + setCloseDeleteSheet(); + closeDetailModal(); + router.push(`/team/${groupId}/tasklist`); + }, + }, + ); + } else if (type === "All") { + if (taskId === null) { + showAlert("선택된 할 일이 없습니다."); + return; + } + + deleteRecurring.mutate( + { + groupId: groupId, + taskListId: taskListId, + dateString: dateString, + taskId: taskId, + recurringId: recurringId, + }, + { + onSuccess: () => { + setCloseDeleteSheet(); + closeDetailModal(); + router.push(`/team/${groupId}/tasklist`); + }, + }, + ); + } + } + }; + + const handleDoneButtonClick = (doneAt: string | null) => { + const done = doneAt ? false : true; + updateRecurringDoneAt.mutate({ + groupId: groupId, + taskListId: taskListId, + dateString: dateString, + taskId: taskId, + done: done, + }); + }; + + return ( + <> + {recurringData && ( + <> + +
+ +
+
+ {isLoadingRecurring ? ( + + ) : ( + + )} +
+
+ + + + + {isOpenUpdateTaskDetail && ( + + )} + + {isOpenDeleteSheet && ( + + )} + + )} + + ); +} diff --git a/src/components/features/tasklist/task-recurring-add-modal.tsx b/src/components/features/tasklist/task-recurring/task-recurring-add-modal.tsx similarity index 75% rename from src/components/features/tasklist/task-recurring-add-modal.tsx rename to src/components/features/tasklist/task-recurring/task-recurring-add-modal.tsx index 55d9469e..66237dee 100644 --- a/src/components/features/tasklist/task-recurring-add-modal.tsx +++ b/src/components/features/tasklist/task-recurring/task-recurring-add-modal.tsx @@ -4,36 +4,54 @@ import { DropdownOption } from "@/types/option"; import { useEffect, useRef, useState } from "react"; import { Controller, SubmitHandler, useFormContext } from "react-hook-form"; import IcDropdown from "@/assets/icons/ic-dropdown.svg"; -import { FrequencyOptions } from "@/types/date-format-type"; +import { FrequencyOptions, FrequencyType } from "@/types/date-format-type"; import CustomSingleDatepicker from "@/components/ui/date-timepicker/single-datepicker"; -import DailyFrequencyOptions from "./daily-frequency-options"; +import DailyFrequencyOptions from "../daily-frequency-options"; import z4 from "zod/v4"; -import { taskSchema } from "@/lib/schema"; +import { taskDetailSchema } from "@/lib/schema"; import { zodResolver } from "@hookform/resolvers/zod"; interface TaskRecurringProps { isOpen: boolean; onClose: () => void; - onSubmit: (value: z4.infer) => void; + isPending: boolean; + onSubmit: (value: z4.infer) => Promise; } -export default function TaskRecurringAddModal({ isOpen, onClose, onSubmit }: TaskRecurringProps) { +export default function TaskRecurringAddModal({ + isOpen, + onClose, + isPending, + onSubmit, +}: TaskRecurringProps) { const [dayIndexArray, setDayIndexArray] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); - const handleSubmit: SubmitHandler> = submitData => { - const transformedData = { - ...submitData, - description: submitData.description || "", - weekDays: dayIndexArray, - }; - onSubmit(transformedData); + const handleSubmit: SubmitHandler> = async submitData => { + if (isSubmitting) return; + setIsSubmitting(true); + + try { + const { weekDays, ...rest } = submitData; + + const transformedData = { + ...rest, + description: rest.description || "", + ...(rest.frequencyType === FrequencyType.Monthly && { + monthDay: new Date(rest.startDate).getDate(), + }), + ...(rest.frequencyType === FrequencyType.Weekly && { weekDays: weekDays }), + }; + await onSubmit(transformedData); + } finally { + setIsSubmitting(false); + } }; const defaultValues = { name: "", description: "", - frequencyType: "ONCE", - monthDay: 1, + frequencyType: FrequencyType.Once, startDate: "", weekDays: [], }; @@ -42,13 +60,17 @@ export default function TaskRecurringAddModal({ isOpen, onClose, onSubmit }: Tas
- +
); @@ -62,13 +84,12 @@ function FormField({ setDayIndexArray: React.Dispatch>; }) { const { - register, setValue, control, formState: { errors }, clearErrors, watch, - } = useFormContext>(); + } = useFormContext>(); const [isDatepickerOpen, setIsDatepickerOpen] = useState(false); const [isTimepickerOpen, setIsTimepickerOpen] = useState(false); @@ -90,7 +111,7 @@ function FormField({ setValue("frequencyType", option.value, { shouldValidate: true }); - if (option.value === "WEEKLY") { + if (option.value === FrequencyType.Weekly) { setIsWeekpickerOpen(true); } else { setIsWeekpickerOpen(false); @@ -203,13 +224,13 @@ function FormField({
setIsDatepickerOpen(true)} readOnly /> setIsTimepickerOpen(true)} readOnly @@ -249,29 +270,36 @@ function FormField({
- - option.value === frequencyTypeValue)?.label - : "" - } - > - - - - - - {selectOptions.map(option => ( - - {option.label} - - ))} - - + ( + + option.value === frequencyTypeValue)?.label + : "" + } + > + + + + + + {selectOptions.map(option => ( + + {option.label} + + ))} + + + )} + />
{isWeekpickerOpen && ( diff --git a/src/components/features/tasklist/task-recurring/task-recurring-delete-sheet.tsx b/src/components/features/tasklist/task-recurring/task-recurring-delete-sheet.tsx new file mode 100644 index 00000000..f2a4fb6b --- /dev/null +++ b/src/components/features/tasklist/task-recurring/task-recurring-delete-sheet.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { Input, Modal } from "@/components/ui"; +import cn from "@/lib/cn"; +import { DeleteType } from "@/types/task"; +import { useState } from "react"; + +interface TaskDeleteProps { + isOpen: boolean; + onClose: () => void; + onDelete: (type: DeleteType) => Promise; + isPending: boolean; +} + +export default function TaskDeleteSheet({ isOpen, onClose, onDelete, isPending }: TaskDeleteProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [deleteType, setDeleteType] = useState("One"); + + const handleSubmit = async () => { + if (isSubmitting) return; + setIsSubmitting(true); + + try { + await onDelete(deleteType); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + +
+ + '반복일정 모두 삭제' 선택 시
해당 반복일정이 전부 삭제됩니다. +
+
+ 삭제 된 할 일은 복구 할 수 없습니다. +
+
+ + + ) => + setDeleteType(e.target.value as DeleteType) + } + className="mr-[5px] w-[25px]" + /> + 현재 할 일 삭제 + + + ) => + setDeleteType(e.target.value as DeleteType) + } + className="mr-[5px] w-[25px]" + /> + 반복일정 모두 삭제 + + +
+
+ +
+ ); +} diff --git a/src/components/features/tasklist/task-recurring/task-recurring-update-modal.tsx b/src/components/features/tasklist/task-recurring/task-recurring-update-modal.tsx new file mode 100644 index 00000000..7edd956a --- /dev/null +++ b/src/components/features/tasklist/task-recurring/task-recurring-update-modal.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { addTaskListStyle } from "@/app/(routes)/team/[id]/tasklist/index.styles"; +import { Form, Input, Modal } from "@/components/ui"; +import { taskDetailUpdateSchema } from "@/lib/schema"; +import { extractChangedFields } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { Controller, SubmitHandler, useFormContext } from "react-hook-form"; +import z4 from "zod/v4"; + +interface TaskUpdateProps { + name: string; + description?: string; + isOpen: boolean; + onClose: () => void; + onSubmit: (value: z4.infer>) => Promise; + type: FormFieldType; + isPending: boolean; +} + +type FormFieldType = "nameOnly" | "nameAndDescription"; + +export default function TaskDetailUpdateTemplate({ + name, + description, + isOpen, + onClose, + onSubmit, + type, + isPending, +}: TaskUpdateProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit: SubmitHandler< + z4.infer> + > = async data => { + if (isSubmitting) return; + setIsSubmitting(true); + + const changedFields = extractChangedFields(data, name, description); + + try { + await onSubmit(changedFields); + } finally { + setIsSubmitting(false); + } + }; + + const schema = + type === "nameOnly" ? taskDetailUpdateSchema(name) : taskDetailUpdateSchema(name, description); + + return ( + +
+ +
+ + +
+ +
+ ); +} + +function FormField({ type }: { type: FormFieldType }) { + const { + control, + formState: { errors }, + } = useFormContext>>(); + + return ( + +
+ + + ( + <> + + + + )} + /> + + {type !== "nameOnly" && ( + + + ( + <> + + + + )} + /> + + )} +
+
+ ); +} diff --git a/src/components/features/tasklist/task.tsx b/src/components/features/tasklist/task.tsx index 5691a5cb..baa9d6f6 100644 --- a/src/components/features/tasklist/task.tsx +++ b/src/components/features/tasklist/task.tsx @@ -6,21 +6,62 @@ import KebabIcon from "@/assets/icons/ic-kebab.svg"; import CalendarIcon from "@/assets/icons/ic-calendar.svg"; import RepeatIcon from "@/assets/icons/ic-repeat.svg"; import { Task as TaskType } from "@/types/task"; -import { useState } from "react"; import { formatDateToFullStr, getFrequencyLabel } from "@/lib/utils"; +import { useTaskListContext } from "@/app/(routes)/team/[id]/tasklist/[taskListId]/tasklist-provider"; +import { notFound, useParams } from "next/navigation"; +import { useRecurringMutation } from "@/hooks/taskList/use-tasklist"; interface TaskProps { task: TaskType; + onKebabClick: ({ + taskId, + recurringId, + type, + }: { + taskId: number; + recurringId: number; + type: KebabType; + }) => void; + onClick: () => void; } -export default function Task({ task }: TaskProps) { - const [checked, setChecked] = useState(false); +export type KebabType = "update" | "delete"; + +export default function Task({ task, onKebabClick, onClick }: TaskProps) { + const { id: groupId, taskListId } = useParams(); + if (groupId == null || taskListId == null) notFound(); + + const { updateDoneAt: updateRecurringDoneAt } = useRecurringMutation(); + const { dateString } = useTaskListContext(); + + const handleKebabClick = (type: KebabType) => { + onKebabClick({ taskId: task.id, recurringId: task.recurringId, type }); + }; + + const handleCheckBoxChange = (done: boolean) => { + if (!(groupId && taskListId && dateString)) return; + + updateRecurringDoneAt.mutate({ + groupId: Number(groupId), + taskListId: Number(taskListId), + dateString: dateString, + taskId: task.id, + done: done, + }); + }; return ( -
+
- + handleCheckBoxChange(done)} + label={task.name} + />
@@ -48,16 +89,19 @@ export default function Task({ task }: TaskProps) {
-
e.stopPropagation()} className="rounded px-[2px] hover:bg-gray-700"> +
e.stopPropagation()}> - - + + - + handleKebabClick("update")}> 수정하기 - + handleKebabClick("delete")}> 삭제하기 diff --git a/src/components/features/team/badge/index.tsx b/src/components/features/team/badge/index.tsx index 4ed509c8..15c14e79 100644 --- a/src/components/features/team/badge/index.tsx +++ b/src/components/features/team/badge/index.tsx @@ -17,7 +17,7 @@ export default function Badge({ tasks }: { tasks: Task[] }) { return (
{tasks.length === 0 || percent === 100 ? ( - + ) : ( )} diff --git a/src/components/features/team/member-card.tsx b/src/components/features/team/member-card.tsx index 25c84517..19b15cb7 100644 --- a/src/components/features/team/member-card.tsx +++ b/src/components/features/team/member-card.tsx @@ -7,6 +7,8 @@ import { useAlert } from "@/providers/alert-provider"; import deleteMember from "@/api/team/delete-member"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; +import { useToast } from "@/providers/toast-provider"; +import { devConsoleError } from "@/lib/error"; const gridStyle = "grid grid-cols-3 grid-rows-2 grid-cols-[24px_1fr_16px] tablet:grid-cols-[32px_1fr_16px] gap-x-2 tablet:gap-x-3"; @@ -19,6 +21,7 @@ type MemberCardProps = { export default function MemberCard({ member, userRole, userId }: MemberCardProps) { const { showAlert } = useAlert(); + const { showToast } = useToast(); const queryClient = useQueryClient(); const router = useRouter(); @@ -37,12 +40,15 @@ export default function MemberCard({ member, userRole, userId }: MemberCardProps queryClient.invalidateQueries({ queryKey: ["getGroups", groupId], }); + showToast("내보내기가 완료되었습니다.", "success"); } else { router.replace("/"); + //TODO 랜딩페이지에 추가 } }, onError: error => { - console.log(error.message); + devConsoleError(error); + showToast("문제가 발생했습니다.", "error"); }, }); diff --git a/src/components/features/team/modal/invite-modal.tsx b/src/components/features/team/modal/invite-modal.tsx new file mode 100644 index 00000000..f11e3776 --- /dev/null +++ b/src/components/features/team/modal/invite-modal.tsx @@ -0,0 +1,43 @@ +import { Modal } from "@/components/ui"; +import getInviteToeken from "@/api/team/get-invite-token"; +import { useQuery } from "@tanstack/react-query"; +import { TeamModalProps } from "../team.props"; +import { useToast } from "@/providers/toast-provider"; +import { devConsoleError } from "@/lib/error"; + +export const InviteModal = ({ isOpen, groupId, onClose }: TeamModalProps) => { + const { showToast } = useToast(); + + const { data, refetch } = useQuery({ + queryKey: ["getInviteToken", groupId], + queryFn: () => getInviteToeken(groupId), + }); + + const copyToClipboard = async (text: string) => { + try { + console.log(text); + await navigator.clipboard.writeText(text); + showToast("클립보드에 복사되었습니다.", "success"); + onClose(); + } catch (err) { + showToast("클립보드 복사 실패:", "error"); + devConsoleError(err); + } + }; + + const handleGetToken = () => { + refetch(); + copyToClipboard(data); + }; + return ( + + + +

+ 팀에 참여할 수 있는 링크를 복사합니다. +

+
+ +
+ ); +}; diff --git a/src/components/features/team/modal/todo-create-modal.tsx b/src/components/features/team/modal/todo-create-modal.tsx new file mode 100644 index 00000000..b6271e49 --- /dev/null +++ b/src/components/features/team/modal/todo-create-modal.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import { Modal, Input } from "@/components/ui"; +import postTodo from "@/api/team/post-todo"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { TeamModalProps } from "../team.props"; +import { useToast } from "@/providers/toast-provider"; +import { devConsoleError } from "@/lib/error"; + +export const TodoListCreateModal = ({ isOpen, groupId, onClose }: TeamModalProps) => { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + const [todoName, setTodoName] = useState(""); + + const { mutate, isPending } = useMutation({ + mutationFn: postTodo, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["getGroups", groupId], + }); + showToast("할 일 목록이 만들어졌습니다.", "success"); + onClose(); + }, + onError: error => { + showToast("할일 목록 생성에 문제가 생겼습니다.", "error"); + devConsoleError(error); + }, + }); + + const handleNameChange = (event: React.ChangeEvent) => { + const inputName = event.target.value; + setTodoName(inputName); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + mutate({ groupId, param: todoName }); + }; + + return ( + +
+ + + + + + + + +
+ ); +}; diff --git a/src/components/features/team/modal/todo-edit-modal.tsx b/src/components/features/team/modal/todo-edit-modal.tsx new file mode 100644 index 00000000..28bbc5f3 --- /dev/null +++ b/src/components/features/team/modal/todo-edit-modal.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import { Modal, Input } from "@/components/ui"; +import patchTodo from "@/api/team/patch-todo"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { TodoEditProps } from "../team.props"; +import { useToast } from "@/providers/toast-provider"; +import { devConsoleError } from "@/lib/error"; + +export const TodoListEditModal = ({ + isOpen, + groupId, + onClose, + taskListId, + taskListName, +}: TodoEditProps) => { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + const [todoName, setTodoName] = useState(taskListName); + + const { mutate, isPending, isSuccess } = useMutation({ + mutationFn: patchTodo, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["getGroups", groupId], + }); + showToast("할 일 목록이 수정되었습니다.", "success"); + onClose(); + }, + onError: error => { + showToast("할 일 목록 수정에 문제가 생겼습니다.", "error"); + devConsoleError(error); + }, + }); + + const handleNameChange = (event: React.ChangeEvent) => { + const inputName = event.target.value; + setTodoName(inputName); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!todoName) { + alert("할 일 목록의 제목은 공란일 수 없습니다."); + return; + } + + mutate({ groupId, taskListId, name: todoName }); + }; + + if (isSuccess) onClose(); + + return ( + +
+ + + + + + + + +
+ ); +}; diff --git a/src/components/features/team/task-list.tsx b/src/components/features/team/task-list.tsx index dd24c86e..ee9e2e0e 100644 --- a/src/components/features/team/task-list.tsx +++ b/src/components/features/team/task-list.tsx @@ -1,31 +1,74 @@ import Link from "next/link"; import cn from "@/lib/cn"; -import Badge from "./badge"; +import { useState, useCallback } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useToggle } from "@/hooks"; +import { useAlert } from "@/providers/alert-provider"; import IcKebab from "@/assets/icons/ic-kebab.svg"; +import Badge from "./badge"; import { Dropdown } from "@/components/ui"; import { TodoListProps } from "@/types/group"; import { todoListStyle, TODO_COLORS } from "./team.styles"; +import { TodoListCreateModal } from "./modal/todo-create-modal"; +import { TodoListEditModal } from "./modal/todo-edit-modal"; +import deleteTodo from "@/api/team/delete-todo"; +import { useToast } from "@/providers/toast-provider"; +import { devConsoleError } from "@/lib/error"; export default function TodoList({ groupId, taskList = [] }: TodoListProps) { if (!taskList) return null; + const queryClient = useQueryClient(); + const { showAlert } = useAlert(); + const { showToast } = useToast(); + const [editTaskId, setEditTaskId] = useState(null); + + const { + isOpen: isCreateModalOpen, + setOpen: setCreateModalOpen, + setClose: setCreateModalClose, + } = useToggle(); + + const { + isOpen: isEditModalOpen, + setOpen: setEditModalOpen, + setClose: setEditModalClose, + } = useToggle(); + + const handleEditModalClose = () => { + setEditModalClose(); + setEditTaskId(null); + }; + const colorChanger = (id: number) => { let colorIndex = id % TODO_COLORS.length; return TODO_COLORS[colorIndex]; }; - const handleSetSession = (id: number, e: React.MouseEvent) => { - handleEventPrevent(e); - sessionStorage.setItem("taskListId", String(id)); - }; + const { mutate } = useMutation({ + mutationFn: deleteTodo, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["getGroups", groupId], + }); + showToast("할 일 목록이 삭제되었습니다", "success"); + }, + onError: error => { + showToast("할 일 목록 삭제에 문제가 발생했습니다.", "error"); + devConsoleError(error); + }, + }); - const handleEventPrevent = (e: React.MouseEvent) => { - const isDropdown = (e.target as HTMLElement).closest("[data-dropdown-trigger]"); - if (isDropdown) { - e.preventDefault(); - e.stopPropagation(); - } - }; + const handleTodoDelete = useCallback( + async (id: number, name: string) => { + const message = `${name}을(를) 삭제하시겠습니까?`; + const confirmed = await showAlert(message); + if (confirmed) { + mutate({ groupId, taskListId: id }); + } + }, + [groupId], + ); return (
@@ -34,40 +77,58 @@ export default function TodoList({ groupId, taskList = [] }: TodoListProps) {

할 일 목록

({taskList.length}개)
- +
{taskList && taskList.map(task => ( - handleSetSession(task.id, e)} - > -
+
+ {task.name} -
- -
handleEventPrevent(e)}> - - - - - - - 수정하기 - - - 삭제하기 - - - -
-
+ +
+ + + + + + + (setEditTaskId(task.id), setEditModalOpen())} + > + 수정하기 + + handleTodoDelete(task.id, task.name)} + > + 삭제하기 + + +
- + {isEditModalOpen && editTaskId === task.id && ( + + )} +
))} + {isCreateModalOpen && ( + + )} ); } diff --git a/src/components/features/team/team-member.tsx b/src/components/features/team/team-member.tsx index 166a9f79..be9748d5 100644 --- a/src/components/features/team/team-member.tsx +++ b/src/components/features/team/team-member.tsx @@ -1,14 +1,18 @@ -import Link from "next/link"; import { Member } from "@/types/tasklist"; import MemberCard from "./member-card"; +import { useToggle } from "@/hooks"; +import { InviteModal } from "./modal/invite-modal"; type TeamMemberProps = { members: Member[]; userId: number; userRole: string; + groupId: number; }; -export default function TeamMember({ members, userId, userRole }: TeamMemberProps) { +export default function TeamMember({ members, userId, userRole, groupId }: TeamMemberProps) { + const { isOpen, setOpen, setClose } = useToggle(); + return (
@@ -16,15 +20,16 @@ export default function TeamMember({ members, userId, userRole }: TeamMemberProp

멤버

({members.length}명)
- +
{members.map(mb => ( ))}
+ {isOpen && } ); } diff --git a/src/components/features/team/team-title.tsx b/src/components/features/team/team-title.tsx index 23a20629..3a3d62f4 100644 --- a/src/components/features/team/team-title.tsx +++ b/src/components/features/team/team-title.tsx @@ -1,25 +1,64 @@ +"use client"; import IcGear from "@/assets/icons/ic-setting.svg"; +import { useCallback } from "react"; import { Dropdown } from "@/components/ui"; import { teamTitleProps } from "@/types/group"; import { teamTitleStyle } from "./team.styles"; +import deleteTeam from "@/api/team/delete-team"; +import { useAlert } from "@/providers/alert-provider"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useToast } from "@/providers/toast-provider"; +import { devConsoleError } from "@/lib/error"; + +export default function TeamTitle({ name, id, userRole }: teamTitleProps) { + const router = useRouter(); + const { showAlert } = useAlert(); + const { showToast } = useToast(); + + const { mutate } = useMutation({ + mutationFn: deleteTeam, + onSuccess: () => { + router.replace("/"); + //TODO: 랜딩페이지에서도 토스트 받을 수 있도록 수정 필요 + }, + onError: error => { + showToast("팀 삭제에 문제가 생겼습니다.", "error"); + devConsoleError(error); + }, + }); + + const handleTeamDelete = useCallback( + async (id: number) => { + const message = `${name}팀을 삭제하시겠습니까?`; + const confirmed = await showAlert(message); + if (confirmed) { + mutate(id); + } + }, + [id], + ); -export default function TeamTitle({ name, id }: teamTitleProps) { return ( -
-

{name}

- - - - - - - 수정하기 - - - 삭제하기 - - - -
+ <> +
+

{name}

+ {userRole === "ADMIN" && ( + + + + + + + 수정하기 + + handleTeamDelete(id)}> + 삭제하기 + + + + )} +
+ ); } diff --git a/src/components/features/team/team.props.ts b/src/components/features/team/team.props.ts index 8089a081..14f84b88 100644 --- a/src/components/features/team/team.props.ts +++ b/src/components/features/team/team.props.ts @@ -25,3 +25,14 @@ export const reportChartProps = { number: false, linearGradient: ["var(--pink-300)", "var(--pink-700)"], }; + +export type TeamModalProps = { + groupId: number; + onClose: () => void; + isOpen: boolean; +}; + +export type TodoEditProps = { + taskListId: number; + taskListName: string; +} & TeamModalProps; diff --git a/src/components/layout/.gitkeep b/src/components/layout/.gitkeep deleted file mode 100644 index 561b17f5..00000000 --- a/src/components/layout/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -// layout 컴포넌트 디렉토리 diff --git a/src/components/layout/header/header.tsx b/src/components/layout/header/header.tsx index 98f3de92..0e8f9bbb 100644 --- a/src/components/layout/header/header.tsx +++ b/src/components/layout/header/header.tsx @@ -23,7 +23,7 @@ export default function Header({ isLoginPage, groups, user }: HeaderProps) { }; if (isLoginPage) { return ( -
+
@@ -36,7 +36,7 @@ export default function Header({ isLoginPage, groups, user }: HeaderProps) { } return ( -
+
{open && }
diff --git a/src/components/skeleton-ui/card-skeleton.tsx b/src/components/skeleton-ui/card-skeleton.tsx index 723b5e2d..3171c62e 100644 --- a/src/components/skeleton-ui/card-skeleton.tsx +++ b/src/components/skeleton-ui/card-skeleton.tsx @@ -38,20 +38,8 @@ export default function CardSkeleton({ )} />
-
-
- +
+
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ); +} + +export function TaskCardSkeleton() { + return ( +
+
+
+ + +
+ +
+
+ ); +} + +export function TaskListSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ); +} + +export function TaskDetailHeaderSkeleton() { + return ( +
+ + +
+ ); +} + +export function TaskDetailAuthorSkeleton() { + return ( +
+
+ + +
+ +
+ ); +} + +export function TaskDetailFrequencySkeleton() { + return ( +
+
+ + +
+ | +
+ + +
+ | +
+ + +
+
+ ); +} + +export function TaskDetailDescriptionSkeleton() { + return ( +
+ + + +
+ ); +} + +export function CommentInputSkeleton() { + return ( +
+ +
+ ); +} + +export function CommentItemSkeleton() { + return ( +
+
+
+ + +
+ +
+ + +
+ ); +} + +export function CommentsListSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ); +} + +export function TaskDetailSkeleton() { + return ( + <> +
+ + + + +
+ +
+ + +
+ + ); +} diff --git a/src/components/skeleton-ui/team-edit-skeleton.tsx b/src/components/skeleton-ui/team-edit-skeleton.tsx new file mode 100644 index 00000000..1169ec1c --- /dev/null +++ b/src/components/skeleton-ui/team-edit-skeleton.tsx @@ -0,0 +1,23 @@ +import Skeleton from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { Container } from "../layout"; + +export default function TeamEditSkeleton() { + return ( + +
+ +
+ + + + + + + +
+ +
+
+ ); +} diff --git a/src/components/skeleton-ui/team-skeleton.tsx b/src/components/skeleton-ui/team-skeleton.tsx new file mode 100644 index 00000000..26ddf677 --- /dev/null +++ b/src/components/skeleton-ui/team-skeleton.tsx @@ -0,0 +1,39 @@ +import Skeleton from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { Container } from "../layout"; + +export default function TeamSkeleton() { + return ( + + +
+
+ + +
+ + + +
+
+ + +
+
+
+ + +
+
+ + + +
+
+
+ ); +} diff --git a/src/components/ui/.gitkeep b/src/components/ui/.gitkeep deleted file mode 100644 index 18ae20ae..00000000 --- a/src/components/ui/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -// ui 컴포넌트 디렉토리 diff --git a/src/components/ui/alert/alert.styles.ts b/src/components/ui/alert/alert.styles.ts index 1757aaeb..856a8487 100644 --- a/src/components/ui/alert/alert.styles.ts +++ b/src/components/ui/alert/alert.styles.ts @@ -1,26 +1,16 @@ import { cva } from "class-variance-authority"; -export const alertOverlayStyle = - "fixed inset-0 z-[9999] flex items-center justify-center bg-modal-dimmed"; +export const alertOverlayStyle = [ + "fixed inset-0 flex items-center justify-center z-[9000] ", + "custom-dialog-backdrop bg-transparent overflow-hidden", + "rounded-t-xl", +].join(" "); -export const alertContainerStyle = cva( - [ - "flex flex-col bg-gray-800 px-4 pb-8 pt-4 text-center items-center rounded-t-xl h-auto", - "p-0 tablet:px-4 tablet:pb-8 tablet:pt-4", - "absolute bottom-0 mobile:relative mobile:bottom-auto mobile:rounded-bl-0 mobile:rounded-b-xl", - ], - { - variants: { - size: { - md: "max-w-[375px] w-[100%]", - lg: "max-w-[384px] w-[100%]", - }, - }, - defaultVariants: { - size: "lg", - }, - }, -); +export const alertContainerStyle = [ + "flex flex-col bg-gray-800 px-4 pb-8 pt-4 text-center items-center rounded-t-xl h-auto w-[100vw] max-w-[384px]", + "p-0 tablet:px-4 tablet:pb-8 tablet:pt-4", + "fixed bottom-0 mobile:relative mobile:bottom-auto mobile:rounded-bl-0 mobile:rounded-b-xl", +].join(" "); export const alertIcon = "mt-[24px] w-[24px] h-[24px]"; diff --git a/src/components/ui/alert/alert.tsx b/src/components/ui/alert/alert.tsx index ca5e137e..123bc985 100644 --- a/src/components/ui/alert/alert.tsx +++ b/src/components/ui/alert/alert.tsx @@ -12,6 +12,8 @@ import { } from "./alert.styles"; import AlertIcon from "@/assets/icons/ic-alert.svg"; import { Button } from ".."; +import { createPortal } from "react-dom"; +import { useEffect, useRef } from "react"; interface AlertProps { isOpen: boolean; @@ -32,11 +34,48 @@ export default function Alert({ onCancel, type, }: AlertProps) { + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + if (isOpen && !dialog.open) dialog.showModal(); + }, [isOpen]); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + const handleClose = () => onCancel(); + dialog.addEventListener("close", handleClose); + return () => dialog.removeEventListener("close", handleClose); + }, [onCancel]); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + const handleEscKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onCancel(); + } + }; + + if (isOpen) { + document.addEventListener("keydown", handleEscKey); + } + + return () => { + document.removeEventListener("keydown", handleEscKey); + }; + }, [isOpen, onCancel]); + if (!isOpen) return null; - return ( -
-
e.stopPropagation()}> + return createPortal( + +
e.stopPropagation()}> {(type === ALERT_TYPE.Leave || type === ALERT_TYPE.DeleteComment || type === ALERT_TYPE.DeleteArticle) && ( @@ -79,6 +118,7 @@ export default function Alert({
-
+ , + document.body, ); } diff --git a/src/components/ui/avatar/index.tsx b/src/components/ui/avatar/index.tsx index a9a16b14..61c01aa3 100644 --- a/src/components/ui/avatar/index.tsx +++ b/src/components/ui/avatar/index.tsx @@ -26,6 +26,7 @@ export default function Avatar({ image, alt = "", shape = "basic", className }: src={image} alt={alt} fill + sizes="100%" className="object-cover" draggable={false} onError={() => setImageError(true)} diff --git a/src/components/ui/card/card-content.tsx b/src/components/ui/card/card-content.tsx index 62e006ae..ddd75724 100644 --- a/src/components/ui/card/card-content.tsx +++ b/src/components/ui/card/card-content.tsx @@ -34,6 +34,7 @@ export default function CardContent({ src={image} fill alt="" + sizes="100%" className="h-full w-full object-cover" draggable={false} placeholder="blur" diff --git a/src/components/ui/card/card-info.tsx b/src/components/ui/card/card-info.tsx index 9ec4c362..68d874c4 100644 --- a/src/components/ui/card/card-info.tsx +++ b/src/components/ui/card/card-info.tsx @@ -1,13 +1,11 @@ import cn from "@/lib/cn"; import { getTimeAgo, formatDateToFullStr, clampText } from "@/lib/utils"; import { DISPLAY_LIMITS } from "@/constants/display"; -import { Avatar } from "@/components/ui"; import { CARD_INFO_STYLES } from "./index.styles"; import IcComment from "@/assets/icons/ic-comment.svg"; import IcLike from "@/assets/icons/ic-heart.svg"; type CardInfoProps = { - image?: string | null; writer: string; createdAt: string; likeCount: number; @@ -17,7 +15,6 @@ type CardInfoProps = { }; export default function CardInfo({ - image, writer, createdAt, likeCount, @@ -29,7 +26,6 @@ export default function CardInfo({
- 작성자 {writer}
diff --git a/src/components/ui/card/index.stories.tsx b/src/components/ui/card/index.stories.tsx index 67ad27bb..f690cb63 100644 --- a/src/components/ui/card/index.stories.tsx +++ b/src/components/ui/card/index.stories.tsx @@ -65,7 +65,6 @@ export const Default: Story = { createdAt="2025-11-11T10:30:00Z" likeCount={11111} commentCount={1234} - image="/assets/images/img-test.jpeg" /> ), @@ -85,7 +84,6 @@ export const WithLink: Story = { createdAt="2025-11-11T10:30:00Z" likeCount={42} commentCount={1234} - image="/assets/images/img-test.jpeg" /> ), @@ -105,7 +103,6 @@ export const WithActions: Story = { createdAt="2025-11-11T10:30:00Z" likeCount={42} commentCount={1234} - image="/assets/images/img-test.jpeg" /> ), @@ -126,7 +123,6 @@ export const WithLinkAndActions: Story = { createdAt="2025-11-11T10:30:00Z" likeCount={42} commentCount={1234} - image="/assets/images/img-test.jpeg" /> ), @@ -150,7 +146,6 @@ export const variant: Story = { createdAt="2025-11-11T10:30:00Z" likeCount={42} commentCount={1234} - image="/assets/images/img-test.jpeg" /> ), @@ -172,7 +167,6 @@ export const WithoutImage: Story = { createdAt="2025-11-11T10:30:00Z" likeCount={42} commentCount={1234} - image="/assets/images/img-test.jpeg" /> @@ -183,7 +177,6 @@ export const WithoutImage: Story = { createdAt="2025-11-11T10:30:00Z" likeCount={42} commentCount={1234} - image="/assets/images/img-test.jpeg" />
@@ -206,7 +199,6 @@ export const TestGrid: Story = { createdAt="2025-11-11T10:30:00Z" likeCount={11111} commentCount={1234} - image="/assets/images/img-test.jpeg" /> @@ -216,7 +208,6 @@ export const TestGrid: Story = { createdAt="2025-11-01T10:30:00Z" likeCount={11111} commentCount={1234} - image="/assets/images/img-test.jpeg" /> @@ -227,7 +218,6 @@ export const TestGrid: Story = { createdAt="2025-11-11T10:30:00Z" likeCount={11111} commentCount={1234} - image="/assets/images/img-test.jpeg" /> @@ -237,7 +227,6 @@ export const TestGrid: Story = { createdAt="2025-11-11T10:30:00Z" likeCount={11111} commentCount={1234} - image="/assets/images/img-test.jpeg" /> diff --git a/src/components/ui/card/index.styles.ts b/src/components/ui/card/index.styles.ts index 738982f9..7fbb3578 100644 --- a/src/components/ui/card/index.styles.ts +++ b/src/components/ui/card/index.styles.ts @@ -41,7 +41,7 @@ export const CARD_CONTENT_STYLES = { export const CARD_INFO_STYLES = { wrapper: cva( - "grid grid-cols-[auto_60px] gap-x-[12px] items-end justify-between tablet:grid-cols-[auto_130px]", + "grid grid-cols-[auto_60px] gap-x-[12px] items-end justify-between tablet:grid-cols-[auto_150px]", { variants: { variant: { @@ -66,7 +66,7 @@ export const CARD_INFO_STYLES = { variant: "primary", }, }), - writer: cva("grid grid-cols-[30px_auto] items-center gap-x-[12px] pr-[12px]", { + writer: cva("pr-[12px]", { variants: { variant: { primary: "order-2 tablet:order-1", @@ -77,7 +77,6 @@ export const CARD_INFO_STYLES = { variant: "primary", }, }), - avatar: cn("w-[32px] h-[32px]"), nickname: cn("text-[12px] text-gray-100 line-clamp-1 break-words block", "tablet:text-[14px]"), time: cva( "text-[12px] text-gray-400 mb-[16px] block tablet:before:relative tablet:text-[14px] tablet:mb-0 tablet:before:content-[''] tablet:before:inline-block tablet:before:w-[1px] tablet:before:h-[12px] tablet:before:bg-gray-700 tablet:before:mr-[12px] tablet:before:top-[1px]", diff --git a/src/components/ui/date-timepicker/single-datepicker.tsx b/src/components/ui/date-timepicker/single-datepicker.tsx index dd59eddc..b137d0a3 100644 --- a/src/components/ui/date-timepicker/single-datepicker.tsx +++ b/src/components/ui/date-timepicker/single-datepicker.tsx @@ -4,6 +4,7 @@ import Datepicker from "react-datepicker"; import { useDatepickerDate } from "@/hooks"; import CustomHeader from "./custom-datepicker-header"; import { cuttingDayString, otherMonthIndicator } from "@/lib/utils"; +import { ko } from "date-fns/locale"; interface CustomSingleDatepickerProps { startDate: Date | null; @@ -29,6 +30,7 @@ export default function CustomSingleDatepicker({ onMonthChange={handleMonthChange} dayClassName={date => otherMonthIndicator(date, currentMonth, currentYear)} minDate={useMinDate ? new Date() : undefined} + locale={ko} /> ); } diff --git a/src/components/ui/date-timepicker/timepicker.tsx b/src/components/ui/date-timepicker/timepicker.tsx index 1a38a4c3..ea8d9ac3 100644 --- a/src/components/ui/date-timepicker/timepicker.tsx +++ b/src/components/ui/date-timepicker/timepicker.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useRef, useState } from "react"; import Button from "../button/button"; import cn from "@/lib/cn"; -import { isEmpty } from "@/lib/utils"; const TIME_PERIOD = { AM: "오전", @@ -50,13 +49,12 @@ export default function CustomTimePicker({ selectedTime, onTimeChange }: CustomT }, [selectedTime]); const handleTimeClick = (index: number, newPeriod?: TimePeriod) => { - if (isEmpty(newPeriod)) newPeriod = TIME_PERIOD.AM; + const currentPeriod = newPeriod || period; setSelectedIndex(index); const time = times[index]; let hour = time.hour % 12; - if (newPeriod === TIME_PERIOD.PM) hour += 12; - if (newPeriod === TIME_PERIOD.AM && hour === 12) hour = 0; - + if (currentPeriod === TIME_PERIOD.PM) hour += 12; + if (currentPeriod === TIME_PERIOD.AM && hour === 12) hour = 0; const newTime = new Date(); newTime.setHours(hour, time.minute, 0, 0); onTimeChange(newTime); @@ -79,6 +77,7 @@ export default function CustomTimePicker({ selectedTime, onTimeChange }: CustomT )} intent="primary" onClick={() => handlePeriodChange(p)} + type="button" > {p} @@ -97,6 +96,7 @@ export default function CustomTimePicker({ selectedTime, onTimeChange }: CustomT ? "font-semibold text-pink-500" : "text-gray-300 hover:bg-gray-700", )} + type="button" > {time.label} diff --git a/src/components/ui/dropdown/trigger-select.tsx b/src/components/ui/dropdown/trigger-select.tsx index 7dfb3ba2..026c9cea 100644 --- a/src/components/ui/dropdown/trigger-select.tsx +++ b/src/components/ui/dropdown/trigger-select.tsx @@ -20,6 +20,7 @@ export function TriggerSelect({ dropDownTriggerStyle({ size: ctx?.size, intent, className }), ctx?.isOpen && "bg-gray-700", )} + type="button" > {selectedLabel} {isIcon && children} diff --git a/src/components/ui/img-upload/index.tsx b/src/components/ui/img-upload/index.tsx index 1d5c0cbd..a74297e4 100644 --- a/src/components/ui/img-upload/index.tsx +++ b/src/components/ui/img-upload/index.tsx @@ -95,7 +95,7 @@ export default function ImgUpload({ value, onChange, id, error }: ImgUploadProps fill className={IMG_UPLOAD_STYLES.image} priority - sizes="100vw" + sizes="100%" /> @@ -89,15 +94,17 @@ const FooterWithButtons = ({ confirmButtonTitle, onConfirm, isSubmit = false, + disabled = false, }: { confirmButtonTitle: string; onConfirm?: () => void; isSubmit?: boolean; + disabled?: boolean; }) => { const { onClose } = useModalContext(); return ( -
+
@@ -105,6 +112,7 @@ const FooterWithButtons = ({ className="w-[136px]" onClick={!isSubmit ? onConfirm : undefined} type={isSubmit ? `submit` : `button`} + disabled={disabled} > {confirmButtonTitle} diff --git a/src/components/ui/modal/modal.style.ts b/src/components/ui/modal/modal.style.ts index 66d53405..0fcfe689 100644 --- a/src/components/ui/modal/modal.style.ts +++ b/src/components/ui/modal/modal.style.ts @@ -1,7 +1,7 @@ import { cva } from "class-variance-authority"; export const modalOverlayStyle = [ - "fixed inset-0 z-[9000] flex items-center justify-center", + "fixed inset-0 z-[5000] flex items-center justify-center", "custom-dialog-backdrop bg-transparent overflow-hidden", "rounded-t-xl", ].join(" "); diff --git a/src/components/ui/reply/index.styles.ts b/src/components/ui/reply/index.styles.ts index 5e7135b7..1b5ccfa4 100644 --- a/src/components/ui/reply/index.styles.ts +++ b/src/components/ui/reply/index.styles.ts @@ -34,6 +34,10 @@ export const replyTextarea = cva( primary: "text-body-m placeholder:text-gray-400", secondary: "text-body-s placeholder:text-gray-500", }, + isAuthor: { + true: "max-w-[calc(100%_-_20px)]", + false: "", + }, }, defaultVariants: { variant: "primary", diff --git a/src/components/ui/reply/index.tsx b/src/components/ui/reply/index.tsx index 73c69235..1ed7d5ec 100644 --- a/src/components/ui/reply/index.tsx +++ b/src/components/ui/reply/index.tsx @@ -66,6 +66,7 @@ export default function Reply({ id={`comment-${comment.id}`} value={editedContent.replace(/\\n/g, "\n")} placeholder="수정할 댓글을 입력해주세요" + maxLength={255} onChange={e => { setEditedContent(e.target.value); onChange(e); @@ -98,10 +99,10 @@ export default function Reply({ ) : ( <> -

{comment.content}

+

{comment.content}

-
- +
+ 작성자 {comment.user.nickname}
diff --git a/src/components/ui/reply/reply-input.tsx b/src/components/ui/reply/reply-input.tsx index 5d299d67..f94b9fc7 100644 --- a/src/components/ui/reply/reply-input.tsx +++ b/src/components/ui/reply/reply-input.tsx @@ -62,6 +62,7 @@ export default function ReplyInput({ autoResizeChange(e, onChange); }} readOnly={!isLoggedIn} + maxLength={255} /> {variant === "primary" && (